Compare commits

...

3 Commits

Author SHA1 Message Date
fahed
6cdec2b4b5 Restrict team_role and brands to admin-only editing
All checks were successful
Deploy / deploy (push) Successful in 11s
- Remove team_role and brands from profile completion wizard
- Lock team_role and brands fields when user edits own profile
- Remove team_role and brands from PATCH /users/me/profile endpoint
- Profile completeness now checks name instead of team_role

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:36:48 +03:00
fahed
4d91e8e8a8 Add password confirmation to user creation/edit in Users page
Shows confirm password field when a password is entered. Validates
match before saving.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:33:14 +03:00
fahed
b1f7d574ed Fix team data not refreshing after save/delete
Await loadTeam() and loadTeams() so the UI reflects changes
immediately without needing a manual page refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:32:01 +03:00
5 changed files with 51 additions and 42 deletions

View File

@@ -200,17 +200,6 @@ function AppContent() {
placeholder={t('team.fullName')} placeholder={t('team.fullName')}
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
<select
value={profileForm.team_role}
onChange={e => setProfileForm(f => ({ ...f, team_role: e.target.value }))}
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"
>
<option value=""></option>
{TEAM_ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</div>
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')} {t('team.optional')}</label> <label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')} {t('team.optional')}</label>
<input <input
@@ -220,16 +209,6 @@ function AppContent() {
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" 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"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
<input
type="text"
value={profileForm.brands}
onChange={e => setProfileForm(f => ({ ...f, brands: e.target.value }))}
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={t('team.brandsHelp')}
/>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border"> <div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button <button
onClick={() => setShowProfileModal(false)} onClick={() => setShowProfileModal(false)}
@@ -241,15 +220,9 @@ function AppContent() {
onClick={async () => { onClick={async () => {
setProfileSaving(true) setProfileSaving(true)
try { try {
const brandsArr = profileForm.brands
.split(',')
.map(b => b.trim())
.filter(Boolean)
await api.patch('/users/me/profile', { await api.patch('/users/me/profile', {
name: profileForm.name, name: profileForm.name,
team_role: profileForm.team_role,
phone: profileForm.phone || null, phone: profileForm.phone || null,
brands: brandsArr,
}) })
await checkAuth() await checkAuth()
setShowProfileModal(false) setShowProfileModal(false)
@@ -260,7 +233,7 @@ function AppContent() {
setProfileSaving(false) setProfileSaving(false)
} }
}} }}
disabled={!profileForm.name || !profileForm.team_role || profileSaving} disabled={!profileForm.name || profileSaving}
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" 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"
> >
{profileSaving ? t('common.loading') : t('team.saveProfile')} {profileSaving ? t('common.loading') : t('team.saveProfile')}

View File

@@ -235,7 +235,14 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.teamRole')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.teamRole')}</label>
{userRole === 'manager' && isCreateMode && !isEditingSelf ? ( {isEditingSelf ? (
<input
type="text"
value={ROLES.find(r => r.value === form.role)?.label || form.role || '—'}
disabled
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
) : userRole === 'manager' && isCreateMode ? (
<> <>
<input <input
type="text" type="text"
@@ -269,6 +276,11 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
<div ref={brandsDropdownRef} className="relative"> <div ref={brandsDropdownRef} className="relative">
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label> <label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
{isEditingSelf ? (
<div className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed">
{(form.brands || []).length === 0 ? '—' : (form.brands || []).join(', ')}
</div>
) : <>
<button <button
type="button" type="button"
onClick={() => setShowBrandsDropdown(prev => !prev)} onClick={() => setShowBrandsDropdown(prev => !prev)}
@@ -328,6 +340,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
)} )}
</div> </div>
)} )}
</>}
</div> </div>
{/* Modules toggle */} {/* Modules toggle */}

View File

