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