diff --git a/ACCOUNT_MANAGEMENT_IMPLEMENTATION.md b/ACCOUNT_MANAGEMENT_IMPLEMENTATION.md new file mode 100644 index 0000000..2ba17f8 --- /dev/null +++ b/ACCOUNT_MANAGEMENT_IMPLEMENTATION.md @@ -0,0 +1,198 @@ +# Account Management Implementation (Phase 4) + +## Overview +Phase 4 adds account management features allowing users to: +- Change their display name (friendly name separate from username) +- Change their password with current password verification + +## Database Changes + +### Migration: `add_display_name_column.sql` +```sql +ALTER TABLE users +ADD COLUMN IF NOT EXISTS display_name VARCHAR(100); + +UPDATE users +SET display_name = name +WHERE display_name IS NULL; +``` + +**To run migration:** +Connect to your PostgreSQL database and execute: +```bash +psql -U your_user -d your_database -f backend/migrations/add_display_name_column.sql +``` + +## Backend Implementation + +### New Model Functions (`backend/models/user.model.js`) +- `getUserById(id)` - Fetch user by ID including display_name +- `updateUserProfile(id, updates)` - Update user profile (display_name) +- `updateUserPassword(id, hashedPassword)` - Update user password +- `getUserPasswordHash(id)` - Get current password hash for verification + +### New Controllers (`backend/controllers/users.controller.js`) +- `getCurrentUser` - GET authenticated user's profile +- `updateCurrentUser` - PATCH user's display name + - Validates: 1-100 characters, trims whitespace +- `changePassword` - POST password change + - Validates: current password correct, new password min 6 chars, passwords match + - Uses bcrypt for password verification and hashing + +### New Routes (`backend/routes/users.routes.js`) +All routes require authentication (`auth` middleware): +- `GET /api/users/me` - Get current user profile +- `PATCH /api/users/me` - Update display name +- `POST /api/users/me/change-password` - Change password + +**Request bodies:** +```javascript +// Update display name +PATCH /api/users/me +{ + "display_name": "New Display Name" +} + +// Change password +POST /api/users/me/change-password +{ + "current_password": "oldpass123", + "new_password": "newpass123" +} +``` + +## Frontend Implementation + +### API Functions (`frontend/src/api/users.js`) +- `getCurrentUser()` - Fetch current user profile +- `updateCurrentUser(display_name)` - Update display name +- `changePassword(current_password, new_password)` - Change password + +### Settings Page Updates (`frontend/src/pages/Settings.jsx`) +Added new "Account" tab with two sections: + +**Display Name Section:** +- Input field with character counter (max 100) +- Real-time validation +- Save button with loading state + +**Password Change Section:** +- Current password field +- New password field (min 6 chars) +- Confirm password field +- Client-side validation before submission +- Loading state during submission + +**Features:** +- Success/error messages displayed at top of tab +- Form validation (character limits, password matching) +- Disabled buttons during API calls +- Auto-clears password fields on success + +### Styling (`frontend/src/styles/pages/Settings.css`) +Added: +- `.account-form` - Form container styling +- `.account-message` - Success/error message styling +- `.account-message.success` - Green success messages +- `.account-message.error` - Red error messages + +## Security Features + +### Password Requirements +- **Backend validation:** + - Minimum 6 characters + - Current password verification before change + - bcrypt hashing (10 rounds) + +- **Frontend validation:** + - HTML5 minlength attribute + - Client-side password matching check + - Current password required + +### Display Name Validation +- **Backend:** + - 1-100 character limit + - Whitespace trimming + +- **Frontend:** + - HTML5 maxlength attribute + - Character counter + +## Usage + +### For Users +1. Navigate to Settings → Account tab +2. **Change Display Name:** + - Enter new display name (1-100 chars) + - Click "Save Display Name" +3. **Change Password:** + - Enter current password + - Enter new password (min 6 chars) + - Confirm new password + - Click "Change Password" + +### For Developers +**Testing the endpoints:** +```bash +# Get current user +curl -X GET http://localhost:5000/api/users/me \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Update display name +curl -X PATCH http://localhost:5000/api/users/me \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"display_name": "New Name"}' + +# Change password +curl -X POST http://localhost:5000/api/users/me/change-password \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "current_password": "oldpass", + "new_password": "newpass" + }' +``` + +## Next Steps + +### Optional Enhancements +1. **Password strength indicator** - Visual feedback on password complexity +2. **Display name in navbar** - Show display_name instead of username in UI +3. **Email verification** - Add email field and verification +4. **2FA support** - Two-factor authentication option +5. **Password history** - Prevent reusing recent passwords +6. **Session management** - View/revoke active sessions + +### Integration with AuthContext +Consider updating `AuthContext` to: +- Store and expose display_name +- Refresh user data after profile updates +- Show display_name in navbar/profile components + +## Files Modified + +### Backend +- ✅ `backend/migrations/add_display_name_column.sql` (NEW) +- ✅ `backend/models/user.model.js` +- ✅ `backend/controllers/users.controller.js` +- ✅ `backend/routes/users.routes.js` + +### Frontend +- ✅ `frontend/src/api/users.js` +- ✅ `frontend/src/pages/Settings.jsx` +- ✅ `frontend/src/styles/pages/Settings.css` + +## Testing Checklist + +- [ ] Run database migration +- [ ] Test GET /api/users/me endpoint +- [ ] Test display name update with valid data +- [ ] Test display name update with invalid data (empty, too long) +- [ ] Test password change with correct current password +- [ ] Test password change with incorrect current password +- [ ] Test password change with mismatched new passwords +- [ ] Test password change with weak password (< 6 chars) +- [ ] Verify frontend validation prevents invalid submissions +- [ ] Verify success/error messages display correctly +- [ ] Test UI responsiveness on mobile diff --git a/backend/controllers/users.controller.js b/backend/controllers/users.controller.js index 0ef8399..033a6c8 100644 --- a/backend/controllers/users.controller.js +++ b/backend/controllers/users.controller.js @@ -1,4 +1,5 @@ const User = require("../models/user.model"); +const bcrypt = require("bcryptjs"); exports.test = async (req, res) => { console.log("User route is working"); @@ -45,11 +46,92 @@ exports.deleteUser = async (req, res) => { } }; - - - exports.checkIfUserExists = async (req, res) => { const { username } = req.query; - const users = await User.checkIfUserExists(username); - res.json(users); + const exists = await User.checkIfUserExists(username); + res.json(exists); +}; + +exports.getCurrentUser = async (req, res) => { + try { + const userId = req.user.id; + const user = await User.getUserById(userId); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + res.json(user); + } catch (err) { + console.error("Error getting current user:", err); + res.status(500).json({ error: "Failed to get user profile" }); + } +}; + +exports.updateCurrentUser = async (req, res) => { + try { + const userId = req.user.id; + const { display_name } = req.body; + + if (!display_name || display_name.trim().length === 0) { + return res.status(400).json({ error: "Display name is required" }); + } + + if (display_name.length > 100) { + return res.status(400).json({ error: "Display name must be 100 characters or less" }); + } + + const updated = await User.updateUserProfile(userId, { display_name: display_name.trim() }); + + if (!updated) { + return res.status(404).json({ error: "User not found" }); + } + + res.json({ message: "Profile updated successfully", user: updated }); + } catch (err) { + console.error("Error updating user profile:", err); + res.status(500).json({ error: "Failed to update profile" }); + } +}; + +exports.changePassword = async (req, res) => { + try { + const userId = req.user.id; + const { current_password, new_password } = req.body; + + // Validation + if (!current_password || !new_password) { + return res.status(400).json({ error: "Current password and new password are required" }); + } + + if (new_password.length < 6) { + return res.status(400).json({ error: "New password must be at least 6 characters" }); + } + + // Get current password hash + const currentHash = await User.getUserPasswordHash(userId); + + if (!currentHash) { + return res.status(404).json({ error: "User not found" }); + } + + // Verify current password + const isValidPassword = await bcrypt.compare(current_password, currentHash); + + if (!isValidPassword) { + return res.status(401).json({ error: "Current password is incorrect" }); + } + + // Hash new password + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(new_password, salt); + + // Update password + await User.updateUserPassword(userId, hashedPassword); + + res.json({ message: "Password changed successfully" }); + } catch (err) { + console.error("Error changing password:", err); + res.status(500).json({ error: "Failed to change password" }); + } }; diff --git a/backend/migrations/add_display_name_column.sql b/backend/migrations/add_display_name_column.sql new file mode 100644 index 0000000..37e559e --- /dev/null +++ b/backend/migrations/add_display_name_column.sql @@ -0,0 +1,10 @@ +-- Add display_name column to users table +-- This allows users to have a friendly name separate from their username + +ALTER TABLE users +ADD COLUMN IF NOT EXISTS display_name VARCHAR(100); + +-- Set display_name to name for existing users (as default) +UPDATE users +SET display_name = name +WHERE display_name IS NULL; diff --git a/backend/models/user.model.js b/backend/models/user.model.js index 0340cc4..706b226 100644 --- a/backend/models/user.model.js +++ b/backend/models/user.model.js @@ -24,10 +24,49 @@ exports.createUser = async (username, hashedPassword, name) => { exports.getAllUsers = async () => { - const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC"); + const result = await pool.query("SELECT id, username, name, role, display_name FROM users ORDER BY id ASC"); return result.rows; }; +exports.getUserById = async (id) => { + const result = await pool.query( + "SELECT id, username, name, role, display_name FROM users WHERE id = $1", + [id] + ); + return result.rows[0]; +}; + +exports.updateUserProfile = async (id, updates) => { + const { display_name } = updates; + const result = await pool.query( + `UPDATE users + SET display_name = COALESCE($1, display_name) + WHERE id = $2 + RETURNING id, username, name, role, display_name`, + [display_name, id] + ); + return result.rows[0]; +}; + +exports.updateUserPassword = async (id, hashedPassword) => { + const result = await pool.query( + `UPDATE users + SET password = $1 + WHERE id = $2 + RETURNING id`, + [hashedPassword, id] + ); + return result.rows[0]; +}; + +exports.getUserPasswordHash = async (id) => { + const result = await pool.query( + "SELECT password FROM users WHERE id = $1", + [id] + ); + return result.rows[0]?.password; +}; + exports.updateUserRole = async (id, role) => { const result = await pool.query( diff --git a/backend/routes/users.routes.js b/backend/routes/users.routes.js index c13c5be..2a8796b 100644 --- a/backend/routes/users.routes.js +++ b/backend/routes/users.routes.js @@ -7,4 +7,9 @@ const { ROLES } = require("../models/user.model"); router.get("/exists", usersController.checkIfUserExists); router.get("/test", usersController.test); +// Current user profile routes (authenticated) +router.get("/me", auth, usersController.getCurrentUser); +router.patch("/me", auth, usersController.updateCurrentUser); +router.post("/me/change-password", auth, usersController.changePassword); + module.exports = router; diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js index c4d13d1..6d2ce42 100644 --- a/frontend/src/api/users.js +++ b/frontend/src/api/users.js @@ -3,4 +3,16 @@ import api from "./axios"; export const getAllUsers = () => api.get("/admin/users"); export const updateRole = (id, role) => api.put(`/admin/users`, { id, role }); export const deleteUser = (id) => api.delete(`/admin/users/${id}`); -export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } }); \ No newline at end of file +export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } }); + +export const getCurrentUser = async () => { + return api.get("/users/me"); +}; + +export const updateCurrentUser = async (display_name) => { + return api.patch("/users/me", { display_name }); +}; + +export const changePassword = async (current_password, new_password) => { + return api.post("/users/me/change-password", { current_password, new_password }); +}; \ No newline at end of file diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index d37c29e..5c0ce13 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,4 +1,5 @@ -import { useContext, useState } from "react"; +import { useContext, useEffect, useState } from "react"; +import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users"; import { SettingsContext } from "../context/SettingsContext"; import "../styles/pages/Settings.css"; @@ -7,11 +8,84 @@ export default function Settings() { const { settings, updateSettings, resetSettings } = useContext(SettingsContext); const [activeTab, setActiveTab] = useState("appearance"); + // Account management state + const [displayName, setDisplayName] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [accountMessage, setAccountMessage] = useState({ type: "", text: "" }); + const [loadingProfile, setLoadingProfile] = useState(false); + const [loadingPassword, setLoadingPassword] = useState(false); + + // Load user profile + useEffect(() => { + const loadProfile = async () => { + try { + const response = await getCurrentUser(); + setDisplayName(response.data.display_name || response.data.name || ""); + } catch (error) { + console.error("Failed to load profile:", error); + } + }; + loadProfile(); + }, []); + const handleThemeChange = (theme) => { updateSettings({ theme }); }; + const handleUpdateDisplayName = async (e) => { + e.preventDefault(); + setLoadingProfile(true); + setAccountMessage({ type: "", text: "" }); + + try { + await updateCurrentUser(displayName); + setAccountMessage({ type: "success", text: "Display name updated successfully!" }); + } catch (error) { + setAccountMessage({ + type: "error", + text: error.response?.data?.error || "Failed to update display name" + }); + } finally { + setLoadingProfile(false); + } + }; + + const handleChangePassword = async (e) => { + e.preventDefault(); + setLoadingPassword(true); + setAccountMessage({ type: "", text: "" }); + + if (newPassword !== confirmPassword) { + setAccountMessage({ type: "error", text: "New passwords don't match" }); + setLoadingPassword(false); + return; + } + + if (newPassword.length < 6) { + setAccountMessage({ type: "error", text: "Password must be at least 6 characters" }); + setLoadingPassword(false); + return; + } + + try { + await changePassword(currentPassword, newPassword); + setAccountMessage({ type: "success", text: "Password changed successfully!" }); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch (error) { + setAccountMessage({ + type: "error", + text: error.response?.data?.error || "Failed to change password" + }); + } finally { + setLoadingPassword(false); + } + }; + const handleToggle = (key) => { updateSettings({ [key]: !settings[key] }); @@ -59,6 +133,12 @@ export default function Settings() { > Behavior +
@@ -237,6 +317,102 @@ export default function Settings() {
)} + + {/* Account Tab */} + {activeTab === "account" && ( +
+

Account Management

+ + {accountMessage.text && ( +
+ {accountMessage.text} +
+ )} + + {/* Display Name Section */} +
+

Display Name

+
+ + setDisplayName(e.target.value)} + maxLength={100} + className="form-input" + placeholder="Your display name" + /> +

+ {displayName.length}/100 characters +

+
+ +
+ +
+ + {/* Password Change Section */} +
+

Change Password

+
+ + setCurrentPassword(e.target.value)} + className="form-input" + required + /> +
+
+ + setNewPassword(e.target.value)} + className="form-input" + minLength={6} + required + /> +

+ Minimum 6 characters +

+
+
+ + setConfirmPassword(e.target.value)} + className="form-input" + minLength={6} + required + /> +
+ +
+
+ )}
diff --git a/frontend/src/styles/pages/Settings.css b/frontend/src/styles/pages/Settings.css index 0b5abc4..6acd6a2 100644 --- a/frontend/src/styles/pages/Settings.css +++ b/frontend/src/styles/pages/Settings.css @@ -188,3 +188,26 @@ } } +/* Account Management */ +.account-form { + margin-bottom: var(--spacing-xl); +} + +.account-message { + padding: var(--spacing-md); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-lg); + font-weight: 500; +} + +.account-message.success { + background-color: var(--color-success-bg); + color: var(--color-success); + border: 1px solid var(--color-success); +} + +.account-message.error { + background-color: var(--color-danger-bg); + color: var(--color-danger); + border: 1px solid var(--color-danger); +}