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" },
}
}
}