feat: admin password change with confirmation in team panel
All checks were successful
Deploy / deploy (push) Successful in 11s
All checks were successful
Deploy / deploy (push) Successful in 11s
Add "Admin Actions" section (superadmin-only, collapsed by default) with password + confirm fields, eye toggle, mismatch validation, and success toast. Delete button moved here too. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect, useRef, useContext } from 'react'
|
import { useState, useEffect, useRef, useContext } from 'react'
|
||||||
import { X, Trash2, ChevronDown, Check } from 'lucide-react'
|
import { X, Trash2, ChevronDown, Check, ShieldAlert, Eye, EyeOff } from 'lucide-react'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import { useToast } from './ToastContainer'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import SlidePanel from './SlidePanel'
|
import SlidePanel from './SlidePanel'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
@@ -18,12 +19,16 @@ const MODULE_COLORS = {
|
|||||||
|
|
||||||
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
||||||
const { t, lang } = useLanguage()
|
const { t, lang } = useLanguage()
|
||||||
|
const toast = useToast()
|
||||||
const { roles } = useContext(AppContext)
|
const { roles } = useContext(AppContext)
|
||||||
const [form, setForm] = useState({})
|
const [form, setForm] = useState({})
|
||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [showBrandsDropdown, setShowBrandsDropdown] = useState(false)
|
const [showBrandsDropdown, setShowBrandsDropdown] = useState(false)
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [passwordSaving, setPasswordSaving] = useState(false)
|
||||||
const brandsDropdownRef = useRef(null)
|
const brandsDropdownRef = useRef(null)
|
||||||
|
|
||||||
// Workload state (loaded internally)
|
// Workload state (loaded internally)
|
||||||
@@ -47,6 +52,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [],
|
team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [],
|
||||||
})
|
})
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
|
setConfirmPassword('')
|
||||||
|
setShowPassword(false)
|
||||||
if (memberId) loadWorkload()
|
if (memberId) loadWorkload()
|
||||||
}
|
}
|
||||||
}, [member])
|
}, [member])
|
||||||
@@ -101,7 +108,6 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
await onSave(memberId, {
|
await onSave(memberId, {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
password: form.password,
|
|
||||||
role: form.permission_level,
|
role: form.permission_level,
|
||||||
role_id: form.role_id || null,
|
role_id: form.role_id || null,
|
||||||
brands: form.brands || [],
|
brands: form.brands || [],
|
||||||
@@ -115,6 +121,22 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePasswordChange = async () => {
|
||||||
|
if (!form.password || form.password !== confirmPassword) return
|
||||||
|
setPasswordSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(memberId, { password: form.password }, false)
|
||||||
|
setForm(f => ({ ...f, password: '' }))
|
||||||
|
setConfirmPassword('')
|
||||||
|
setShowPassword(false)
|
||||||
|
toast.success(t('team.passwordChanged'))
|
||||||
|
} finally {
|
||||||
|
setPasswordSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMismatch = confirmPassword && form.password !== confirmPassword
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
setShowDeleteConfirm(false)
|
setShowDeleteConfirm(false)
|
||||||
await onDelete(memberId)
|
await onDelete(memberId)
|
||||||
@@ -354,26 +376,15 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
{dirty && (
|
||||||
{dirty && (
|
<button
|
||||||
<button
|
onClick={handleSave}
|
||||||
onClick={handleSave}
|
disabled={!form.name || saving}
|
||||||
disabled={!form.name || saving}
|
className={`w-full px-4 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 ${saving ? 'btn-loading' : ''}`}
|
||||||
className={`flex-1 px-4 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 ${saving ? 'btn-loading' : ''}`}
|
>
|
||||||
>
|
{isEditingSelf ? t('team.saveProfile') : t('team.saveChanges')}
|
||||||
{isEditingSelf ? t('team.saveProfile') : t('team.saveChanges')}
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
|
||||||
{!isEditingSelf && canManageTeam && onDelete && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
title={t('team.remove')}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
@@ -437,6 +448,72 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Admin Actions Section (superadmin only, not self) */}
|
||||||
|
{!isEditingSelf && userRole === 'superadmin' && (
|
||||||
|
<CollapsibleSection
|
||||||
|
title={<span className="flex items-center gap-1.5"><ShieldAlert className="w-3.5 h-3.5 text-red-500" />{t('team.adminActions')}</span>}
|
||||||
|
defaultOpen={false}
|
||||||
|
noBorder
|
||||||
|
>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
{/* Change password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => update('password', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 pe-9 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder={t('team.newPassword')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(v => !v)}
|
||||||
|
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-primary"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.confirmPassword')}</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
|
className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary ${passwordMismatch ? 'border-red-400' : 'border-border'}`}
|
||||||
|
placeholder={t('team.confirmPassword')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
{passwordMismatch && (
|
||||||
|
<p className="text-[11px] text-red-500 mt-1">{t('team.passwordsDoNotMatch')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePasswordChange}
|
||||||
|
disabled={!form.password || form.password.length < 6 || form.password !== confirmPassword || passwordSaving}
|
||||||
|
className={`w-full px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${passwordSaving ? 'btn-loading' : ''}`}
|
||||||
|
>
|
||||||
|
{t('team.changePassword')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete member */}
|
||||||
|
{canManageTeam && onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
{t('team.removeMember')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
</SlidePanel>
|
</SlidePanel>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -234,6 +234,10 @@
|
|||||||
"team.defaultPassword": "افتراضياً: changeme123",
|
"team.defaultPassword": "افتراضياً: changeme123",
|
||||||
"team.confirmPassword": "تأكيد كلمة المرور",
|
"team.confirmPassword": "تأكيد كلمة المرور",
|
||||||
"team.passwordsDoNotMatch": "كلمتا المرور غير متطابقتين",
|
"team.passwordsDoNotMatch": "كلمتا المرور غير متطابقتين",
|
||||||
|
"team.adminActions": "إجراءات المسؤول",
|
||||||
|
"team.newPassword": "كلمة مرور جديدة (٦ أحرف على الأقل)",
|
||||||
|
"team.changePassword": "تغيير كلمة المرور",
|
||||||
|
"team.passwordChanged": "تم تغيير كلمة المرور بنجاح",
|
||||||
"team.optional": "(اختياري)",
|
"team.optional": "(اختياري)",
|
||||||
"team.fixedRole": "دور ثابت للمديرين",
|
"team.fixedRole": "دور ثابت للمديرين",
|
||||||
"team.remove": "إزالة",
|
"team.remove": "إزالة",
|
||||||
|
|||||||
@@ -234,6 +234,10 @@
|
|||||||
"team.defaultPassword": "Default: changeme123",
|
"team.defaultPassword": "Default: changeme123",
|
||||||
"team.confirmPassword": "Confirm Password",
|
"team.confirmPassword": "Confirm Password",
|
||||||
"team.passwordsDoNotMatch": "Passwords do not match",
|
"team.passwordsDoNotMatch": "Passwords do not match",
|
||||||
|
"team.adminActions": "Admin Actions",
|
||||||
|
"team.newPassword": "New password (min 6 characters)",
|
||||||
|
"team.changePassword": "Change Password",
|
||||||
|
"team.passwordChanged": "Password changed successfully",
|
||||||
"team.optional": "(optional)",
|
"team.optional": "(optional)",
|
||||||
"team.fixedRole": "Fixed role for managers",
|
"team.fixedRole": "Fixed role for managers",
|
||||||
"team.remove": "Remove",
|
"team.remove": "Remove",
|
||||||
|
|||||||
Reference in New Issue
Block a user