@@ -43,8 +43,6 @@ export default function Team() {
if (isEditingSelf) { if (isEditingSelf) {
await api.patch('/users/me/profile', { await api.patch('/users/me/profile', {
name: data.name, name: data.name,
team_role: data.role,
brands: data.brands,
phone: data.phone, phone: data.phone,
}) })
} else { } else {
@@ -83,8 +81,8 @@ export default function Team() {
} }
} }
loadTeam() await loadTeam()
loadTeams() await loadTeams()
} catch (err) { } catch (err) {
console.error('Save failed:', err) console.error('Save failed:', err)
alert(err.message || 'Failed to save') alert(err.message || 'Failed to save')
@@ -98,8 +96,8 @@ export default function Team() {
} else { } else {
await api.post('/teams', data) await api.post('/teams', data)
} }
loadTeams() await loadTeams()
loadTeam() await loadTeam()
} catch (err) { } catch (err) {
console.error('Team save failed:', err) console.error('Team save failed:', err)
alert(err.message || 'Failed to save team') alert(err.message || 'Failed to save team')
@@ -111,8 +109,8 @@ export default function Team() {
await api.delete(`/teams/${teamId}`) await api.delete(`/teams/${teamId}`)
setPanelTeam(null) setPanelTeam(null)
if (teamFilter === teamId) setTeamFilter(null) if (teamFilter === teamId) setTeamFilter(null)
loadTeams() await loadTeams()
loadTeam() await loadTeam()
} catch (err) { } catch (err) {
console.error('Team delete failed:', err) console.error('Team delete failed:', err)
} }
@@ -124,7 +122,7 @@ export default function Team() {
setSelectedMember(null) setSelectedMember(null)
} }
setPanelMember(null) setPanelMember(null)
loadTeam() await loadTeam()
} }
const openMemberDetail = async (member) => { const openMemberDetail = async (member) => {

View File

@@ -34,6 +34,8 @@ export default function Users() {
const [form, setForm] = useState(EMPTY_FORM) const [form, setForm] = useState(EMPTY_FORM)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [userToDelete, setUserToDelete] = useState(null) const [userToDelete, setUserToDelete] = useState(null)
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordError, setPasswordError] = useState('')
useEffect(() => { loadUsers() }, []) useEffect(() => { loadUsers() }, [])
@@ -49,6 +51,11 @@ export default function Users() {
} }
const handleSave = async () => { const handleSave = async () => {
setPasswordError('')
if (form.password && form.password !== confirmPassword) {
setPasswordError('Passwords do not match')
return
}
try { try {
const data = { const data = {
name: form.name, name: form.name,
@@ -87,12 +94,16 @@ export default function Users() {
role: user.role || 'contributor', role: user.role || 'contributor',
avatar: user.avatar || '', avatar: user.avatar || '',
}) })
setConfirmPassword('')
setPasswordError('')
setShowModal(true) setShowModal(true)
} }
const openNew = () => { const openNew = () => {
setEditingUser(null) setEditingUser(null)
setForm(EMPTY_FORM) setForm(EMPTY_FORM)
setConfirmPassword('')
setPasswordError('')
setShowModal(true) setShowModal(true)
} }
@@ -253,13 +264,29 @@ export default function Users() {
<input <input
type="password" type="password"
value={form.password} value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} onChange={e => { setForm(f => ({ ...f, password: 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" 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="••••••••" placeholder="••••••••"
required={!editingUser} required={!editingUser}
/> />
</div> </div>
{form.password && (
<div>
<label className="block text-sm font-medium text-text-primary mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={e => { setConfirmPassword(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="••••••••"
/>
{passwordError && (
<p className="text-xs text-red-500 mt-1">{passwordError}</p>
)}
</div>
)}
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">Role *</label> <label className="block text-sm font-medium text-text-primary mb-1">Role *</label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">

View File

@@ -663,7 +663,7 @@ app.post('/api/auth/login', async (req, res) => {
avatar: user.avatar, avatar: user.avatar,
team_role: user.team_role, team_role: user.team_role,
tutorial_completed: user.tutorial_completed, tutorial_completed: user.tutorial_completed,
profileComplete: !!user.team_role, profileComplete: !!user.name,
modules, modules,
}, },
}); });
@@ -739,9 +739,7 @@ app.get('/api/users/me/profile', requireAuth, async (req, res) => {
app.patch('/api/users/me/profile', requireAuth, async (req, res) => { app.patch('/api/users/me/profile', requireAuth, async (req, res) => {
const data = {}; const data = {};
if (req.body.name !== undefined) data.name = req.body.name; if (req.body.name !== undefined) data.name = req.body.name;
if (req.body.team_role !== undefined) data.team_role = req.body.team_role;
if (req.body.phone !== undefined) data.phone = req.body.phone; if (req.body.phone !== undefined) data.phone = req.body.phone;
if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' }); if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });