feat: add self-service password change from user menu
All checks were successful
Deploy / deploy (push) Successful in 11s
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:
@@ -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 (
|
||||
<>
|
||||
<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 */}
|
||||
<div>
|
||||
@@ -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"
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
|
||||
user?.role === 'superadmin'
|
||||
? 'bg-gradient-to-br from-purple-500 to-pink-500'
|
||||
user?.role === 'superadmin'
|
||||
? 'bg-gradient-to-br from-purple-500 to-pink-500'
|
||||
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
||||
}`}>
|
||||
{getInitials(user?.name)}
|
||||
@@ -103,7 +145,7 @@ export default function Header() {
|
||||
<div className="py-2">
|
||||
{user?.role === 'superadmin' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
window.location.href = '/users'
|
||||
}}
|
||||
@@ -113,9 +155,17 @@ export default function Header() {
|
||||
<span className="text-sm text-text-primary">User Management</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
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
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
logout()
|
||||
}}
|
||||
@@ -130,5 +180,74 @@ export default function Header() {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user