Compare commits

..

6 Commits

Author SHA1 Message Date
fahed
01fdb93efd feat: hide dashboard sections for modules the user cannot access
All checks were successful
Deploy / deploy (push) Successful in 11s
Only fetch data and render stat cards, lists, and widgets for modules
the user has enabled (marketing, projects, finance).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:58:48 +03:00
fahed
52d69ee02d 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>
2026-02-23 15:54:29 +03:00
fahed
7554b1cb56 Add language selection to profile completion wizard
All checks were successful
Deploy / deploy (push) Successful in 12s
Users can choose English or Arabic during profile setup. The
selection is applied immediately via the existing LanguageContext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:40:10 +03:00
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
7 changed files with 376 additions and 163 deletions

View File

@@ -48,7 +48,7 @@ export const AppContext = createContext()
function AppContent() { function AppContent() {
const { user, loading: authLoading, checkAuth, hasModule } = useAuth() const { user, loading: authLoading, checkAuth, hasModule } = useAuth()
const { t, lang } = useLanguage() const { t, lang, setLang } = useLanguage()
const [teamMembers, setTeamMembers] = useState([]) const [teamMembers, setTeamMembers] = useState([])
const [brands, setBrands] = useState([]) const [brands, setBrands] = useState([])
const [teams, setTeams] = useState([]) const [teams, setTeams] = useState([])
@@ -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
@@ -221,14 +210,29 @@ function AppContent() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label> <label className="block text-sm font-medium text-text-primary mb-1">{t('settings.language')}</label>
<input <div className="grid grid-cols-2 gap-2">
type="text" <button
value={profileForm.brands} type="button"
onChange={e => setProfileForm(f => ({ ...f, brands: e.target.value }))} onClick={() => setLang('en')}
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={`p-3 rounded-lg border-2 text-center transition-all ${
placeholder={t('team.brandsHelp')} lang === 'en' ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
/> }`}
>
<div className="text-lg mb-1">EN</div>
<div className="text-xs font-medium text-text-primary">English</div>
</button>
<button
type="button"
onClick={() => setLang('ar')}
className={`p-3 rounded-lg border-2 text-center transition-all ${
lang === 'ar' ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
}`}
>
<div className="text-lg mb-1">ع</div>
<div className="text-xs font-medium text-text-primary">العربية</div>
</button>
</div>
</div> </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
@@ -241,15 +245,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 +258,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

@@ -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

@@ -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

@@ -3,6 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
import { format, isAfter, isBefore, addDays } from 'date-fns' import { format, isAfter, isBefore, addDays } from 'date-fns'
import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react' import { FileText, Megaphone, AlertTriangle, ArrowRight, Clock, Wallet, TrendingUp, TrendingDown, DollarSign, Landmark, CheckSquare, FolderKanban } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext' import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG } from '../utils/api' import { api, PRIORITY_CONFIG } from '../utils/api'
import StatCard from '../components/StatCard' import StatCard from '../components/StatCard'
@@ -264,6 +265,7 @@ export default function Dashboard() {
const { t, currencySymbol } = useLanguage() const { t, currencySymbol } = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const { currentUser, teamMembers } = useContext(AppContext) const { currentUser, teamMembers } = useContext(AppContext)
const { hasModule } = useAuth()
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [campaigns, setCampaigns] = useState([]) const [campaigns, setCampaigns] = useState([])
const [tasks, setTasks] = useState([]) const [tasks, setTasks] = useState([])
@@ -282,18 +284,30 @@ export default function Dashboard() {
const loadData = async () => { const loadData = async () => {
try { try {
const [postsRes, campaignsRes, tasksRes, financeRes, projectsRes] = await Promise.allSettled([ const fetches = []
api.get('/posts?limit=50&sort=-createdAt'), // Only fetch data for modules the user has access to
api.get('/campaigns'), if (hasModule('marketing')) {
api.get('/tasks'), fetches.push(api.get('/posts?limit=50&sort=-createdAt').then(r => ({ key: 'posts', data: r.data || r || [] })))
api.get('/finance/summary'), fetches.push(api.get('/campaigns').then(r => ({ key: 'campaigns', data: r.data || r || [] })))
api.get('/projects'), }
]) if (hasModule('projects')) {
setPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : []) fetches.push(api.get('/tasks').then(r => ({ key: 'tasks', data: r.data || r || [] })))
setCampaigns(campaignsRes.status === 'fulfilled' ? (campaignsRes.value.data || campaignsRes.value || []) : []) fetches.push(api.get('/projects').then(r => ({ key: 'projects', data: r.data || r || [] })))
setTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : []) }
setFinance(financeRes.status === 'fulfilled' ? (financeRes.value.data || financeRes.value || null) : null) if (hasModule('finance')) {
setProjects(projectsRes.status === 'fulfilled' ? (projectsRes.value.data || projectsRes.value || []) : []) fetches.push(api.get('/finance/summary').then(r => ({ key: 'finance', data: r.data || r || null })))
}
const results = await Promise.allSettled(fetches)
results.forEach(r => {
if (r.status !== 'fulfilled') return
const { key, data } = r.value
if (key === 'posts') setPosts(data)
else if (key === 'campaigns') setCampaigns(data)
else if (key === 'tasks') setTasks(data)
else if (key === 'projects') setProjects(data)
else if (key === 'finance') setFinance(data)
})
} catch (err) { } catch (err) {
console.error('Dashboard load error:', err) console.error('Dashboard load error:', err)
} finally { } finally {
@@ -339,6 +353,42 @@ export default function Dashboard() {
.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate)) .sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate))
.slice(0, 8) .slice(0, 8)
const statCards = []
if (hasModule('marketing')) {
statCards.push({
icon: FileText,
label: t('dashboard.totalPosts'),
value: filteredPosts.length || 0,
subtitle: `${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`,
color: 'brand-primary',
})
statCards.push({
icon: Megaphone,
label: t('dashboard.activeCampaigns'),
value: activeCampaigns,
subtitle: `${campaigns.length} ${t('dashboard.total')}`,
color: 'brand-secondary',
})
}
if (hasModule('finance')) {
statCards.push({
icon: Landmark,
label: t('dashboard.budgetRemaining'),
value: `${(finance?.remaining ?? 0).toLocaleString()}`,
subtitle: finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget'),
color: 'brand-tertiary',
})
}
if (hasModule('projects')) {
statCards.push({
icon: AlertTriangle,
label: t('dashboard.overdueTasks'),
value: overdueTasks,
subtitle: overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack'),
color: 'brand-quaternary',
})
}
if (loading) { if (loading) {
return <SkeletonDashboard /> return <SkeletonDashboard />
} }
@@ -363,54 +413,39 @@ export default function Dashboard() {
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 stagger-children"> {statCards.length > 0 && (
<StatCard <div className={`grid grid-cols-1 sm:grid-cols-2 ${statCards.length >= 4 ? 'lg:grid-cols-4' : statCards.length === 3 ? 'lg:grid-cols-3' : 'lg:grid-cols-2'} gap-4 stagger-children`}>
icon={FileText} {statCards.map((card, i) => (
label={t('dashboard.totalPosts')} <StatCard key={i} {...card} />
value={filteredPosts.length || 0} ))}
subtitle={`${filteredPosts.filter(p => p.status === 'published').length} ${t('dashboard.published')}`}
color="brand-primary"
/>
<StatCard
icon={Megaphone}
label={t('dashboard.activeCampaigns')}
value={activeCampaigns}
subtitle={`${campaigns.length} ${t('dashboard.total')}`}
color="brand-secondary"
/>
<StatCard
icon={Landmark}
label={t('dashboard.budgetRemaining')}
value={`${(finance?.remaining ?? 0).toLocaleString()}`}
subtitle={finance?.totalReceived ? `${(finance.spent || 0).toLocaleString()} ${t('dashboard.spent')} ${t('dashboard.of')} ${finance.totalReceived.toLocaleString()} ${currencySymbol}` : t('dashboard.noBudget')}
color="brand-tertiary"
/>
<StatCard
icon={AlertTriangle}
label={t('dashboard.overdueTasks')}
value={overdueTasks}
subtitle={overdueTasks > 0 ? t('dashboard.needsAttention') : t('dashboard.allOnTrack')}
color="brand-quaternary"
/>
</div> </div>
)}
{/* My Tasks + Project Progress */} {/* My Tasks + Project Progress */}
{hasModule('projects') && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<MyTasksList tasks={filteredTasks} currentUserId={currentUser?.id || currentUser?._id} navigate={navigate} t={t} /> <MyTasksList tasks={filteredTasks} currentUserId={currentUser?.id || currentUser?._id} navigate={navigate} t={t} />
<ProjectProgress projects={projects} tasks={tasks} t={t} /> <ProjectProgress projects={projects} tasks={tasks} t={t} />
</div> </div>
)}
{/* Budget + Active Campaigns */} {/* Budget + Active Campaigns */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> {(hasModule('finance') || hasModule('marketing')) && (
<FinanceMini finance={finance} /> <div className={`grid grid-cols-1 ${hasModule('finance') && hasModule('marketing') ? 'lg:grid-cols-3' : ''} gap-6`}>
<div className="lg:col-span-2"> {hasModule('finance') && <FinanceMini finance={finance} />}
{hasModule('marketing') && (
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
<ActiveCampaignsList campaigns={campaigns} finance={finance} /> <ActiveCampaignsList campaigns={campaigns} finance={finance} />
</div> </div>
)}
</div> </div>
)}
{/* Recent Posts + Upcoming Deadlines */} {/* Recent Posts + Upcoming Deadlines */}
{(hasModule('marketing') || hasModule('projects')) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Posts */} {/* Recent Posts */}
{hasModule('marketing') && (
<div className="section-card"> <div className="section-card">
<div className="section-card-header flex items-center justify-between"> <div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3> <h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
@@ -442,8 +477,10 @@ export default function Dashboard() {
)} )}
</div> </div>
</div> </div>
)}
{/* Upcoming Deadlines */} {/* Upcoming Deadlines */}
{hasModule('projects') && (
<div className="section-card"> <div className="section-card">
<div className="section-card-header flex items-center justify-between"> <div className="section-card-header flex items-center justify-between">
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3> <h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
@@ -477,7 +514,9 @@ export default function Dashboard() {
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
)}
</div> </div>
) )
} }

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' });
@@ -760,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 });