diff --git a/package.json b/package.json index 07ac251..affd4a6 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,15 @@ "@reduxjs/toolkit": "^2.8.2", "axios": "^1.9.0", "framer-motion": "^12.23.0", + "lodash": "^4.17.21", "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", "react-redux": "^9.2.0", "react-router-dom": "^7.6.2", - "redux": "^5.0.1" + "redux": "^5.0.1", + "use-mask-input": "^3.5.0" }, "devDependencies": { "@chakra-ui/cli": "^3.21.1", diff --git a/src/components/Identity/AccountActivityCard.jsx b/src/components/Identity/AccountActivityCard.jsx new file mode 100644 index 0000000..2f5bea3 --- /dev/null +++ b/src/components/Identity/AccountActivityCard.jsx @@ -0,0 +1,42 @@ +import { Box, Card, CardBody, CardHeader, Heading, Skeleton, Spinner, Stack, StackSeparator, Text, useMediaQuery } from '@chakra-ui/react' +import React from 'react' + +function AccountActivityCard({ infoLoading, auditLogsData }) { + const [isSmallerThan800] = useMediaQuery("(max-width: 800px)"); + + return ( + + + Account Activity + + + + + {auditLogsData.length == 0 ? ( + <> + {/* + Retrieving... */} + 💤 Oops! No account activity yet. + + ) : ( + } gap={4}> + {auditLogsData.map((log, index) => ( + + + {log.title} + + {log.created} + + {log.description} + + + ))} + + )} + + + + ) +} + +export default AccountActivityCard \ No newline at end of file diff --git a/src/components/Identity/ChangePasswordDialogButton.jsx b/src/components/Identity/ChangePasswordDialogButton.jsx new file mode 100644 index 0000000..94a8379 --- /dev/null +++ b/src/components/Identity/ChangePasswordDialogButton.jsx @@ -0,0 +1,145 @@ +import { Button, CloseButton, Dialog, Field, Input, Portal, Text } from '@chakra-ui/react' +import { useEffect, useState } from 'react' +import server, { JSONResponse } from '../../networking' +import ToastWizard from '../toastWizard'; +import { useDispatch } from 'react-redux'; +import { logout } from '../../slices/AuthState'; +import { useNavigate } from 'react-router-dom'; + +function ChangePasswordDialogButton() { + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const [currentPass, setCurrentPass] = useState(''); + const [newPass, setNewPass] = useState(''); + const [confirmNewPass, setConfirmNewPass] = useState(''); + const [startedTyping, setStartedTyping] = useState(false); + const [changing, setChanging] = useState(false); + + useEffect(() => { + if (newPass.length === 0) { + setStartedTyping(false); + setConfirmNewPass(''); + } + }, [newPass]); + + useEffect(() => { + if (confirmNewPass.length > 0 && !startedTyping) { + setStartedTyping(true); + } + }, [confirmNewPass]) + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleChangePassword(); + } + } + + const handleChangePassword = async () => { + if (!(currentPass && newPass && confirmNewPass && newPass === confirmNewPass)) { + ToastWizard.standard("error", "Invalid input", "Please fill in all fields and ensure passwords match."); + return; + } else if (currentPass === confirmNewPass) { + ToastWizard.standard("error", "Invalid input", "New password cannot be the same as the current password."); + return; + } + + setChanging(true); + try { + const response = await server.post('/profile/changePassword', { + currentPassword: currentPass, + newPassword: confirmNewPass + }) + + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw new Error(errObject); + } + + // Success case + dispatch(logout(true, (result) => { + if (result instanceof JSONResponse && !result.isErrorStatus()) { + navigate('/auth/login'); + } else { + ToastWizard.standard("error", "Logout failed", "Please try again."); + } + return; + })); + ToastWizard.standard("success", "Password changed successfully.", "Please log in again with your new password."); + } else { + throw new Error("Unexpected response format"); + } + } catch (err) { + if (err.response && err.response.data instanceof JSONResponse) { + console.log("Error response in change password request:", err.response.data.fullMessage()); + if (err.response.data.userErrorType()) { + ToastWizard.standard("error", err.response.data.message); + } else { + ToastWizard.standard("error", "Password update failed.", "Failed to change your password. Please try again."); + } + } else { + console.log("Unexpected error in change password request:", err); + ToastWizard.standard("error", "Password update failed.", "Failed to change your password. Please try again."); + } + } + + setChanging(false); + } + + return ( + + + + + + + + + + Change Password + + + Verify your identity before changing your password. + + Current Password + setCurrentPass(e.target.value)} /> + + + + New Password + setNewPass(e.target.value)} /> + + + + Confirm New Password + setConfirmNewPass(e.target.value)} /> + {startedTyping && newPass !== confirmNewPass && ( + + Passwords do not match. + + )} + + + + {/* + + */} + + + + + + + + + + ) +} + +export default ChangePasswordDialogButton \ No newline at end of file diff --git a/src/components/Identity/ProfileActionBar.jsx b/src/components/Identity/ProfileActionBar.jsx new file mode 100644 index 0000000..2a87cc8 --- /dev/null +++ b/src/components/Identity/ProfileActionBar.jsx @@ -0,0 +1,113 @@ +import { ActionBar, Button, Dialog, Portal } from '@chakra-ui/react' +import { isEqual } from 'lodash'; +import { useEffect, useState } from 'react' +import { LuSquarePlus, LuTrash2 } from 'react-icons/lu'; +import ToastWizard from '../toastWizard'; +import server, { JSONResponse } from '../../networking'; + +function ProfileActionBar({ accountInfo, originalAccountInfo, setAccountInfo, getAccountInfo, enterKeyHit }) { + const [changesMade, setChangesMade] = useState(false); + const [isConfirmSaveOpen, setIsConfirmSaveOpen] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); + + useEffect(() => { + setChangesMade(!isEqual(accountInfo, originalAccountInfo)); + }, [accountInfo, originalAccountInfo]) + + useEffect(() => { + if (changesMade) { + setIsConfirmSaveOpen(true); + } + }, [enterKeyHit]); + + const handleCancelChanges = () => { + setAccountInfo(originalAccountInfo); + } + + const handleSaveChanges = async () => { + setSaveLoading(true); + + var updateDict = {}; + if (accountInfo.username !== originalAccountInfo.username) updateDict.username = accountInfo.username; + if (accountInfo.fname !== originalAccountInfo.fname) updateDict.fname = accountInfo.fname; + if (accountInfo.lname !== originalAccountInfo.lname) updateDict.lname = accountInfo.lname; + if (accountInfo.contact !== originalAccountInfo.contact) updateDict.contact = accountInfo.contact; + if (accountInfo.email !== originalAccountInfo.email) updateDict.email = accountInfo.email; + + try { + const response = await server.post('/profile/update', updateDict); + + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw new Error(errObject); + } + + // Success case + getAccountInfo(); + setIsConfirmSaveOpen(false); + + ToastWizard.standard("success", "Profile updated.", "Your changes have been saved."); + } else { + throw new Error("Unexpected response format"); + } + } catch (err) { + if (err.response && err.response.data instanceof JSONResponse) { + console.log("Error response in update profile data request:", err.response.data.fullMessage()); + if (err.response.data.userErrorType()) { + ToastWizard.standard("error", "Profile update failed.", err.response.data.message, 3000); + } else { + ToastWizard.standard("error", "Profile update failed.", "Failed to update your profile information. Please try again.", 3000); + } + } else { + console.log("Unexpected error in profile info request:", err); + ToastWizard.standard("error", "Profile update failed.", "Failed to update your profile information. Please try again.", 3000); + } + } + + setSaveLoading(false); + } + + return ( + + + + + + + setIsConfirmSaveOpen(e.open)}> + + + + + + + + + Update Profile + + + + Are you sure you'd like to save these changes? This action cannot be undone. + + + + + + + + + + + + + + + ) +} + +export default ProfileActionBar \ No newline at end of file diff --git a/src/components/Identity/ProfileAvatar.jsx b/src/components/Identity/ProfileAvatar.jsx new file mode 100644 index 0000000..0b974af --- /dev/null +++ b/src/components/Identity/ProfileAvatar.jsx @@ -0,0 +1,101 @@ +import React, { useRef, useState } from 'react' +import { useSelector } from 'react-redux'; +import ToastWizard from '../toastWizard'; +import server, { JSONResponse } from '../../networking'; +import { Avatar, Spinner } from '@chakra-ui/react'; + +function ProfileAvatar({ accountInfo }) { + const { username, accountID } = useSelector(state => state.auth); + + const [avatarSrc, setAvatarSrc] = useState(accountID && `${import.meta.env.VITE_BACKEND_URL}/cdn/pfp/${accountID}`); + const [avatarUploading, setAvatarUploading] = useState(false); + const fileInputRef = useRef(null); + + const handleFileChange = async (e) => { + if (e.target.files.length === 0) { + ToastWizard.standard("warning", "No avatar image selected for upload") + return; + } + + const file = e.target.files ? e.target.files[0] : null; + if (!file) { + ToastWizard.standard("warning", "No avatar image selected for upload") + return; + } + + const MAX_SIZE = 5 * 1024 * 1024; // 5MB + if (file.size > MAX_SIZE) { + ToastWizard.standard("error", "Avatar image size exceeds 5MB limit"); + return; + } + + // Create a preview URL + const previewUrl = URL.createObjectURL(file); + setAvatarSrc(previewUrl); + + const formData = new FormData() + + formData.append('file', file) + const config = { + 'Content-Type': 'multipart/form-data' + } + + setAvatarUploading(true); + try { + const response = await server.post("/profile/uploadPicture", formData, { headers: config, transformRequest: formData => formData }) + + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw new Error(errObject); + } + + // Success case + ToastWizard.standard("success", "Avatar updated successfully!"); + } else { + throw new Error("Unexpected response format"); + } + } catch (err) { + if (err.response && err.response.data instanceof JSONResponse) { + console.log("Error response in avatar update request:", err.response.data.fullMessage()); + if (err.response.data.userErrorType()) { + ToastWizard.standard("error", "Avatar update failed.", err.response.data.message); + } else { + ToastWizard.standard("error", "Something went wrong in updating your avatar.", "Please try again."); + } + } else { + console.log("Unexpected error in login request:", err); + ToastWizard.standard("error", "Something went wrong in updating your avatar.", "Please try again."); + } + } + + setAvatarSrc(accountID && `${import.meta.env.VITE_BACKEND_URL}/cdn/pfp/${accountID}?t=${Date.now()}`); + setAvatarUploading(false); + } + + const handleAvatarClick = (e) => { + fileInputRef.current.click(); + }; + + return <> + + + + + + {avatarUploading && } + +} + +export default ProfileAvatar \ No newline at end of file diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index b5a8314..54c63c5 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -9,14 +9,14 @@ import { useSelector } from 'react-redux' function Navbar() { const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); - const { username } = useSelector(state => state.auth); + const { username, accountID } = useSelector(state => state.auth); const handleLogoClick = () => { navigate('/'); } const handleProfileClick = () => { - navigate('/sampleProtected'); + navigate('/profile'); } const toggleSidebar = () => { @@ -31,6 +31,7 @@ function Navbar() { + setIsOpen(e.open)} /> diff --git a/src/main.jsx b/src/main.jsx index 96f4afa..13a35cb 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -16,11 +16,11 @@ import Health from './pages/Health.jsx' import Homepage from './pages/Homepage.jsx' import Login from './pages/Login.jsx'; import DefaultLayout from './DefaultLayout.jsx'; -import SampleProtected from './pages/SampleProtected.jsx'; import ProtectedLayout from './ProtectedLayout.jsx'; import AnimateIn from './AnimateIn.jsx'; import PublicGallery from './pages/PublicGallery.jsx'; import PublicProfile from './pages/PublicProfile.jsx'; +import Profile from './pages/Profile.jsx'; const store = configureStore({ reducer: { @@ -49,7 +49,7 @@ createRoot(document.getElementById('root')).render( {/* Protected Pages */} }> - } /> + } /> diff --git a/src/networking.js b/src/networking.js index 9225523..c6dd94d 100644 --- a/src/networking.js +++ b/src/networking.js @@ -42,7 +42,7 @@ const instance = axios.create({ }) instance.interceptors.request.use((config) => { - if (config.method == 'post') { + if (config.method == 'post' && !config.headers.get('Content-Type')) { config.headers["Content-Type"] = "application/json"; } diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index edbd086..f8a7763 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -1,17 +1,20 @@ import { Box, Button, Field, Fieldset, Flex, For, Image, Input, NativeSelect, Spacer, Stack, Text, useMediaQuery, VStack } from '@chakra-ui/react' import { PasswordInput } from "../components/ui/password-input" -import { useState } from 'react' +import { useEffect, useState } from 'react' import sccciBuildingImage from '../assets/sccciBuildingOpening1964.png' import logoVisual from '../assets/logoVisual.svg' import server, { JSONResponse } from '../networking' import ToastWizard from '../components/toastWizard' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { useNavigate } from 'react-router-dom' import { fetchSession } from '../slices/AuthState' function Login() { const dispatch = useDispatch(); const navigate = useNavigate(); + + const { username, loaded } = useSelector(state => state.auth); + const [isSmallerThan800] = useMediaQuery("(max-width: 800px)"); const [usernameOrEmail, setUsernameOrEmail] = useState(''); @@ -51,8 +54,14 @@ function Login() { // Success case dispatch(fetchSession()); - navigate("/sampleProtected"); - ToastWizard.standard("success", "Login successful.", "Welcome back to ArchAIve!") + navigate("/catalogue"); + ToastWizard.standard( + "success", + (res.data.raw.fname && res.data.raw.lname) ? `Welcome back, ${res.data.raw.fname} ${res.data.raw.lname}!` : `Welcome back, ${res.data.raw.username}!`, + "Login successful.", + 5000, + true + ) } else { console.log("Unexpected response in login request:", res.data); ambiguousErrorToast(); @@ -76,6 +85,13 @@ function Login() { }) } + useEffect(() => { + if (loaded && username) { + navigate('/profile') + ToastWizard.standard("default", `You are already logged in, ${username}`, "Please logout to switch accounts.", 2000, true); + } + }, [loaded]) + return ( diff --git a/src/pages/Profile.jsx b/src/pages/Profile.jsx new file mode 100644 index 0000000..248e551 --- /dev/null +++ b/src/pages/Profile.jsx @@ -0,0 +1,229 @@ +import { Avatar, Box, Button, CloseButton, Dialog, Field, FileUpload, Flex, Grid, GridItem, HStack, Input, Portal, Skeleton, SkeletonText, Spacer, Spinner, Stack, Text, useMediaQuery, VStack } from '@chakra-ui/react' +import { useEffect, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { withMask } from 'use-mask-input'; +import { logout } from '../slices/AuthState'; +import ToastWizard from '../components/toastWizard'; +import server, { JSONResponse } from '../networking'; +import { GiExitDoor } from 'react-icons/gi'; +import { IoLogOutOutline } from 'react-icons/io5'; +import ProfileActionBar from '../components/Identity/ProfileActionBar'; +import AccountActivityCard from '../components/Identity/AccountActivityCard'; +import ChangePasswordDialogButton from '../components/Identity/ChangePasswordDialogButton'; +import ProfileAvatar from '../components/Identity/ProfileAvatar'; + +function Profile() { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + // Loading states + const [loggingOut, setLoggingOut] = useState(false); + const [infoLoading, setInfoLoading] = useState(true); + + // Profile data + const [originalAccountInfo, setOriginalAccountInfo] = useState({}); + const [accountInfo, setAccountInfo] = useState({}); + const [auditLogsData, setAuditLogsData] = useState([]); + const [enterKeyHit, setEnterKeyHit] = useState(false); + + let fullName = (originalAccountInfo.fname || 'Name') + ' ' + (originalAccountInfo.lname || 'Unavailable'); + + const [isSmallerThan800] = useMediaQuery("(max-width: 800px)"); + + const handleLogout = () => { + setLoggingOut(true); + const logoutPromise = new Promise((resolve, reject) => { + dispatch(logout(true, (msg) => { + setLoggingOut(false); + if (typeof msg === 'string') { + reject(msg); + } else { + navigate('/auth/login'); + resolve(msg); + } + })); + }) + + ToastWizard.promiseBased( + logoutPromise, + "Logged out successfully.", + "You have been logged out.", + "Logout failed.", + "Something went wrong while logging out. Please try again.", + "Logging out...", + "Connecting to server...", + handleLogout, + "Retry", + ['error'] + ) + } + + const processAuditLogs = (data) => { + var logs = []; + + for (var log of data) { + log["originalCreated"] = structuredClone(log["created"]); + log["created"] = new Date(log["created"]).toLocaleString('en-GB', { dateStyle: "long", timeStyle: "medium", hour12: true }); + logs.push(log); + } + + logs.sort((a, b) => { + // Sort by created in descending order + return new Date(b["originalCreated"]) - new Date(a["originalCreated"]); + }) + + return logs; + } + + const processAccountInfo = (data) => { + if (data.lastLogin) { + data.lastLogin = new Date(data.lastLogin).toLocaleString('en-GB', { dateStyle: "long", timeStyle: "medium", hour12: true }); + } + + return data; + } + + const getAccountInfo = async () => { + setInfoLoading(true); + try { + const response = await server.get('/profile/info?includeLogs=true') + + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw new Error(errObject); + } + + const preppedAccInfo = processAccountInfo(structuredClone(response.data.raw.info)); + delete preppedAccInfo.logs; + + // Success case + setOriginalAccountInfo(preppedAccInfo); + setAccountInfo(preppedAccInfo); + setAuditLogsData(processAuditLogs(response.data.raw.info.logs || [])); + } else { + throw new Error("Unexpected response format"); + } + } catch (err) { + if (err.response && err.response.data instanceof JSONResponse) { + console.log("Error response in profile info request:", err.response.data.fullMessage()); + if (err.response.data.userErrorType()) { + ToastWizard.standard("error", "Profile info retrieval failed.", err.response.data.message, 3000, true, getAccountInfo, 'Retry'); + } else { + ToastWizard.standard("error", "Couldn't retrieve profile.", "Failed to retrieve your information. Please try again.", 3000, true, getAccountInfo, 'Retry'); + } + } else { + console.log("Unexpected error in profile info request:", err); + ToastWizard.standard("error", "Couldn't retrieve profile.", "Failed to retrieve your information. Please try again.", 3000, true, getAccountInfo, 'Retry'); + } + } + + setInfoLoading(false); + } + + useEffect(() => { + getAccountInfo(); + }, []) + + // useEffect(() => { + // console.log(accountInfo) + // console.log(auditLogsData) + // }, [accountInfo, auditLogsData]) + + const handleEnterKey = (e) => { + if (e.key === 'Enter') { + setEnterKeyHit(prev => !prev); + } + } + + return <> + + + + + + + {fullName} + {originalAccountInfo.role} + + + Name & Account Details + + + + + First Name + setAccountInfo({ ...accountInfo, fname: e.target.value })} /> + + + + + + Last Name + setAccountInfo({ ...accountInfo, lname: e.target.value })} /> + + + + + + Username + setAccountInfo({ ...accountInfo, username: e.target.value })} /> + + + + + + Email + setAccountInfo({ ...accountInfo, email: e.target.value })} /> + + + + + + Contact + setAccountInfo({ ...accountInfo, contact: e.target.value })} /> + + + + + + + + + + + + + + Security & Authentication + + + Last Login: {originalAccountInfo.lastLogin} + + + + + + + + + + + + + + +} + +export default Profile \ No newline at end of file diff --git a/src/themes/MainTheme.js b/src/themes/MainTheme.js index a912f61..3011fec 100644 --- a/src/themes/MainTheme.js +++ b/src/themes/MainTheme.js @@ -13,6 +13,12 @@ const buttonRecipe = defineRecipe({ bg: "rgb(190, 0, 25)", color: "white", _hover: { bg: "primaryColour" }, + }, + ArchPrimaryAlt: { + border: '1px solid #12144C', + bg: 'white', + color: 'primaryColour', + _hover: { bg: "primaryColour", color: "white" }, } } }