Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/middleware/validateUserProfile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { body, validationResult } from "express-validator";

export const validateUserProfile = [
body("name").optional().isString().withMessage("Name must be a string"),
body("bio").optional().isString().withMessage("Bio must be a string"),
body("location")
.optional()
.isString()
.withMessage("Location must be a string"),
body("avatar").optional().isURL().withMessage("Avatar must be a valid URL"),

(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
},
];

// Using express-validator to validate user profile fields
96 changes: 81 additions & 15 deletions backend/models/userModel.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,90 @@
import mongoose from "mongoose";

const userSchema = new mongoose.Schema({
name: { type: String, default: "" }, // Optional name field. It can be updated from frontend
email: { type: String, required: true, unique: true },
password: { type: String, required: true }, // Password should be hashed before saving to the database
bio: { type: String, default: "" }, // Optional bio field
location: { type: String, default: "" }, // Optional location field
avatar: { type: String, default: "" }, // Optional avatar field
// This field can be used to store the URL of the user's avatar image
createdAt: { type: Date, default: Date.now }, // Timestamp of user creation
updatedAt: { type: Date, default: Date.now }, // Timestamp of last update
role: {
type: String,
enum: ["admin", "user"],
default: "user",
const userSchema = new mongoose.Schema(
{
name: { type: String, default: "" }, // Optional name field. It can be updated from frontend
email: { type: String, required: true, unique: true },
password: { type: String, required: true }, // Password should be hashed before saving to the database
bio: { type: String, default: "" }, // Optional bio field
location: { type: String, default: "" }, // Optional location field
avatar: {
type: String,
default: "https://avatars.githubusercontent.com/u/165805964?v=4",
}, // Optional avatar field (my github avatar URL)
// This field can be used to store the URL of the user's avatar image
createdAt: { type: Date, default: Date.now }, // Timestamp of user creation
updatedAt: { type: Date, default: Date.now }, // Timestamp of last update
role: {
type: String,
enum: ["admin", "user"],
default: "user",
},
},
});
{ timestamps: true } // Add createdAt and updatedAt fields automatically
);

const User = mongoose.model("User", userSchema);

export default User;

// Next, I am going jump and create an auth route inside app.js. This route will be responsible for authenticating users. I split it when i have more time and more routes to create.

// Full version of the user model with regex to check the valid email, hashed password before saving, compare password and time stamp for createdAt and updatedAt fields, and role-based access control.

{
/*
import mongoose from "mongoose";
import bcrypt from "bcrypt";

const userSchema = new mongoose.Schema(
{
name: { type: String, default: "" }, // Optional name field
email: {
type: String,
required: true,
unique: true,
validate: {
validator: function (v) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); // Regex for email validation
},
message: (props) => `${props.value} is not a valid email address!`,
},
},
password: { type: String, required: true }, // Password should be hashed before saving
bio: { type: String, default: "" }, // Optional bio field
location: { type: String, default: "" }, // Optional location field
avatar: {
type: String,
default: "https://avatars.githubusercontent.com/u/165805964?v=4", // Default avatar URL
},
role: {
type: String,
enum: ["admin", "user"],
default: "user",
},
},
{ timestamps: true } // Automatically adds createdAt and updatedAt fields
);

// Hash password before saving
userSchema.pre("save", async function (next) {
if (!this.isModified("password")) return next();
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (err) {
next(err);
}
});

// Method to compare passwords
userSchema.methods.comparePassword = async function (candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};

const User = mongoose.model("User", userSchema);

export default User;
*/
}
27 changes: 12 additions & 15 deletions backend/routes/userProfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import express from "express";
import authenticateToken from "../middleware/authMiddleware.js";
import User from "../models/userModel.js";
import { validateUserProfile } from "../middleware/validateUserProfile.js";

const router = express.Router();
console.log("userProfile routes is loaded");
Expand All @@ -28,28 +29,24 @@ router.get("/", authenticateToken, async (req, res) => {

// PUT /profile — update user profile fields!
// Change to PATCH method to allow partial updates
router.patch("/", authenticateToken, async (req, res) => {
// Additional validation middleware to ensure correct data types (validateUserProfile)
router.patch("/", authenticateToken, validateUserProfile, async (req, res) => {
console.log("Updating user profile:", req.body);
try {
const { name, bio, location, avatar } = req.body;

// Optional validation
if (name && typeof name !== "string") {
return res.status(400).json({ error: "Name must be a string" });
}
if (bio && typeof bio !== "string") {
return res.status(400).json({ error: "Bio must be a string" });
}
if (location && typeof location !== "string") {
return res.status(400).json({ error: "Location must be a string" });
}
if (avatar && typeof avatar !== "string") {
return res.status(400).json({ error: "Avatar must be a string (URL)" });
}

const user = await User.findById(req.user._id);
if (!user) return res.status(404).json({ error: "User not found" });

// Validate that at least one field is provided
if (!name && !bio && !location && !avatar) {
return res
.status(400)
.json({ error: "At least one field must be provided" });
}

// user profile validation moved to validateUserProfile middleware!

// Conditionally update only if provided
if (name !== undefined) user.name = name;
if (bio !== undefined) user.bio = bio;
Expand Down
62 changes: 32 additions & 30 deletions frontend/src/components/views/UserProfile.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { useUserProfileFetcher } from "../hooks/userProfileFetcher";
import PropTypes from "prop-types";
import { apiRequest } from "../utils/api";
Expand All @@ -8,24 +8,20 @@ import BtnNeoGradient from "../buttons/BtnNeonGradient";
import Button from "../buttons/Button";
import ButtonGradient from "../buttons/ButtonGradient";

const UserProfile = ({ returnToInfo }) => {
const UserProfile = ({ returnToInfo, onUpdate }) => {
const { profile, setProfile, loading, message, setMessage, refetch } =
useUserProfileFetcher();
const [saving, setSaving] = useState(false);

useEffect(() => {
refetch(); // Force refetch when the component mounts
}, []);

const { name, bio, location, avatar } = profile;
// Detect if any field changed - Updated from name only to include all fields

const isUnchanged =
name === "" && bio === "" && location === "" && avatar === "";

const handleChange = (e) => {
setProfile({ ...profile, [e.target.name]: e.target.value });
};
// Handle update

const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
Expand All @@ -41,36 +37,50 @@ const UserProfile = ({ returnToInfo }) => {
setMessage("Profile updated!");
console.log("Profile updated successfully:", name);

// Refetch the profile data from the server to ensure it's up-to-date
await refetch();
console.log("Refetched profile data:", profile);
await refetch(); // Refetch the profile data to ensure it's up-to-date

if (onUpdate) {
onUpdate({
name: payload.name,
bio: payload.bio,
location: payload.location,
avatar: payload.avatar,
});
console.log("onUpdate callback called with:", payload);
}
} catch (err) {
setMessage(err.message);
} finally {
setSaving(false);
}
};

// If loading, show a loading message
if (loading) {
return <p className="text-yellow-500">Loading profile...</p>;
}

console.log("Rendering UserProfile with profile:", profile);

return (
<div className="bg-neutral-800/20 backdrop-blur-sm p-4 rounded-lg shadow-lg max-w-xs">
<h2 className="text-2xl text-red-600 p-2 font-bold mb-2">
Edit Your Profile
</h2>
<h3 className="text-2xl text-red-600 p-2 font-bold mb-1 text-center">
{name || "User Profile"}
</h3>
<form onSubmit={handleSubmit}>
{avatar && (
<div className="mb-1 flex justify-center">
<img
src={avatar}
alt="Avatar Preview"
className="rounded-full w-24 h-24 object-cover"
/>
</div>
)}
<label className="block mb-2">
Name:
<input
name="name"
className="border p-2 w-full"
type="text"
value={profile.name}
value={name}
onChange={handleChange}
disabled={loading}
placeholder="Enter your user name"
Expand All @@ -82,7 +92,7 @@ const UserProfile = ({ returnToInfo }) => {
<textarea
name="bio"
className="border p-2 w-full"
value={profile.bio}
value={bio}
onChange={handleChange}
disabled={loading}
rows="3"
Expand All @@ -96,7 +106,7 @@ const UserProfile = ({ returnToInfo }) => {
name="location"
className="border p-2 w-full"
type="text"
value={profile.location}
value={location}
onChange={handleChange}
disabled={loading}
placeholder="Where are you located?"
Expand All @@ -109,22 +119,13 @@ const UserProfile = ({ returnToInfo }) => {
name="avatar"
className="border p-2 w-full"
type="text"
value={profile.avatar}
value={avatar}
onChange={handleChange}
disabled={loading}
placeholder="Enter your avatar image URL"
/>
</label>

{avatar && (
<div className="mb-2">
<img
src={avatar}
alt="Avatar Preview"
className="rounded-full w-20 h-20 object-cover"
/>
</div>
)}
<BtnNeoGradient />
<SpaceBtn
type="submit"
Expand All @@ -144,6 +145,7 @@ const UserProfile = ({ returnToInfo }) => {
</div>
);
};

UserProfile.propTypes = {
returnToInfo: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired,
Expand Down