From 52d69ee02df62aaa26ba727ea74bf5122b8e2c49 Mon Sep 17 00:00:00 2001 From: fahed Date: Mon, 23 Feb 2026 15:54:29 +0300 Subject: [PATCH] feat: add self-service password change from user menu Co-Authored-By: Claude Opus 4.6 --- client/src/components/Header.jsx | 133 +++++++++++++++++++++++++++++-- server/server.js | 21 +++++ 2 files changed, 147 insertions(+), 7 deletions(-) diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index 4962d22..956dbb1 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -1,8 +1,9 @@ import { useState, useRef, useEffect } from 'react' import { useLocation } from 'react-router-dom' -import { Bell, ChevronDown, LogOut, Shield } from 'lucide-react' +import { Bell, ChevronDown, LogOut, Shield, Lock, AlertCircle, CheckCircle } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' -import { getInitials } from '../utils/api' +import { getInitials, api } from '../utils/api' +import Modal from './Modal' const pageTitles = { '/': 'Dashboard', @@ -25,6 +26,11 @@ const ROLE_INFO = { export default function Header() { const { user, logout } = useAuth() const [showDropdown, setShowDropdown] = useState(false) + const [showPasswordModal, setShowPasswordModal] = useState(false) + const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' }) + const [passwordError, setPasswordError] = useState('') + const [passwordSuccess, setPasswordSuccess] = useState('') + const [passwordSaving, setPasswordSaving] = useState(false) const dropdownRef = useRef(null) const location = useLocation() @@ -46,9 +52,45 @@ export default function Header() { return () => document.removeEventListener('mousedown', handleClickOutside) }, []) + const handlePasswordChange = async () => { + setPasswordError('') + setPasswordSuccess('') + if (passwordForm.newPassword !== passwordForm.confirmPassword) { + setPasswordError('New passwords do not match') + return + } + if (passwordForm.newPassword.length < 6) { + setPasswordError('New password must be at least 6 characters') + return + } + setPasswordSaving(true) + try { + await api.patch('/users/me/password', { + currentPassword: passwordForm.currentPassword, + newPassword: passwordForm.newPassword, + }) + setPasswordSuccess('Password updated successfully') + setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }) + setTimeout(() => setShowPasswordModal(false), 1500) + } catch (err) { + setPasswordError(err.message || 'Failed to change password') + } finally { + setPasswordSaving(false) + } + } + + const openPasswordModal = () => { + setShowDropdown(false) + setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }) + setPasswordError('') + setPasswordSuccess('') + setShowPasswordModal(true) + } + const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor return ( + <>
{/* Page title */}
@@ -70,8 +112,8 @@ export default function Header() { className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-surface-tertiary transition-colors" >
{getInitials(user?.name)} @@ -103,7 +145,7 @@ export default function Header() {
{user?.role === 'superadmin' && ( )} - + + +
+ + {/* Change Password Modal */} + setShowPasswordModal(false)} title="Change Password" size="md"> +
+
+ + { setPasswordForm(f => ({ ...f, currentPassword: e.target.value })); setPasswordError('') }} + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" + placeholder="••••••••" + /> +
+
+ + { setPasswordForm(f => ({ ...f, newPassword: e.target.value })); setPasswordError('') }} + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" + placeholder="••••••••" + minLength={6} + /> +
+
+ + { setPasswordForm(f => ({ ...f, confirmPassword: e.target.value })); setPasswordError('') }} + className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" + placeholder="••••••••" + minLength={6} + /> +
+ + {passwordError && ( +
+ +

{passwordError}

+
+ )} + + {passwordSuccess && ( +
+ +

{passwordSuccess}

+
+ )} + +
+ + +
+
+
+ ) } diff --git a/server/server.js b/server/server.js index c87806e..24336e8 100644 --- a/server/server.js +++ b/server/server.js @@ -758,6 +758,27 @@ app.patch('/api/users/me/profile', requireAuth, async (req, res) => { } }); +app.patch('/api/users/me/password', requireAuth, async (req, res) => { + const { currentPassword, newPassword } = req.body; + if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current password and new password are required' }); + if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' }); + + try { + const cred = authDb.prepare('SELECT * FROM auth_credentials WHERE nocodb_user_id = ?').get(req.session.userId); + if (!cred) return res.status(404).json({ error: 'Credentials not found' }); + + const valid = await bcrypt.compare(currentPassword, cred.password_hash); + if (!valid) return res.status(401).json({ error: 'Current password is incorrect' }); + + const hash = await bcrypt.hash(newPassword, 10); + authDb.prepare('UPDATE auth_credentials SET password_hash = ? WHERE nocodb_user_id = ?').run(hash, req.session.userId); + res.json({ message: 'Password updated successfully' }); + } catch (err) { + console.error('Change password error:', err); + res.status(500).json({ error: 'Failed to change password' }); + } +}); + app.patch('/api/users/me/tutorial', requireAuth, async (req, res) => { try { await nocodb.update('Users', req.session.userId, { tutorial_completed: !!req.body.completed });