add password and display name manipulation
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 13s
Build & Deploy Costco Grocery List / deploy (push) Successful in 6s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s

This commit is contained in:
Nico 2026-01-24 21:38:33 -08:00
parent 889914a57f
commit 1281c91c28
8 changed files with 553 additions and 8 deletions

View File

@ -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

View File

@ -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" });
}
};

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -4,3 +4,15 @@ 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 } });
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 });
};

View File

@ -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
</button>
<button
className={`settings-tab ${activeTab === "account" ? "active" : ""}`}
onClick={() => setActiveTab("account")}
>
Account
</button>
</div>
<div className="settings-content">
@ -237,6 +317,102 @@ export default function Settings() {
</div>
</div>
)}
{/* Account Tab */}
{activeTab === "account" && (
<div className="settings-section">
<h2 className="text-xl font-semibold mb-4">Account Management</h2>
{accountMessage.text && (
<div className={`account-message ${accountMessage.type}`}>
{accountMessage.text}
</div>
)}
{/* Display Name Section */}
<form onSubmit={handleUpdateDisplayName} className="account-form">
<h3 className="text-lg font-semibold mb-3">Display Name</h3>
<div className="settings-group">
<label className="settings-label">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
maxLength={100}
className="form-input"
placeholder="Your display name"
/>
<p className="settings-description">
{displayName.length}/100 characters
</p>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loadingProfile}
>
{loadingProfile ? "Saving..." : "Save Display Name"}
</button>
</form>
<hr className="my-4" style={{ borderColor: 'var(--border-color)' }} />
{/* Password Change Section */}
<form onSubmit={handleChangePassword} className="account-form">
<h3 className="text-lg font-semibold mb-3">Change Password</h3>
<div className="settings-group">
<label className="settings-label">
Current Password
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="form-input"
required
/>
</div>
<div className="settings-group">
<label className="settings-label">
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="form-input"
minLength={6}
required
/>
<p className="settings-description">
Minimum 6 characters
</p>
</div>
<div className="settings-group">
<label className="settings-label">
Confirm New Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="form-input"
minLength={6}
required
/>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={loadingPassword}
>
{loadingPassword ? "Changing..." : "Change Password"}
</button>
</form>
</div>
)}
</div>
<div className="mt-4">

View File

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