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 ( +
+

Change Password

+ +
+ clearFieldError("currentPassword")} + placeholder="Enter current password" + variant="outlined" + size="medium" + fullWidth + error={!!fieldErrors.currentPassword} + helperText={fieldErrors.currentPassword} + /> + + clearFieldError("newPassword")} + placeholder="Enter new password" + variant="outlined" + size="medium" + fullWidth + error={!!fieldErrors.newPassword} + helperText={ + fieldErrors.newPassword || + "Must be at least 6 characters with 1 uppercase letter and 1 number" + } + /> + + clearFieldError("confirmPassword")} + placeholder="Confirm new password" + variant="outlined" + size="medium" + fullWidth + error={!!fieldErrors.confirmPassword} + helperText={fieldErrors.confirmPassword} + /> + + {message && ( +
+ {message} +
+ )} + + {formError && !Object.values(fieldErrors).some((error) => error) && ( +
+ {formError} +
+ )} + + + +
+ ); +}; 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 +

+ +
+
+ + + +
+ + + +
+ +
+ {profile.email} +
+

Email cannot be changed

+
+ + {message && ( +
+ {message} +
+ )} + + {formError && ( +
+ {formError} +
+ )} + + + +
+ ); +}; 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 = () => {

- +
Profile -
+ {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 ( +
+
+
+

Loading profile...

+
+
+ ); + } + + 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