diff --git a/src/app/api/update-user/route.ts b/src/app/api/update-user/route.ts index 8345426..81b9cfb 100644 --- a/src/app/api/update-user/route.ts +++ b/src/app/api/update-user/route.ts @@ -13,7 +13,17 @@ export async function POST(req: Request) { if ("user" in authResult === false) return authResult; const { user } = authResult; - const { email, name, password } = await req.json(); + // todo handle requestedRole + const { userId, email, name, password } = await req.json(); + + // Trying to update a different user than themselves + // Only available to admins + // todo add senior scientists being able to update their juniors + if (userId && userId !== user.id) { + if (user.role !== "ADMIN") { + return NextResponse.json({ message: "Not authorised" }, { status: 401 }); + } + } // Check if email is already in use by another user if (email && email !== user.email) { @@ -25,6 +35,7 @@ export async function POST(req: Request) { } } + // todo move to dedicated function // Validate password strength if provided let passwordHash = user.passwordHash; if (password) { @@ -47,9 +58,8 @@ export async function POST(req: Request) { passwordHash = await bcryptjs.hash(password, 10); } - // Update user in database const updatedUser = await prisma.user.update({ - where: { id: user.id }, + where: { id: userId || user.id }, data: { name: name || user.name, email: email || user.email, @@ -57,7 +67,7 @@ export async function POST(req: Request) { }, }); - // Link orders with matching email to the updated user + // Link non-account orders with matching email to the updated user if (email && email !== user.email) { await prisma.order.updateMany({ where: { diff --git a/src/app/api/user/route.ts b/src/app/api/user/route.ts index a6da762..ffd9b1a 100644 --- a/src/app/api/user/route.ts +++ b/src/app/api/user/route.ts @@ -31,7 +31,8 @@ export async function GET() { }); if (user) { - return NextResponse.json({ message: "Got user successfully", user }, { status: 200 }); + const { passwordHash: _, ...userSansHash } = user; + return NextResponse.json({ message: "Got user successfully", user: userSansHash }, { status: 200 }); } else { cookieStore.delete("jwt"); // Delete JWT cookie if user not found } return NextResponse.json({ message: "Failed to get user" }, { status: 401 }); diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index f8a67c7..02b4240 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -1,210 +1,399 @@ "use client"; +import { useStoreState } from "@hooks/store"; import axios, { AxiosError } from "axios"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { FaSignOutAlt } from "react-icons/fa"; +import { useEffect, useState, useRef } from "react"; import { FaUser } from "react-icons/fa6"; - import { User } from "@appTypes/Prisma"; import { useStoreActions } from "@hooks/store"; -// todo add previous orders list +function CustomRoleDropdown({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (role: string) => void; + disabled: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const roles = ["ADMIN", "SCIENTIST", "GUEST"] as const; + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + return ( +
+ + {isOpen && ( +
+
    + {roles.map((role) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +} export default function Profile() { const router = useRouter(); - const setUser = useStoreActions((actions) => actions.setUser) as (user: User | null) => void; - const [user, setUserState] = useState(null); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [role, setRole] = useState(""); - const [error, setError] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); + const user = useStoreState((state) => state.user); + const setUser = useStoreActions((actions) => actions.setUser); + const [activeTab, setActiveTab] = useState<"profile" | "orders" | "account">("profile"); - useEffect(() => { - const fetchUser = async () => { - try { - const userData: User = { - id: 1, - createdAt: new Date(), - name: "John Doe", - email: "john.doe@example.com", - passwordHash: "hashed_password", - role: "SCIENTIST", - scientist: undefined, - purchasedArtefacts: [], - }; - setUserState(userData); - setName(userData.name); - setEmail(userData.email); - setRole(userData.role); - } catch { - setError("Failed to load user data."); - } - }; - fetchUser(); - }, []); - - const handleSave = async () => { - if (!name || !email) { - setError("Name and email are required."); - return; - } - setIsSubmitting(true); - try { - // todo create receiving api route - // todo handle sending fields to api route - await new Promise((resolve) => setTimeout(resolve, 500)); - setUserState({ ...user!, name, email, role }); - alert("Profile updated successfully."); - setError(""); - } catch { - setError("Failed to update profile. Please try again."); - } finally { - setIsSubmitting(false); - } - }; - - const handleLogout = async () => { + async function handleLogout() { try { const res = await axios.get("/api/logout"); if (res.status === 200) { setUser(null); router.push("/"); - } else { - console.error("Failed to logout", res.data); } } catch (error) { const axiosError = error as AxiosError<{ message: string }>; if (axiosError.response && axiosError.response.status === 400) { setUser(null); router.push("/"); - } else { - console.error("Error during logout", axiosError); } } - }; + } - return ( -
-
-
- + useEffect(() => { + !user && router.push("/"); + }, []); + + function ProfileTab() { + if (!user) return

No user data available

; + + const isScientistOrAdmin = user.role === "SCIENTIST" || user.role === "ADMIN"; + + const stats = [ + ...(isScientistOrAdmin + ? [ + { label: "Earthquakes Logged", value: user.earthquakes.length }, + { label: "Observatories Logged", value: user.observatories.length }, + { label: "Artefacts Logged", value: user.artefacts.length }, + ] + : []), + { label: "Orders Placed", value: user.purchasedOrders.length }, + ]; + + return ( +
+
+

Profile Overview

+ {user.role !== "GUEST" &&

{user.role}

}
-

User Profile

-
-
- {error &&

{error}

} -
-
- +
+

User since {new Date(user.createdAt).toLocaleDateString("en-GB")}

+
+ {stats.map((stat) => ( +
+

{stat.label}

+

{stat.value}

-
-
- - setName(e.target.value)} - className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500 text-sm" - aria-label="User Name" - disabled={isSubmitting} - /> + ))} +
+ {user.scientist && ( +
+

Scientist Details

+
+
+

Level

+

{user.scientist.level}

-
- - setEmail(e.target.value)} - className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500 text-sm" - aria-label="User Email" - disabled={isSubmitting} - /> +
+

Superior

+

{user.scientist.superior?.name || "None"}

-
- - -
-
- - setPassword(e.target.value)} - className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500 text-sm" - disabled={isSubmitting} - /> -
-
- - setConfirmPassword(e.target.value)} - className="w-full p-2 border border-neutral-300 rounded-md placeholder-neutral-400 focus:ring-2 focus:ring-blue-500 text-sm" - disabled={isSubmitting} - /> -
-
-
-
- - +
+

Subordinates

+

{user.scientist.subordinates.length}

-
+ )} +
+
+ ); + } + + function OrdersTab() { + return ( +
+

Previous Orders

+ {user!.purchasedOrders.length === 0 ? ( +

No previous orders.

+ ) : ( +
    + {user!.purchasedOrders.map((order) => ( +
  • + Order #{order.id} +
  • + ))} +
+ )} +
+ ); + } + + function AccountTab() { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [requestedRole, setRequestedRole] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + const router = useRouter(); + + useEffect(() => { + if (user) { + setName(user.name); + setEmail(user.email); + } + }, [user]); + + async function handleSave() { + setError(""); + setSuccessMessage(""); + if (!name || !email) { + setError("Name and email are required."); + return; + } + if (password && password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + setIsSubmitting(true); + try { + const res = await axios.post( + "/api/update-user", + { name, email, password, requestedRole }, + { headers: { "Content-Type": "application/json" } } + ); + if (res.status === 200) { + setUser(res.data.user); + setSuccessMessage("Account updated successfully."); + if (requestedRole) { + setSuccessMessage("Account updated and role change request submitted."); + setRequestedRole(""); + } + } else { + setError("Unexpected error occurred"); + } + } catch (error) { + const axiosError = error as AxiosError<{ message: string }>; + setError(axiosError.response?.data?.message || "Network error occurred"); + } finally { + setIsSubmitting(false); + } + } + + async function handleDeleteAccount() { + if (!confirm("Are you sure you want to delete your account? This action cannot be undone.")) { + return; + } + setIsDeleting(true); + try { + const res = await axios.post( + "/api/delete-user", + { userId: user!.id }, + { headers: { "Content-Type": "application/json" } } + ); + if (res.status === 200) { + setUser(null); + router.push("/"); + } else { + setError("Failed to delete account"); + } + } catch (error) { + setError("Error deleting account"); + } finally { + setIsDeleting(false); + } + } + + function handleClear() { + setName(user?.name || ""); + setEmail(user?.email || ""); + setPassword(""); + setConfirmPassword(""); + setRequestedRole(""); + setError(""); + setSuccessMessage(""); + } + + return ( +
+

Account Settings

+ {successMessage &&

{successMessage}

} + {error &&

{error}

} +
+
+
+ + setName(e.target.value)} + className="w-full p-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-blue-500 text-sm" + disabled={isSubmitting} + /> +
+
+ + setEmail(e.target.value)} + className="w-full p-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-blue-500 text-sm" + disabled={isSubmitting} + /> +
+
+ + setPassword(e.target.value)} + className="w-full p-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-blue-500 text-sm" + disabled={isSubmitting} + /> +
+
+ + setConfirmPassword(e.target.value)} + className="w-full p-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-blue-500 text-sm" + disabled={isSubmitting} + /> +
+
+ + +
+
+
+ + +
+
+ +
+
+
+ ); + } + + return ( +
+
+ +
+
+
+

Hello {user?.name}

+
+
+
+ + + +
+
+
+ {activeTab === "profile" && } + {activeTab === "orders" && } + {activeTab === "account" && } +
diff --git a/src/app/user/page.tsx b/src/app/user/page.tsx deleted file mode 100644 index 0f5acc9..0000000 --- a/src/app/user/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function User() { - return ( -
-

User

-
- ); -} diff --git a/src/types/ApiTypes.ts b/src/types/ApiTypes.ts index 11dece6..ea02c6c 100644 --- a/src/types/ApiTypes.ts +++ b/src/types/ApiTypes.ts @@ -1,8 +1,22 @@ -import { Artefact } from "@prismaclient"; +import { Artefact, Earthquake, Observatory, Order, Request, Scientist, User } from "@prismaclient"; interface ExtendedArtefact extends Artefact { location: string; date: Date; } -export type { ExtendedArtefact }; +interface ExtendedScientist extends Scientist { + superior?: Scientist; + subordinates: Scientist[]; +} + +interface ExtendedUser extends User { + earthquakes: Earthquake[]; + observatories: Observatory[]; + artefacts: Artefact[]; + purchasedOrders: Order[]; + requests: Request[]; + scientist: ExtendedScientist; +} + +export type { ExtendedArtefact, ExtendedUser }; diff --git a/src/types/Prisma.ts b/src/types/Prisma.ts index 6ffd0bc..430109e 100644 --- a/src/types/Prisma.ts +++ b/src/types/Prisma.ts @@ -1,3 +1,5 @@ +import type { User as PrismaUser } from "@prismaclient"; + interface Scientist { id: number; createdAt: Date; @@ -25,11 +27,7 @@ interface Artefact { dateAdded: string; } -interface User { - id: number; - createdAt: Date; - name: string; - email: string; +interface User extends PrismaUser { passwordHash: string; role: string; scientist: Scientist | undefined; diff --git a/src/types/StoreModel.ts b/src/types/StoreModel.ts index ae98ddd..15b565a 100644 --- a/src/types/StoreModel.ts +++ b/src/types/StoreModel.ts @@ -1,6 +1,7 @@ import { Action } from "easy-peasy"; -// import type { User } from "@prismaclient"; -import { User } from "@appTypes/Prisma"; +import type { User } from "@prismaclient"; +import { ExtendedUser } from "@appTypes/ApiTypes"; +// import { User } from "@appTypes/Prisma"; type Currency = "GBP" | "USD" | "EUR"; @@ -14,8 +15,8 @@ interface CurrencyModel { interface StoreModel { currency: CurrencyModel; - user: User | null; - setUser: Action; + user: ExtendedUser | null; + setUser: Action; } export type { StoreModel, Currency };