feat: add self-service password change from user menu
All checks were successful
Deploy / deploy (push) Successful in 11s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-02-23 15:54:29 +03:00
parent 7554b1cb56
commit 52d69ee02d
2 changed files with 147 additions and 7 deletions

View File

@@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useLocation } from 'react-router-dom' 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 { useAuth } from '../contexts/AuthContext'
import { getInitials } from '../utils/api' import { getInitials, api } from '../utils/api'
import Modal from './Modal'
const pageTitles = { const pageTitles = {
'/': 'Dashboard', '/': 'Dashboard',
@@ -25,6 +26,11 @@ const ROLE_INFO = {
export default function Header() { export default function Header() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [showDropdown, setShowDropdown] = useState(false) 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 dropdownRef = useRef(null)
const location = useLocation() const location = useLocation()
@@ -46,9 +52,45 @@ export default function Header() {
return () => document.removeEventListener('mousedown', handleClickOutside) 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 const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor
return ( return (
<>
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20"> <header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
{/* Page title */} {/* Page title */}
<div> <div>
@@ -114,6 +156,14 @@ export default function Header() {
</button> </button>
)} )}
<button
onClick={openPasswordModal}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-left"
>
<Lock className="w-4 h-4 text-text-tertiary" />
<span className="text-sm text-text-primary">Change Password</span>
</button>
<button <button
onClick={() => { onClick={() => {
setShowDropdown(false) setShowDropdown(false)
@@ -130,5 +180,74 @@ export default function Header() {
</div> </div>
</div> </div>
</header> </header>
{/* Change Password Modal */}
<Modal isOpen={showPasswordModal} onClose={() => setShowPasswordModal(false)} title="Change Password" size="md">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Current Password</label>
<input
type="password"
value={passwordForm.currentPassword}
onChange={e => { 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="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">New Password</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={e => { 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}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Confirm New Password</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={e => { 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}
/>
</div>
{passwordError && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0" />
<p className="text-sm text-red-500">{passwordError}</p>
</div>
)}
{passwordSuccess && (
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<CheckCircle className="w-4 h-4 text-green-500 shrink-0" />
<p className="text-sm text-green-500">{passwordSuccess}</p>
</div>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowPasswordModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
Cancel
</button>
<button
onClick={handlePasswordChange}
disabled={!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword || passwordSaving}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
>
{passwordSaving ? 'Saving...' : 'Update Password'}
</button>
</div>
</div>
</Modal>
</>
) )
} }

View File

@@ -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) => { app.patch('/api/users/me/tutorial', requireAuth, async (req, res) => {
try { try {
await nocodb.update('Users', req.session.userId, { tutorial_completed: !!req.body.completed }); await nocodb.update('Users', req.session.userId, { tutorial_completed: !!req.body.completed });