Compare commits
13 Commits
76290d9f7e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01fdb93efd | ||
|
|
52d69ee02d | ||
|
|
7554b1cb56 | ||
|
|
6cdec2b4b5 | ||
|
|
4d91e8e8a8 | ||
|
|
b1f7d574ed | ||
|
|
2c0152f176 | ||
|
|
bf084a85d7 | ||
|
|
d38f3a7780 | ||
|
|
3d1fab191a | ||
|
|
fd4d6648b0 | ||
|
|
ec640a9bd9 | ||
|
|
8d53524e41 |
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const ROLE_BADGES = {
|
|||||||
photographer: { bg: 'bg-cyan-50', text: 'text-cyan-700', label: 'Photographer' },
|
photographer: { bg: 'bg-cyan-50', text: 'text-cyan-700', label: 'Photographer' },
|
||||||
videographer: { bg: 'bg-sky-50', text: 'text-sky-700', label: 'Videographer' },
|
videographer: { bg: 'bg-sky-50', text: 'text-sky-700', label: 'Videographer' },
|
||||||
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
|
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
|
||||||
|
superadmin: { bg: 'bg-red-50', text: 'text-red-700', label: 'Super Admin' },
|
||||||
|
contributor: { bg: 'bg-slate-50', text: 'text-slate-700', label: 'Contributor' },
|
||||||
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
|
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
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 [passwordError, setPasswordError] = useState('')
|
||||||
const brandsDropdownRef = useRef(null)
|
const brandsDropdownRef = useRef(null)
|
||||||
|
|
||||||
// Workload state (loaded internally)
|
// Workload state (loaded internally)
|
||||||
@@ -59,6 +61,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(isCreateMode)
|
setDirty(isCreateMode)
|
||||||
|
setConfirmPassword('')
|
||||||
|
setPasswordError('')
|
||||||
if (!isCreateMode) loadWorkload()
|
if (!isCreateMode) loadWorkload()
|
||||||
}
|
}
|
||||||
}, [member])
|
}, [member])
|
||||||
@@ -108,6 +112,11 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
setPasswordError('')
|
||||||
|
if (isCreateMode && form.password && form.password !== confirmPassword) {
|
||||||
|
setPasswordError('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await onSave(isCreateMode ? null : memberId, {
|
await onSave(isCreateMode ? null : memberId, {
|
||||||
@@ -204,13 +213,36 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isCreateMode && form.password && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary 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 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"
|
||||||
@@ -244,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)}
|
||||||
@@ -303,6 +340,7 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modules toggle */}
|
{/* Modules toggle */}
|
||||||
|
|||||||
@@ -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,121 +413,110 @@ 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')}`}
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* My Tasks + Project Progress */}
|
{/* My Tasks + Project Progress */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
{hasModule('projects') && (
|
||||||
<MyTasksList tasks={filteredTasks} currentUserId={currentUser?.id || currentUser?._id} navigate={navigate} t={t} />
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<ProjectProgress projects={projects} tasks={tasks} t={t} />
|
<MyTasksList tasks={filteredTasks} currentUserId={currentUser?.id || currentUser?._id} navigate={navigate} t={t} />
|
||||||
</div>
|
<ProjectProgress projects={projects} tasks={tasks} t={t} />
|
||||||
|
</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} />}
|
||||||
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
|
{hasModule('marketing') && (
|
||||||
|
<div className={hasModule('finance') ? 'lg:col-span-2' : ''}>
|
||||||
|
<ActiveCampaignsList campaigns={campaigns} finance={finance} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Recent Posts + Upcoming Deadlines */}
|
{/* Recent Posts + Upcoming Deadlines */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
{(hasModule('marketing') || hasModule('projects')) && (
|
||||||
{/* Recent Posts */}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="section-card">
|
{/* Recent Posts */}
|
||||||
<div className="section-card-header flex items-center justify-between">
|
{hasModule('marketing') && (
|
||||||
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
|
<div className="section-card">
|
||||||
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
<div className="section-card-header flex items-center justify-between">
|
||||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
<h3 className="font-semibold text-text-primary">{t('dashboard.recentPosts')}</h3>
|
||||||
</Link>
|
<Link to="/posts" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
</div>
|
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||||
<div className="divide-y divide-border-light">
|
</Link>
|
||||||
{filteredPosts.length === 0 ? (
|
|
||||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
|
||||||
{t('dashboard.noPostsYet')}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="divide-y divide-border-light">
|
||||||
filteredPosts.slice(0, 8).map((post) => (
|
{filteredPosts.length === 0 ? (
|
||||||
<div
|
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||||
key={post._id}
|
{t('dashboard.noPostsYet')}
|
||||||
onClick={() => navigate('/posts')}
|
</div>
|
||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
) : (
|
||||||
>
|
filteredPosts.slice(0, 8).map((post) => (
|
||||||
<div className="flex-1 min-w-0">
|
<div
|
||||||
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
key={post._id}
|
||||||
<div className="flex items-center gap-2 mt-1">
|
onClick={() => navigate('/posts')}
|
||||||
{post.brand && <BrandBadge brand={post.brand} />}
|
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{post.brand && <BrandBadge brand={post.brand} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={post.status} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
<StatusBadge status={post.status} size="xs" />
|
)}
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upcoming Deadlines */}
|
|
||||||
<div className="section-card">
|
|
||||||
<div className="section-card-header flex items-center justify-between">
|
|
||||||
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
|
|
||||||
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
|
||||||
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border-light">
|
|
||||||
{upcomingDeadlines.length === 0 ? (
|
|
||||||
<div className="py-12 text-center text-sm text-text-tertiary">
|
|
||||||
{t('dashboard.noUpcomingDeadlines')}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
upcomingDeadlines.map((task) => (
|
)}
|
||||||
<div
|
|
||||||
key={task._id}
|
{/* Upcoming Deadlines */}
|
||||||
onClick={() => navigate('/tasks')}
|
{hasModule('projects') && (
|
||||||
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
<div className="section-card">
|
||||||
>
|
<div className="section-card-header flex items-center justify-between">
|
||||||
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
<h3 className="font-semibold text-text-primary">{t('dashboard.upcomingDeadlines')}</h3>
|
||||||
<div className="flex-1 min-w-0">
|
<Link to="/tasks" className="text-sm text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1">
|
||||||
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
{t('dashboard.viewAll')} <ArrowRight className="w-3.5 h-3.5" />
|
||||||
<StatusBadge status={task.status} size="xs" />
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-light">
|
||||||
|
{upcomingDeadlines.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-text-tertiary">
|
||||||
|
{t('dashboard.noUpcomingDeadlines')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
|
) : (
|
||||||
<Clock className="w-3.5 h-3.5" />
|
upcomingDeadlines.map((task) => (
|
||||||
{format(new Date(task.dueDate), 'MMM d')}
|
<div
|
||||||
</div>
|
key={task._id}
|
||||||
</div>
|
onClick={() => navigate('/tasks')}
|
||||||
))
|
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||||
)}
|
>
|
||||||
</div>
|
<div className={`w-2 h-2 rounded-full ${(PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium).color}`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary truncate">{task.title}</p>
|
||||||
|
<StatusBadge status={task.status} size="xs" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-text-tertiary shrink-0">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
{format(new Date(task.dueDate), 'MMM d')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { Megaphone, Lock, Mail, AlertCircle } from 'lucide-react'
|
import { Megaphone, Lock, Mail, AlertCircle, User, CheckCircle } from 'lucide-react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -13,6 +14,17 @@ export default function Login() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [needsSetup, setNeedsSetup] = useState(null)
|
||||||
|
const [setupName, setSetupName] = useState('')
|
||||||
|
const [setupEmail, setSetupEmail] = useState('')
|
||||||
|
const [setupPassword, setSetupPassword] = useState('')
|
||||||
|
const [setupConfirm, setSetupConfirm] = useState('')
|
||||||
|
const [setupDone, setSetupDone] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/setup/status').then(data => setNeedsSetup(data.needsSetup)).catch(() => setNeedsSetup(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -28,6 +40,35 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSetup = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (setupPassword !== setupConfirm) {
|
||||||
|
setError('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/setup', { name: setupName, email: setupEmail, password: setupPassword })
|
||||||
|
setSetupDone(true)
|
||||||
|
setNeedsSetup(false)
|
||||||
|
setEmail(setupEmail)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Setup failed')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSetup === null) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center px-4">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
@@ -36,82 +77,192 @@ export default function Login() {
|
|||||||
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-lg">
|
||||||
<Megaphone className="w-8 h-8 text-white" />
|
<Megaphone className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-white mb-2">{t('login.title')}</h1>
|
<h1 className="text-3xl font-bold text-white mb-2">
|
||||||
<p className="text-slate-400">{t('login.subtitle')}</p>
|
{needsSetup ? 'Initial Setup' : t('login.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
{needsSetup ? 'Create your superadmin account to get started' : t('login.subtitle')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Card */}
|
{/* Success Message */}
|
||||||
|
{setupDone && (
|
||||||
|
<div className="flex items-center gap-2 p-3 mb-4 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-400 shrink-0" />
|
||||||
|
<p className="text-sm text-green-400">Account created. You can now log in.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
|
<div className="bg-slate-800/50 backdrop-blur-sm rounded-2xl border border-slate-700/50 p-8 shadow-2xl">
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
{needsSetup ? (
|
||||||
{/* Email */}
|
<form onSubmit={handleSetup} className="space-y-5">
|
||||||
<div>
|
{/* Name */}
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
<div>
|
||||||
{t('auth.email')}
|
<label className="block text-sm font-medium text-slate-300 mb-2">Name</label>
|
||||||
</label>
|
<div className="relative">
|
||||||
<div className="relative">
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="email"
|
value={setupName}
|
||||||
value={email}
|
onChange={(e) => setSetupName(e.target.value)}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
dir="auto"
|
placeholder="Your name"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
required
|
||||||
placeholder="user@company.com"
|
autoFocus
|
||||||
required
|
/>
|
||||||
autoFocus
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
<label className="block text-sm font-medium text-slate-300 mb-2">Email</label>
|
||||||
{t('auth.password')}
|
<div className="relative">
|
||||||
</label>
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
<div className="relative">
|
<input
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
type="email"
|
||||||
<input
|
value={setupEmail}
|
||||||
type="password"
|
onChange={(e) => setSetupEmail(e.target.value)}
|
||||||
value={password}
|
dir="auto"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
placeholder="admin@company.com"
|
||||||
placeholder="••••••••"
|
required
|
||||||
required
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Password */}
|
||||||
{error && (
|
<div>
|
||||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
<label className="block text-sm font-medium text-slate-300 mb-2">Password</label>
|
||||||
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
<div className="relative">
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={setupPassword}
|
||||||
|
onChange={(e) => setSetupPassword(e.target.value)}
|
||||||
|
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="Choose a strong password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Confirm Password */}
|
||||||
<button
|
<div>
|
||||||
type="submit"
|
<label className="block text-sm font-medium text-slate-300 mb-2">Confirm Password</label>
|
||||||
disabled={loading}
|
<div className="relative">
|
||||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
>
|
<input
|
||||||
{loading ? (
|
type="password"
|
||||||
<span className="flex items-center justify-center gap-2">
|
value={setupConfirm}
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
onChange={(e) => setSetupConfirm(e.target.value)}
|
||||||
{t('auth.signingIn')}
|
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
</span>
|
placeholder="Re-enter your password"
|
||||||
) : (
|
required
|
||||||
t('auth.loginBtn')
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
</form>
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Creating account...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Create Superadmin Account'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||||
|
{t('auth.email')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
dir="auto"
|
||||||
|
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="user@company.com"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||||
|
{t('auth.password')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full pl-11 pr-4 py-3 bg-slate-900/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400 shrink-0" />
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
{t('auth.signingIn')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t('auth.loginBtn')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-6 pt-6 border-t border-slate-700/50">
|
{!needsSetup && (
|
||||||
<p className="text-xs text-slate-500 text-center">
|
<div className="mt-6 pt-6 border-t border-slate-700/50">
|
||||||
{t('login.forgotPassword')}
|
<p className="text-xs text-slate-500 text-center">
|
||||||
</p>
|
{t('login.forgotPassword')}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
208
server/server.js
208
server/server.js
@@ -206,6 +206,140 @@ const LINK_TO_FK = {
|
|||||||
|
|
||||||
// ─── TABLE CREATION: Ensure required tables exist ────────────────
|
// ─── TABLE CREATION: Ensure required tables exist ────────────────
|
||||||
const REQUIRED_TABLES = {
|
const REQUIRED_TABLES = {
|
||||||
|
Users: [
|
||||||
|
{ title: 'name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'email', uidt: 'Email' },
|
||||||
|
{ title: 'role', uidt: 'SingleSelect', dtxp: "'superadmin','manager','contributor'" },
|
||||||
|
{ title: 'team_role', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'brands', uidt: 'LongText' },
|
||||||
|
{ title: 'phone', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'avatar', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'tutorial_completed', uidt: 'Checkbox' },
|
||||||
|
],
|
||||||
|
Brands: [
|
||||||
|
{ title: 'name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'name_ar', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'priority', uidt: 'Number' },
|
||||||
|
{ title: 'color', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'icon', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'category', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'logo', uidt: 'SingleLineText' },
|
||||||
|
],
|
||||||
|
Campaigns: [
|
||||||
|
{ title: 'name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'description', uidt: 'LongText' },
|
||||||
|
{ title: 'start_date', uidt: 'Date' },
|
||||||
|
{ title: 'end_date', uidt: 'Date' },
|
||||||
|
{ title: 'status', uidt: 'SingleSelect', dtxp: "'planning','active','paused','completed','cancelled'" },
|
||||||
|
{ title: 'color', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'budget', uidt: 'Decimal' },
|
||||||
|
{ title: 'goals', uidt: 'LongText' },
|
||||||
|
{ title: 'platforms', uidt: 'LongText' },
|
||||||
|
{ title: 'budget_spent', uidt: 'Decimal' },
|
||||||
|
{ title: 'revenue', uidt: 'Decimal' },
|
||||||
|
{ title: 'impressions', uidt: 'Number' },
|
||||||
|
{ title: 'clicks', uidt: 'Number' },
|
||||||
|
{ title: 'conversions', uidt: 'Number' },
|
||||||
|
{ title: 'cost_per_click', uidt: 'Decimal' },
|
||||||
|
{ title: 'notes', uidt: 'LongText' },
|
||||||
|
{ title: 'brand_id', uidt: 'Number' },
|
||||||
|
{ title: 'created_by_user_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
CampaignTracks: [
|
||||||
|
{ title: 'name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'type', uidt: 'SingleSelect', dtxp: "'organic_social','paid_social','paid_search','email','seo','influencer','event','other'" },
|
||||||
|
{ title: 'platform', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'budget_allocated', uidt: 'Decimal' },
|
||||||
|
{ title: 'budget_spent', uidt: 'Decimal' },
|
||||||
|
{ title: 'revenue', uidt: 'Decimal' },
|
||||||
|
{ title: 'impressions', uidt: 'Number' },
|
||||||
|
{ title: 'clicks', uidt: 'Number' },
|
||||||
|
{ title: 'conversions', uidt: 'Number' },
|
||||||
|
{ title: 'notes', uidt: 'LongText' },
|
||||||
|
{ title: 'status', uidt: 'SingleSelect', dtxp: "'planned','active','paused','completed'" },
|
||||||
|
{ title: 'campaign_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
CampaignAssignments: [
|
||||||
|
{ title: 'assigned_at', uidt: 'DateTime' },
|
||||||
|
{ title: 'campaign_id', uidt: 'Number' },
|
||||||
|
{ title: 'member_id', uidt: 'Number' },
|
||||||
|
{ title: 'assigner_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
Projects: [
|
||||||
|
{ title: 'name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'description', uidt: 'LongText' },
|
||||||
|
{ title: 'status', uidt: 'SingleSelect', dtxp: "'active','paused','completed','cancelled'" },
|
||||||
|
{ title: 'priority', uidt: 'SingleSelect', dtxp: "'low','medium','high','urgent'" },
|
||||||
|
{ title: 'start_date', uidt: 'Date' },
|
||||||
|
{ title: 'due_date', uidt: 'Date' },
|
||||||
|
{ title: 'brand_id', uidt: 'Number' },
|
||||||
|
{ title: 'owner_id', uidt: 'Number' },
|
||||||
|
{ title: 'created_by_user_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
Tasks: [
|
||||||
|
{ title: 'title', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'description', uidt: 'LongText' },
|
||||||
|
{ title: 'status', uidt: 'SingleSelect', dtxp: "'todo','in_progress','done'" },
|
||||||
|
{ title: 'priority', uidt: 'SingleSelect', dtxp: "'low','medium','high','urgent'" },
|
||||||
|
{ title: 'start_date', uidt: 'Date' },
|
||||||
|
{ title: 'due_date', uidt: 'Date' },
|
||||||
|
{ title: 'is_personal', uidt: 'Checkbox' },
|
||||||
|
{ title: 'completed_at', uidt: 'DateTime' },
|
||||||
|
{ title: 'project_id', uidt: 'Number' },
|
||||||
|
{ title: 'assigned_to_id', uidt: 'Number' },
|
||||||
|
{ title: 'created_by_user_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
Posts: [
|
||||||
|
{ title: 'title', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'description', uidt: 'LongText' },
|
||||||
|
{ title: 'status', uidt: 'SingleSelect', dtxp: "'draft','in_review','approved','scheduled','published','rejected'" },
|
||||||
|
{ title: 'platform', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'platforms', uidt: 'LongText' },
|
||||||
|
{ title: 'content_type', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'scheduled_date', uidt: 'DateTime' },
|
||||||
|
{ title: 'published_date', uidt: 'DateTime' },
|
||||||
|
{ title: 'notes', uidt: 'LongText' },
|
||||||
|
{ title: 'publication_links', uidt: 'LongText' },
|
||||||
|
{ title: 'brand_id', uidt: 'Number' },
|
||||||
|
{ title: 'assigned_to_id', uidt: 'Number' },
|
||||||
|
{ title: 'campaign_id', uidt: 'Number' },
|
||||||
|
{ title: 'track_id', uidt: 'Number' },
|
||||||
|
{ title: 'created_by_user_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
Assets: [
|
||||||
|
{ title: 'filename', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'original_name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'mime_type', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'size', uidt: 'Number' },
|
||||||
|
{ title: 'tags', uidt: 'LongText' },
|
||||||
|
{ title: 'folder', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'brand_id', uidt: 'Number' },
|
||||||
|
{ title: 'campaign_id', uidt: 'Number' },
|
||||||
|
{ title: 'uploader_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
PostAttachments: [
|
||||||
|
{ title: 'filename', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'original_name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'mime_type', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'size', uidt: 'Number' },
|
||||||
|
{ title: 'url', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'post_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
Comments: [
|
||||||
|
{ title: 'entity_type', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'entity_id', uidt: 'Number' },
|
||||||
|
{ title: 'content', uidt: 'LongText' },
|
||||||
|
{ title: 'user_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
|
BudgetEntries: [
|
||||||
|
{ title: 'label', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'amount', uidt: 'Decimal' },
|
||||||
|
{ title: 'source', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'category', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'date_received', uidt: 'Date' },
|
||||||
|
{ title: 'notes', uidt: 'LongText' },
|
||||||
|
{ title: 'campaign_id', uidt: 'Number' },
|
||||||
|
],
|
||||||
TaskAttachments: [
|
TaskAttachments: [
|
||||||
{ title: 'filename', uidt: 'SingleLineText' },
|
{ title: 'filename', uidt: 'SingleLineText' },
|
||||||
{ title: 'original_name', uidt: 'SingleLineText' },
|
{ title: 'original_name', uidt: 'SingleLineText' },
|
||||||
@@ -468,6 +602,32 @@ async function getRecordName(table, id) {
|
|||||||
// Clear name cache periodically (every 60s)
|
// Clear name cache periodically (every 60s)
|
||||||
setInterval(() => { Object.keys(_nameCache).forEach(k => delete _nameCache[k]); }, 60000);
|
setInterval(() => { Object.keys(_nameCache).forEach(k => delete _nameCache[k]); }, 60000);
|
||||||
|
|
||||||
|
// ─── SETUP ROUTES ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/setup/status', (req, res) => {
|
||||||
|
const count = authDb.prepare('SELECT COUNT(*) as cnt FROM auth_credentials').get().cnt;
|
||||||
|
res.json({ needsSetup: count === 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/setup', async (req, res) => {
|
||||||
|
const count = authDb.prepare('SELECT COUNT(*) as cnt FROM auth_credentials').get().cnt;
|
||||||
|
if (count > 0) return res.status(403).json({ error: 'Setup already completed' });
|
||||||
|
|
||||||
|
const { name, email, password } = req.body;
|
||||||
|
if (!name || !email || !password) return res.status(400).json({ error: 'Name, email, and password are required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await nocodb.create('Users', { name, email, role: 'superadmin' });
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
|
||||||
|
console.log(`[SETUP] Superadmin created: ${email} (NocoDB Id: ${created.Id})`);
|
||||||
|
res.status(201).json({ message: 'Superadmin account created. You can now log in.' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Setup error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create superadmin account' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── AUTH ROUTES ────────────────────────────────────────────────
|
// ─── AUTH ROUTES ────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/api/auth/login', async (req, res) => {
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
@@ -503,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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -579,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' });
|
||||||
|
|
||||||
@@ -600,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 });
|
||||||
@@ -621,19 +800,26 @@ app.get('/api/users', requireAuth, requireRole('superadmin'), async (req, res) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
|
app.post('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
const { name, email, password, role, avatar } = req.body;
|
const { name, email, password, role, avatar, team_role, brands, phone, modules } = req.body;
|
||||||
if (!name || !email || !password || !role) return res.status(400).json({ error: 'Name, email, password, and role are required' });
|
if (!name || !email || !role) return res.status(400).json({ error: 'Name, email, and role are required' });
|
||||||
if (!['superadmin', 'manager', 'contributor'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
if (!['superadmin', 'manager', 'contributor'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = authDb.prepare('SELECT id FROM auth_credentials WHERE email = ?').get(email);
|
const existing = authDb.prepare('SELECT id FROM auth_credentials WHERE email = ?').get(email);
|
||||||
if (existing) return res.status(409).json({ error: 'Email already exists' });
|
if (existing) return res.status(409).json({ error: 'Email already exists' });
|
||||||
|
|
||||||
const created = await nocodb.create('Users', { name, email, role, avatar: avatar || null });
|
const created = await nocodb.create('Users', {
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
name, email, role, avatar: avatar || null,
|
||||||
|
team_role: team_role || null,
|
||||||
|
brands: JSON.stringify(brands || []),
|
||||||
|
phone: phone || null,
|
||||||
|
modules: JSON.stringify(modules || ALL_MODULES),
|
||||||
|
});
|
||||||
|
const defaultPassword = password || 'changeme123';
|
||||||
|
const passwordHash = await bcrypt.hash(defaultPassword, 10);
|
||||||
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
|
authDb.prepare('INSERT INTO auth_credentials (email, password_hash, nocodb_user_id) VALUES (?, ?, ?)').run(email, passwordHash, created.Id);
|
||||||
const user = await nocodb.get('Users', created.Id);
|
const user = await nocodb.get('Users', created.Id);
|
||||||
res.status(201).json(user);
|
res.status(201).json({ ...user, id: user.Id, _id: user.Id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create user error:', err);
|
console.error('Create user error:', err);
|
||||||
res.status(500).json({ error: 'Failed to create user' });
|
res.status(500).json({ error: 'Failed to create user' });
|
||||||
@@ -648,9 +834,11 @@ app.patch('/api/users/:id', requireAuth, requireRole('superadmin'), async (req,
|
|||||||
if (req.body.role && !['superadmin', 'manager', 'contributor'].includes(req.body.role)) return res.status(400).json({ error: 'Invalid role' });
|
if (req.body.role && !['superadmin', 'manager', 'contributor'].includes(req.body.role)) return res.status(400).json({ error: 'Invalid role' });
|
||||||
|
|
||||||
const data = {};
|
const data = {};
|
||||||
for (const f of ['name', 'email', 'role', 'avatar']) {
|
for (const f of ['name', 'email', 'role', 'avatar', 'team_role', 'phone']) {
|
||||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||||
}
|
}
|
||||||
|
if (req.body.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
|
||||||
|
if (req.body.modules !== undefined) data.modules = JSON.stringify(req.body.modules);
|
||||||
|
|
||||||
if (req.body.password) {
|
if (req.body.password) {
|
||||||
const hash = await bcrypt.hash(req.body.password, 10);
|
const hash = await bcrypt.hash(req.body.password, 10);
|
||||||
@@ -688,7 +876,6 @@ app.delete('/api/users/:id', requireAuth, requireRole('superadmin'), async (req,
|
|||||||
app.get('/api/users/assignable', requireAuth, async (req, res) => {
|
app.get('/api/users/assignable', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = await nocodb.list('Users', {
|
const users = await nocodb.list('Users', {
|
||||||
where: '(team_role,isnot,null)',
|
|
||||||
sort: 'name',
|
sort: 'name',
|
||||||
});
|
});
|
||||||
res.json(users.map(u => ({ ...u, id: u.Id, _id: u.Id })));
|
res.json(users.map(u => ({ ...u, id: u.Id, _id: u.Id })));
|
||||||
@@ -702,7 +889,6 @@ app.get('/api/users/assignable', requireAuth, async (req, res) => {
|
|||||||
app.get('/api/users/team', requireAuth, async (req, res) => {
|
app.get('/api/users/team', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = await nocodb.list('Users', {
|
const users = await nocodb.list('Users', {
|
||||||
where: '(team_role,isnot,null)',
|
|
||||||
sort: 'name',
|
sort: 'name',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user