diff --git a/client/src/App.tsx b/client/src/App.tsx
index 49f4c99..67b9704 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -1,13 +1,14 @@
import { Routes, Route, useLocation } from "react-router-dom";
import { ToastProvider } from "./ui";
-import { IncomesPage } from "./pages/IncomesPage";
+import { Incomes } from "./pages/Incomes";
import Sidebar from "./components/common/Sidebar";
import Dashboard from "./pages/Dashboard";
import BackgroundImage from "./components/common/BackgroundImage";
-import { CreateIncomePage } from "./pages/CreateIncomePage";
-import { EditIncomePage } from "./pages/EditIncomePage";
+import { CreateIncome } from "./pages/CreateIncome";
+import { EditIncome } from "./pages/EditIncome";
import Mascot from "./components/common/Mascot";
import { DashboardHeader } from "./components/common/Header";
+import { Profile } from "./pages/Profile";
function App() {
/*
@@ -22,7 +23,7 @@ function App() {
pauseOnHover={true}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
>
-
+
{location.pathname.includes("/login") ||
location.pathname.includes("/register") ? null : (
<>
@@ -34,9 +35,10 @@ function App() {
} />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts
index 93bed92..de18745 100644
--- a/client/src/api/services/DefaultService.ts
+++ b/client/src/api/services/DefaultService.ts
@@ -42,8 +42,8 @@ export class DefaultService {
}
/**
* List all user expenses
- * @param start Start date
- * @param end End date
+ * @param start Start date (ISO string)
+ * @param end End date (ISO string)
* @param category
* @param type
* @returns any List of expenses
@@ -383,4 +383,36 @@ export class DefaultService {
url: "/user/profile",
});
}
+ /**
+ * Update user profile
+ * @param requestBody
+ * @returns any Profile updated
+ * @throws ApiError
+ */
+ public static putUserProfile(
+ requestBody: Record
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "PUT",
+ url: "/user/profile",
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
+ /**
+ * Update user password
+ * @param requestBody
+ * @returns any Password updated
+ * @throws ApiError
+ */
+ public static patchUserProfilePassword(
+ requestBody: Record
+ ): CancelablePromise {
+ return __request(OpenAPI, {
+ method: "PATCH",
+ url: "/user/profile/password",
+ body: requestBody,
+ mediaType: "application/json",
+ });
+ }
}
diff --git a/client/src/components/IncomeForm.tsx b/client/src/components/Income/IncomeForm.tsx
similarity index 90%
rename from client/src/components/IncomeForm.tsx
rename to client/src/components/Income/IncomeForm.tsx
index 8dff09c..f0477c9 100644
--- a/client/src/components/IncomeForm.tsx
+++ b/client/src/components/Income/IncomeForm.tsx
@@ -1,8 +1,8 @@
import React, { useState, useEffect } from "react";
-import type { Income, IncomeFormData } from "../types/Income";
-import { Button, TextField, Dialog, DatePicker } from "../ui";
-import { useMascot } from "../hooks/useMascot";
-import { validateIncomeData } from "../utils/validators";
+import type { Income, IncomeFormData } from "../../types/Income";
+import { Button, TextField, Dialog, DatePicker } from "../../ui";
+import { useMascot } from "../../hooks/useMascot";
+import { validateIncomeData } from "../../utils/validators";
interface IncomeFormProps {
income?: Income;
diff --git a/client/src/components/IncomeList.tsx b/client/src/components/Income/IncomeList.tsx
similarity index 93%
rename from client/src/components/IncomeList.tsx
rename to client/src/components/Income/IncomeList.tsx
index eae35b2..2693531 100644
--- a/client/src/components/IncomeList.tsx
+++ b/client/src/components/Income/IncomeList.tsx
@@ -1,7 +1,7 @@
import { forwardRef, useImperativeHandle } from "react";
-import type { Income } from "../types/Income";
-import { useIncomes } from "../hooks/useIncomes";
-import { Button } from "../ui";
+import type { Income } from "../../types/Income";
+import { useIncomes } from "../../hooks/useIncomes";
+import { Button } from "../../ui";
interface IncomeListProps {
startDate?: string;
diff --git a/client/src/components/Profile/PasswordForm.tsx b/client/src/components/Profile/PasswordForm.tsx
new file mode 100644
index 0000000..e09c130
--- /dev/null
+++ b/client/src/components/Profile/PasswordForm.tsx
@@ -0,0 +1,195 @@
+import React, { useState } from "react";
+import { Button, TextField } from "../../ui";
+import type { ChangePasswordRequest } from "../../types/UserProfile";
+import { createFieldChangeHandler } from "../../utils/formUtils";
+
+interface PasswordFormProps {
+ onChangePassword: (
+ data: ChangePasswordRequest
+ ) => Promise<{ success: boolean; error?: string }>;
+ loading?: boolean;
+}
+
+export const PasswordForm: React.FC = ({
+ onChangePassword,
+ loading = false,
+}) => {
+ const [formData, setFormData] = useState({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
+ const [message, setMessage] = useState("");
+ const [formError, setFormError] = useState("");
+ const [fieldErrors, setFieldErrors] = useState>({});
+
+ const validateForm = (): boolean => {
+ const errors: Record = {};
+
+ if (!formData.currentPassword) {
+ errors.currentPassword = "Current password is required";
+ }
+
+ if (formData.newPassword.length < 6) {
+ errors.newPassword = "Password must be at least 6 characters long";
+ } else if (!/(?=.*[A-Z])/.test(formData.newPassword)) {
+ errors.newPassword =
+ "Password must contain at least one uppercase letter";
+ } else if (!/(?=.*\d)/.test(formData.newPassword)) {
+ errors.newPassword = "Password must contain at least one number";
+ }
+
+ if (formData.newPassword !== formData.confirmPassword) {
+ errors.confirmPassword = "Passwords do not match";
+ }
+
+ setFieldErrors(errors);
+ return Object.keys(errors).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setMessage("");
+ setFormError("");
+ setFieldErrors({});
+
+ if (!validateForm()) return;
+
+ const result = await onChangePassword({
+ currentPassword: formData.currentPassword,
+ newPassword: formData.newPassword,
+ });
+
+ if (result.success) {
+ setMessage("Password changed successfully!");
+ setFormData({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
+ setTimeout(() => setMessage(""), 3000);
+ } else {
+ const error = result.error || "Failed to change password";
+ setFormError(error);
+
+ if (
+ error.toLowerCase().includes("current") ||
+ error.toLowerCase().includes("incorrect")
+ ) {
+ setFieldErrors((prev) => ({ ...prev, currentPassword: error }));
+ } else if (
+ error.toLowerCase().includes("new") ||
+ error.toLowerCase().includes("password")
+ ) {
+ if (
+ error.toLowerCase().includes("uppercase") ||
+ error.toLowerCase().includes("number") ||
+ error.toLowerCase().includes("6") ||
+ error.toLowerCase().includes("character")
+ ) {
+ setFieldErrors((prev) => ({ ...prev, newPassword: error }));
+ } else {
+ setFormError(error);
+ }
+ } else {
+ setFormError(error);
+ }
+ }
+ };
+
+ const handleCurrentPasswordChange = createFieldChangeHandler(
+ setFormData,
+ "currentPassword"
+ );
+ const handleNewPasswordChange = createFieldChangeHandler(
+ setFormData,
+ "newPassword"
+ );
+ const handleConfirmPasswordChange = createFieldChangeHandler(
+ setFormData,
+ "confirmPassword"
+ );
+
+ const clearFieldError = (fieldName: string) => {
+ if (fieldErrors[fieldName]) {
+ setFieldErrors((prev) => ({ ...prev, [fieldName]: "" }));
+ }
+ if (formError) setFormError("");
+ };
+
+ return (
+
+ );
+};
diff --git a/client/src/components/Profile/ProfileForm.tsx b/client/src/components/Profile/ProfileForm.tsx
new file mode 100644
index 0000000..2d612de
--- /dev/null
+++ b/client/src/components/Profile/ProfileForm.tsx
@@ -0,0 +1,138 @@
+import React, { useState } from "react";
+import type {
+ UserProfile,
+ UpdateProfileRequest,
+} from "../../types/UserProfile";
+import { Button, TextField } from "../../ui";
+import { createFieldChangeHandler } from "../../utils/formUtils";
+
+interface ProfileFormProps {
+ profile: UserProfile;
+ onUpdate: (
+ data: UpdateProfileRequest
+ ) => Promise<{ success: boolean; error?: string }>;
+ loading?: boolean;
+}
+
+export const ProfileForm: React.FC = ({
+ profile,
+ onUpdate,
+ loading = false,
+}) => {
+ const [formData, setFormData] = useState({
+ firstname: profile.firstname || "",
+ lastname: profile.lastname || "",
+ username: profile.username || "",
+ });
+ const [message, setMessage] = useState("");
+ const [formError, setFormError] = useState("");
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setMessage("");
+ setFormError("");
+
+ const result = await onUpdate(formData);
+ if (result.success) {
+ setMessage("Profile updated successfully!");
+ setTimeout(() => setMessage(""), 3000);
+ } else {
+ setFormError(result.error || "Failed to update profile");
+ }
+ };
+
+ const handleFirstNameChange = createFieldChangeHandler(
+ setFormData,
+ "firstname"
+ );
+ const handleLastNameChange = createFieldChangeHandler(
+ setFormData,
+ "lastname"
+ );
+ const handleUsernameChange = createFieldChangeHandler(
+ setFormData,
+ "username"
+ );
+
+ const clearError = () => {
+ if (formError) setFormError("");
+ };
+
+ return (
+
+
+ Profile Information
+
+
+
+
+ );
+};
diff --git a/client/src/components/common/Header/Header.tsx b/client/src/components/common/Header/Header.tsx
index 85f3521..66d3be7 100644
--- a/client/src/components/common/Header/Header.tsx
+++ b/client/src/components/common/Header/Header.tsx
@@ -4,9 +4,15 @@ import ThemeToggle from "../ThemeToggle";
import { ChevronDown } from "lucide-react";
import { DateDropdown, NotificationBell, SearchInput } from "./components";
import { useState, useEffect } from "react";
+import { useUserStore } from "../../../stores/userStore";
const DashboardHeader = () => {
const [isScrolled, setIsScrolled] = useState(false);
+ const { user, loading, error, fetchProfile } = useUserStore();
+
+ useEffect(() => {
+ fetchProfile();
+ }, [fetchProfile]);
useEffect(() => {
const handleScroll = () => {
@@ -17,6 +23,32 @@ const DashboardHeader = () => {
return () => window.removeEventListener("scroll", handleScroll);
}, []);
+ const getUserDisplayName = () => {
+ if (loading) return "Loading...";
+ if (error || !user) return "GUEST";
+
+ if (user.firstname && user.lastname) {
+ return `${user.firstname} ${user.lastname}`;
+ }
+ if (user.firstname) return user.firstname;
+ if (user.lastname) return user.lastname;
+ if (user.username) return user.username;
+
+ return "User";
+ };
+
+ const getUsername = () => {
+ if (loading) return "Loading...";
+ if (error || !user) return "guest";
+ return user.username;
+ };
+
+ const getWelcomeMessage = () => {
+ if (loading) return "Welcome back!";
+ if (error || !user) return "Welcome, Guest!";
+ return `Hi, `;
+ };
+
return (
{
>
- Hi,{" "}
- John
+ {getWelcomeMessage()}
+ {user && (
+
+ {getUserDisplayName().split(" ")[0]}
+
+ )}
Track all your transactions with PennyPal
@@ -45,22 +81,32 @@ const DashboardHeader = () => {
-
+

-
+ {user && (
+
+ )}
+ {!user && (
+
+ )}
- John Doe
+ {getUserDisplayName()}
- Client
+ @{getUsername()}
diff --git a/client/src/hooks/useUserProfile.ts b/client/src/hooks/useUserProfile.ts
new file mode 100644
index 0000000..ec20bd2
--- /dev/null
+++ b/client/src/hooks/useUserProfile.ts
@@ -0,0 +1,68 @@
+import { useState, useEffect } from "react";
+import { UserService } from "../services/UserService";
+import type {
+ UserProfile,
+ UpdateProfileRequest,
+ ChangePasswordRequest,
+} from "../types/UserProfile";
+
+export const useUserProfile = () => {
+ const [profile, setProfile] = useState
(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchProfile = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const userProfile = await UserService.getProfile();
+ setProfile(userProfile);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to fetch profile");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const updateProfile = async (
+ data: UpdateProfileRequest
+ ): Promise<{ success: boolean; error?: string }> => {
+ try {
+ setError(null);
+ const updatedProfile = await UserService.updateProfile(data);
+ setProfile(updatedProfile);
+ return { success: true };
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : "Failed to update profile";
+ return { success: false, error: errorMessage };
+ }
+ };
+
+ const changePassword = async (
+ data: ChangePasswordRequest
+ ): Promise<{ success: boolean; error?: string }> => {
+ try {
+ setError(null);
+ await UserService.changePassword(data);
+ return { success: true };
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : "Failed to change password";
+ return { success: false, error: errorMessage };
+ }
+ };
+
+ useEffect(() => {
+ fetchProfile();
+ }, []);
+
+ return {
+ profile,
+ loading,
+ error,
+ fetchProfile,
+ updateProfile,
+ changePassword,
+ };
+};
diff --git a/client/src/pages/CreateIncomePage.tsx b/client/src/pages/CreateIncome.tsx
similarity index 89%
rename from client/src/pages/CreateIncomePage.tsx
rename to client/src/pages/CreateIncome.tsx
index 301fd2c..d8fbf69 100644
--- a/client/src/pages/CreateIncomePage.tsx
+++ b/client/src/pages/CreateIncome.tsx
@@ -1,10 +1,10 @@
import { useNavigate } from "react-router-dom";
-import { IncomeForm } from "../components/IncomeForm";
+import { IncomeForm } from "../components/Income/IncomeForm";
import { Button, useToast } from "../ui";
import { IncomeService } from "../services/IncomeService";
import type { CreateIncomeRequest } from "../types/Income";
-export const CreateIncomePage = () => {
+export const CreateIncome = () => {
const navigate = useNavigate();
const toast = useToast();
diff --git a/client/src/pages/EditIncomePage.tsx b/client/src/pages/EditIncome.tsx
similarity index 92%
rename from client/src/pages/EditIncomePage.tsx
rename to client/src/pages/EditIncome.tsx
index 3b2dd01..22c6d79 100644
--- a/client/src/pages/EditIncomePage.tsx
+++ b/client/src/pages/EditIncome.tsx
@@ -1,11 +1,11 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
-import { IncomeForm } from "../components/IncomeForm";
+import { IncomeForm } from "../components/Income/IncomeForm";
import { Button, useToast, Skeleton } from "../ui";
import { IncomeService } from "../services/IncomeService";
import type { Income, UpdateIncomeRequest } from "../types/Income";
-export const EditIncomePage = () => {
+export const EditIncome = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
diff --git a/client/src/pages/IncomesPage.tsx b/client/src/pages/Incomes.tsx
similarity index 95%
rename from client/src/pages/IncomesPage.tsx
rename to client/src/pages/Incomes.tsx
index c6be2b4..7733557 100644
--- a/client/src/pages/IncomesPage.tsx
+++ b/client/src/pages/Incomes.tsx
@@ -1,12 +1,12 @@
import { useState, useRef } from "react";
import type { Income } from "../types/Income";
-import { IncomeList } from "../components/IncomeList";
+import { IncomeList } from "../components/Income/IncomeList";
import { Button, Dialog, useToast, DatePicker } from "../ui";
import { IncomeService } from "../services/IncomeService";
import { useNavigate } from "react-router-dom";
import { validateDateRange } from "../utils/validators";
-export const IncomesPage = () => {
+export const Incomes = () => {
const [dateFilter, setDateFilter] = useState<{
start?: string;
end?: string;
diff --git a/client/src/pages/Profile.tsx b/client/src/pages/Profile.tsx
new file mode 100644
index 0000000..dd5e41b
--- /dev/null
+++ b/client/src/pages/Profile.tsx
@@ -0,0 +1,109 @@
+import React, { useState } from "react";
+import { ProfileForm } from "../components/Profile/ProfileForm";
+import { PasswordForm } from "../components/Profile/PasswordForm";
+import { Button } from "../ui";
+import { useUserStore } from "../stores/userStore";
+import type {
+ ChangePasswordRequest,
+ UpdateProfileRequest,
+} from "../types/UserProfile";
+
+export const Profile: React.FC = () => {
+ const { user, loading, error, fetchProfile, updateProfile, changePassword } =
+ useUserStore();
+ const [updating, setUpdating] = useState(false);
+ const [changingPassword, setChangingPassword] = useState(false);
+
+ const handleUpdateProfile = async (data: UpdateProfileRequest) => {
+ setUpdating(true);
+ const result = await updateProfile(data);
+ setUpdating(false);
+ return result;
+ };
+
+ const handleChangePassword = async (data: ChangePasswordRequest) => {
+ setChangingPassword(true);
+ const result = await changePassword(data);
+ setChangingPassword(false);
+ return result;
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!user) {
+ return (
+
+
+
+ Please Log In
+
+
+ You need to be logged in to view your profile
+
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
Error
+
{error}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Account Information
+
+
+
+ Member since:
+
+ {new Date(user.created_at).toLocaleDateString()}
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/services/UserService.ts b/client/src/services/UserService.ts
new file mode 100644
index 0000000..f0957a0
--- /dev/null
+++ b/client/src/services/UserService.ts
@@ -0,0 +1,60 @@
+import { DefaultService } from "../api/services/DefaultService";
+import { useMascotStore } from "../stores/mascotStore";
+import type {
+ UserProfile,
+ UpdateProfileRequest,
+ ChangePasswordRequest,
+} from "../types/UserProfile";
+
+interface ApiError {
+ body?: { message?: string };
+}
+
+export class UserService {
+ //GET user profile
+ static async getProfile(): Promise {
+ try {
+ const response = await DefaultService.getUserProfile();
+ useMascotStore.getState().setExpression("success");
+ return response as UserProfile;
+ } catch (error: unknown) {
+ useMascotStore.getState().setExpression("error");
+ const err = error as ApiError;
+ const errorMessage = err.body?.message || "Failed to fetch user profile";
+ throw new Error(errorMessage);
+ }
+ }
+
+ //UPDATE user profile
+ static async updateProfile(
+ profileData: UpdateProfileRequest
+ ): Promise {
+ try {
+ const response = await DefaultService.putUserProfile(profileData);
+ useMascotStore.getState().setExpression("success");
+ return response as UserProfile;
+ } catch (error: unknown) {
+ useMascotStore.getState().setExpression("error");
+ const err = error as ApiError;
+ const errorMessage = err.body?.message || "Failed to update profile";
+ throw new Error(errorMessage);
+ }
+ }
+
+ //CHANGE password
+ static async changePassword(
+ passwordData: ChangePasswordRequest
+ ): Promise<{ message: string }> {
+ try {
+ const { ...apiData } = passwordData; // removes confirmPassword if exists
+ const response = await DefaultService.patchUserProfilePassword(apiData);
+ useMascotStore.getState().setExpression("success");
+ return response as { message: string };
+ } catch (error: unknown) {
+ useMascotStore.getState().setExpression("error");
+ const err = error as ApiError;
+ const errorMessage = err.body?.message || "Failed to change password";
+ throw new Error(errorMessage);
+ }
+ }
+}
diff --git a/client/src/stores/userStore.ts b/client/src/stores/userStore.ts
new file mode 100644
index 0000000..90d85ce
--- /dev/null
+++ b/client/src/stores/userStore.ts
@@ -0,0 +1,70 @@
+import { create } from "zustand";
+import { UserService } from "../services/UserService";
+import type {
+ UserProfile,
+ UpdateProfileRequest,
+ ChangePasswordRequest,
+} from "../types/UserProfile";
+
+interface UserState {
+ user: UserProfile | null;
+ loading: boolean;
+ error: string | null;
+ fetchProfile: () => Promise;
+ updateProfile: (
+ data: UpdateProfileRequest
+ ) => Promise<{ success: boolean; error?: string }>;
+ changePassword: (
+ data: ChangePasswordRequest
+ ) => Promise<{ success: boolean; error?: string }>;
+ clearUser: () => void;
+}
+
+export const useUserStore = create((set) => ({
+ user: null,
+ loading: false,
+ error: null,
+
+ fetchProfile: async () => {
+ set({ loading: true, error: null });
+ try {
+ const userProfile = await UserService.getProfile();
+ set({ user: userProfile, loading: false });
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : "Failed to fetch profile";
+ set({ error: errorMessage, loading: false, user: null });
+ }
+ },
+
+ updateProfile: async (data: UpdateProfileRequest) => {
+ try {
+ set({ error: null });
+ const updatedProfile = await UserService.updateProfile(data);
+ set({ user: updatedProfile });
+ return { success: true };
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : "Failed to update profile";
+ set({ error: errorMessage });
+ return { success: false, error: errorMessage };
+ }
+ },
+
+ changePassword: async (data: ChangePasswordRequest) => {
+ try {
+ set({ error: null });
+ await UserService.changePassword(data);
+ return { success: true };
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : "Failed to change password";
+ set({ error: errorMessage });
+ return { success: false, error: errorMessage };
+ }
+ },
+
+ clearUser: () => {
+ set({ user: null, error: null });
+ },
+}));
diff --git a/client/src/types/UserProfile.ts b/client/src/types/UserProfile.ts
new file mode 100644
index 0000000..6d8e02a
--- /dev/null
+++ b/client/src/types/UserProfile.ts
@@ -0,0 +1,20 @@
+export interface UpdateProfileRequest {
+ firstname?: string;
+ lastname?: string;
+ username?: string;
+}
+
+export interface ChangePasswordRequest {
+ currentPassword: string;
+ newPassword: string;
+ confirmPassword?: string;
+}
+
+export interface UserProfile {
+ user_id: number;
+ email: string;
+ username: string;
+ firstname: string;
+ lastname: string;
+ created_at: string;
+}
\ No newline at end of file
diff --git a/client/src/utils/formUtils.ts b/client/src/utils/formUtils.ts
new file mode 100644
index 0000000..7cebb88
--- /dev/null
+++ b/client/src/utils/formUtils.ts
@@ -0,0 +1,19 @@
+import type { ChangeEvent } from "react";
+
+export const createChangeHandler = (setter: (value: string) => void) => {
+ return (e: ChangeEvent) => {
+ setter(e.target.value);
+ };
+};
+
+export const createFieldChangeHandler = >(
+ setFormData: React.Dispatch>,
+ field: keyof T
+) => {
+ return (e: ChangeEvent) => {
+ setFormData((prev) => ({
+ ...prev,
+ [field]: e.target.value,
+ }));
+ };
+};
diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js
new file mode 100644
index 0000000..d44ce46
--- /dev/null
+++ b/server/controllers/user.controller.js
@@ -0,0 +1,38 @@
+import {
+ updateUserProfile,
+ changeUserPassword,
+ getPublicUser,
+} from "../services/auth.service.js";
+import { asyncHandler } from "../utils/asyncHandler.js";
+
+export const getUserProfile = asyncHandler(async (req, res) => {
+ const user = await getPublicUser(req.user.user_id);
+ return res.json(user);
+});
+
+export const updateProfile = asyncHandler(async (req, res) => {
+ const { firstname, lastname, username } = req.body;
+
+ const user = await updateUserProfile(req.user.user_id, {
+ firstname,
+ lastname,
+ username,
+ });
+ return res.json(user);
+});
+
+export const changePassword = asyncHandler(async (req, res) => {
+ const { currentPassword, newPassword } = req.body;
+ try {
+ const result = await changeUserPassword(req.user.user_id, {
+ currentPassword,
+ newPassword,
+ });
+ return res.json(result);
+ } catch (error) {
+ if (error instanceof UnauthorizedError) {
+ throw new BadRequestError("Current password is incorrect");
+ }
+ throw error;
+ }
+});
diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml
index be252b6..080b378 100644
--- a/server/docs/Expense Tracker API.yaml
+++ b/server/docs/Expense Tracker API.yaml
@@ -338,3 +338,27 @@ paths:
responses:
"200":
description: Profile info
+ put:
+ summary: Update user profile
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ responses:
+ "200":
+ description: Profile updated
+
+ /user/profile/password:
+ patch:
+ summary: Update user password
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ responses:
+ "200":
+ description: Password updated
diff --git a/server/middleware/validate.js b/server/middleware/validate.js
index 29fc8f3..ef141d0 100644
--- a/server/middleware/validate.js
+++ b/server/middleware/validate.js
@@ -1,22 +1,25 @@
-import { BadRequestError } from '../utils/errors.js';
-import isEmail from 'validator/lib/isEmail.js';
-import normalizeEmail from 'validator/lib/normalizeEmail.js';
-import isURL from 'validator/lib/isURL.js';
-
-export const requireFields = (...fields) => (req, _res, next) => {
- for (const f of fields) {
- const v = req.body?.[f];
- if (v == null || v === '') {
- return next(new BadRequestError(`Missing field: ${f}`));
+import { BadRequestError } from "../utils/errors.js";
+import isEmail from "validator/lib/isEmail.js";
+import normalizeEmail from "validator/lib/normalizeEmail.js";
+import isURL from "validator/lib/isURL.js";
+import isStrongPassword from 'validator/lib/isStrongPassword.js';
+
+export const requireFields =
+ (...fields) =>
+ (req, _res, next) => {
+ for (const f of fields) {
+ const v = req.body?.[f];
+ if (v == null || v === "") {
+ return next(new BadRequestError(`Missing field: ${f}`));
+ }
}
- }
- next();
-};
+ next();
+ };
// Normalize and validate email. Sets req.body.email to the normalized lowercase value.
export const validateEmail = () => (req, _res, next) => {
- const raw = String(req.body.email || '').trim();
- if (!isEmail(raw)) return next(new BadRequestError('Invalid email format'));
+ const raw = String(req.body.email || "").trim();
+ if (!isEmail(raw)) return next(new BadRequestError("Invalid email format"));
const normalized = normalizeEmail(raw, {
all_lowercase: true,
gmail_remove_dots: false,
@@ -29,20 +32,24 @@ export const validateEmail = () => (req, _res, next) => {
next();
};
-export const sanitizeBody = (...fields) => (req, _res, next) => {
- for (const f of fields) {
- if (typeof req.body?.[f] === 'string') {
- req.body[f] = req.body[f].trim();
+export const sanitizeBody =
+ (...fields) =>
+ (req, _res, next) => {
+ for (const f of fields) {
+ if (typeof req.body?.[f] === "string") {
+ req.body[f] = req.body[f].trim();
+ }
}
- }
- next();
-};
+ next();
+ };
export const validateTextMaxLengths = (limits) => (req, _res, next) => {
for (const [field, max] of Object.entries(limits || {})) {
const v = req.body?.[field];
- if (typeof v === 'string' && v.length > max) {
- return next(new BadRequestError(`${field} is too long (max ${max} characters)`));
+ if (typeof v === "string" && v.length > max) {
+ return next(
+ new BadRequestError(`${field} is too long (max ${max} characters)`)
+ );
}
}
next();
@@ -57,17 +64,19 @@ export const validateIdParam = (paramName) => (req, _res, next) => {
};
export const validateCategoryCreate = [
- requireFields('name'),
- sanitizeBody('name', 'icon_url'),
+ requireFields("name"),
+ sanitizeBody("name", "icon_url"),
validateTextMaxLengths({ name: 50 }),
(_req, _res, next) => {
const { icon_url } = _req.body;
if (icon_url) {
- if (typeof icon_url !== 'string' || icon_url.length > 255) {
- return next(new BadRequestError('icon_url is too long (max 255 characters)'));
+ if (typeof icon_url !== "string" || icon_url.length > 255) {
+ return next(
+ new BadRequestError("icon_url is too long (max 255 characters)")
+ );
}
if (!isURL(icon_url, { require_protocol: true })) {
- return next(new BadRequestError('Invalid URL format for icon_url'));
+ return next(new BadRequestError("Invalid URL format for icon_url"));
}
}
next();
@@ -75,19 +84,21 @@ export const validateCategoryCreate = [
];
export const validateCategoryUpdate = [
- sanitizeBody('name', 'icon_url'),
+ sanitizeBody("name", "icon_url"),
validateTextMaxLengths({ name: 50 }),
(_req, _res, next) => {
const { name, icon_url } = _req.body;
- if (name != null && name === '') {
- return next(new BadRequestError('name cannot be empty'));
+ if (name != null && name === "") {
+ return next(new BadRequestError("name cannot be empty"));
}
if (icon_url) {
- if (typeof icon_url !== 'string' || icon_url.length > 255) {
- return next(new BadRequestError('icon_url is too long (max 255 characters)'));
+ if (typeof icon_url !== "string" || icon_url.length > 255) {
+ return next(
+ new BadRequestError("icon_url is too long (max 255 characters)")
+ );
}
if (!isURL(icon_url, { require_protocol: true })) {
- return next(new BadRequestError('Invalid URL format for icon_url'));
+ return next(new BadRequestError("Invalid URL format for icon_url"));
}
}
next();
@@ -96,13 +107,68 @@ export const validateCategoryUpdate = [
// Combined middlewares for cleaner routes
export const validateSignup = [
- requireFields('email', 'password'),
+ requireFields("email", "password"),
validateEmail(),
- sanitizeBody('username', 'firstname', 'lastname'),
- validateTextMaxLengths({ username: 50, firstname: 50}),
+ sanitizeBody("username", "firstname", "lastname"),
+ validateTextMaxLengths({ username: 50, firstname: 50 }),
];
export const validateLogin = [
- requireFields('email', 'password'),
+ requireFields("email", "password"),
validateEmail(),
];
+
+//--------------------USER PROFILE VALIDATIONS
+export const validateUpdateProfile = [
+ sanitizeBody("firstname", "lastname", "username"),
+ validateTextMaxLengths({
+ firstname: 100,
+ lastname: 100,
+ username: 50,
+ }),
+ (req, _res, next) => {
+ const { firstname, lastname, username } = req.body;
+
+ //check if at least one field is provided
+ if (
+ firstname === undefined &&
+ lastname === undefined &&
+ username === undefined
+ ) {
+ return next(
+ new BadRequestError("At least one field is required for update")
+ );
+ }
+
+ if (username !== undefined && username === "") {
+ return next(new BadRequestError("Username cannot be empty"));
+ }
+
+ next();
+ },
+];
+
+export const validateChangePassword = [
+ requireFields("currentPassword", "newPassword"),
+ (req, _res, next) => {
+ const { newPassword } = req.body;
+
+ if (
+ !isStrongPassword(String(newPassword), {
+ minLength: 6,
+ minLowercase: 0,
+ minUppercase: 1,
+ minNumbers: 1,
+ minSymbols: 0,
+ })
+ ) {
+ return next(
+ new BadRequestError(
+ "New password must be at least 6 characters and include at least one uppercase letter and one number"
+ )
+ );
+ }
+
+ next();
+ },
+];
diff --git a/server/routes/user.route.js b/server/routes/user.route.js
new file mode 100644
index 0000000..893e653
--- /dev/null
+++ b/server/routes/user.route.js
@@ -0,0 +1,18 @@
+import { Router } from "express";
+import {
+ getUserProfile,
+ updateProfile,
+ changePassword,
+} from "../controllers/user.controller.js";
+import {
+ validateUpdateProfile,
+ validateChangePassword,
+} from "../middleware/validate.js";
+
+const router = Router();
+
+router.get("/profile", getUserProfile);
+router.put("/profile", validateUpdateProfile, updateProfile);
+router.patch("/profile/password", validateChangePassword, changePassword);
+
+export default router;
diff --git a/server/server.js b/server/server.js
index 3e3618a..b62de41 100644
--- a/server/server.js
+++ b/server/server.js
@@ -1,29 +1,33 @@
-import express from 'express';
-import cors from 'cors';
-import dotenv from 'dotenv';
-import cookieParser from 'cookie-parser';
-import { requireAuth } from './middleware/auth.middleware.js';
-import { PrismaClient } from '@prisma/client';
-import incomeRoutes from './routes/income.route.js';
-import authRoutes from './routes/auth.route.js';
-import categoryRoutes from './routes/category.route.js';
+import express from "express";
+import cors from "cors";
+import dotenv from "dotenv";
+import cookieParser from "cookie-parser";
+import { requireAuth } from "./middleware/auth.middleware.js";
+import { PrismaClient } from "@prisma/client";
+import incomeRoutes from "./routes/income.route.js";
+import authRoutes from "./routes/auth.route.js";
+import categoryRoutes from "./routes/category.route.js";
+import userRoutes from "./routes/user.route.js";
import expenseRoutes from './routes/expense.route.js';
+
dotenv.config();
const app = express();
const PORT = process.env.PORT || 8080;
-app.use(cors({
- origin: process.env.CORS_ORIGIN || true,
- credentials: true,
-}));
+app.use(
+ cors({
+ origin: process.env.CORS_ORIGIN || true,
+ credentials: true,
+ })
+);
app.use(express.json());
app.use(cookieParser());
-
-app.use('/api/auth', authRoutes);
-app.use('/api/incomes', requireAuth, incomeRoutes);
-app.use('/api/categories', categoryRoutes);
+app.use("/api/auth", authRoutes);
+app.use("/api/incomes", requireAuth, incomeRoutes);
+app.use("/api/categories", categoryRoutes);
+app.use("/api/user", requireAuth, userRoutes);
app.use('/api/expenses', expenseRoutes)
// Initialize a single Prisma client instance
diff --git a/server/services/auth.service.js b/server/services/auth.service.js
index e5d40eb..0e61ff4 100644
--- a/server/services/auth.service.js
+++ b/server/services/auth.service.js
@@ -52,3 +52,39 @@ export const getPublicUser = async (userId) => {
if (!user) throw new NotFoundError('User not found');
return user;
};
+
+//--------------------------------------------------------
+
+//UPDATE USER, in order to allow user to change their profile informations
+
+export const updateUserProfile = async (userId, { firstname, lastname, username }) => {
+ const user = await prisma.user.update({
+ where: { user_id: userId },
+ data: {
+ firstname: firstname || undefined,
+ lastname: lastname || undefined,
+ username: username || undefined,
+ },
+ select: publicUserSelect,
+ });
+ return user;
+};
+
+//possibility to change password
+
+export const changeUserPassword = async (userId, { currentPassword, newPassword }) => {
+ const user = await prisma.user.findUnique({ where: { user_id: userId } });
+ if (!user) throw new NotFoundError('User not found');
+
+ const ok = await bcrypt.compare(currentPassword, user.hashed_password);
+ if (!ok) throw new UnauthorizedError('Current password is incorrect');
+
+ const hashed_password = await bcrypt.hash(newPassword, 10);
+
+ await prisma.user.update({
+ where: { user_id: userId },
+ data: { hashed_password },
+ });
+
+ return { message: 'Password updated successfully' };
+};
\ No newline at end of file