49e1a796ed
Critical fixes: - XSS: escapeHtml() on all user-supplied text in email notifications - Budget PATCH: added mutex lock + availability validation (prevents corruption) - batchResolveNames: fixed wrong signature for budget request earmark names Dead code cleanup: - Deleted 8 unused PostComposition* files (replaced by PostDetail full page) Performance: - budget-helpers: single-fetch with computeFromEntries(), optional prefetch param - post-composition: parallelized text + thumbnail fetches with Promise.all Consistency: - PostDetail.jsx: native <select> → PortalSelect (matches all panels) - Finance.jsx: 11 hardcoded English table headers → t() with i18n keys - PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys - App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback) - UploadZone: proper useRef pattern, no vanilla JS document.createElement - All file inputs: className="hidden" → absolute w-0 h-0 opacity-0 - ArtefactDetailPanel: removed campaign/project selects (inherited from post) - TranslationDetailPanel: removed brand/linked post selects (inherited from post) - ApproverMultiSelect: portal-based dropdown (fixes clipping in modals) - Thumbnail fix: post-composition constructs URL from filename (was undefined) - Upload fix: UploadZone with drag-and-drop for design + video artefacts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
353 lines
15 KiB
React
353 lines
15 KiB
React
import { Routes, Route, Navigate } from 'react-router-dom'
|
|
import { useState, useEffect, createContext, lazy, Suspense } from 'react'
|
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
|
import { LanguageProvider } from './i18n/LanguageContext'
|
|
import { ToastProvider } from './components/ToastContainer'
|
|
import { ThemeProvider } from './contexts/ThemeContext'
|
|
import ErrorBoundary from './components/ErrorBoundary'
|
|
import Layout from './components/Layout'
|
|
import Tutorial from './components/Tutorial'
|
|
import Modal from './components/Modal'
|
|
import { api } from './utils/api'
|
|
import { useLanguage } from './i18n/LanguageContext'
|
|
import { useKeyboardShortcuts, DEFAULT_SHORTCUTS } from './hooks/useKeyboardShortcuts'
|
|
|
|
// Lazy-loaded page components
|
|
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
|
const PostProduction = lazy(() => import('./pages/PostProduction'))
|
|
const PostDetail = lazy(() => import('./pages/PostDetail'))
|
|
const Assets = lazy(() => import('./pages/Assets'))
|
|
const Campaigns = lazy(() => import('./pages/Campaigns'))
|
|
const CampaignDetail = lazy(() => import('./pages/CampaignDetail'))
|
|
const Finance = lazy(() => import('./pages/Finance'))
|
|
const Budgets = lazy(() => import('./pages/Budgets'))
|
|
const Projects = lazy(() => import('./pages/Projects'))
|
|
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
|
|
const Tasks = lazy(() => import('./pages/Tasks'))
|
|
const Team = lazy(() => import('./pages/Team'))
|
|
// Users page removed — unified into Team page
|
|
const Settings = lazy(() => import('./pages/Settings'))
|
|
const Brands = lazy(() => import('./pages/Brands'))
|
|
const Login = lazy(() => import('./pages/Login'))
|
|
const Artefacts = lazy(() => import('./pages/Artefacts'))
|
|
const PostCalendar = lazy(() => import('./pages/PostCalendar'))
|
|
const PublicReview = lazy(() => import('./pages/PublicReview'))
|
|
const PublicPostReview = lazy(() => import('./pages/PublicPostReview'))
|
|
const Issues = lazy(() => import('./pages/Issues'))
|
|
const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
|
|
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
|
|
const Translations = lazy(() => import('./pages/Translations'))
|
|
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
|
|
const PublicBudgetApproval = lazy(() => import('./pages/PublicBudgetApproval'))
|
|
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
|
|
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
|
|
|
|
// Permission levels (access control)
|
|
export const PERMISSION_LEVELS = [
|
|
{ value: 'superadmin', label: 'Super Admin' },
|
|
{ value: 'manager', label: 'Manager' },
|
|
{ value: 'contributor', label: 'Contributor' },
|
|
]
|
|
|
|
export const AppContext = createContext()
|
|
|
|
function AppContent() {
|
|
const { user, loading: authLoading, checkAuth, hasModule } = useAuth()
|
|
const { t, lang, setLang } = useLanguage()
|
|
const [teamMembers, setTeamMembers] = useState([])
|
|
const [brands, setBrands] = useState([])
|
|
const [teams, setTeams] = useState([])
|
|
const [roles, setRoles] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showTutorial, setShowTutorial] = useState(false)
|
|
const [showProfilePrompt, setShowProfilePrompt] = useState(false)
|
|
const [showProfileModal, setShowProfileModal] = useState(false)
|
|
const [profileForm, setProfileForm] = useState({ name: '', team_role: '', phone: '', brands: '' })
|
|
const [profileSaving, setProfileSaving] = useState(false)
|
|
|
|
// Keyboard shortcuts
|
|
useKeyboardShortcuts(DEFAULT_SHORTCUTS)
|
|
|
|
useEffect(() => {
|
|
if (user && !authLoading) {
|
|
loadInitialData()
|
|
// Check if tutorial should be shown
|
|
if (user.tutorial_completed === 0) {
|
|
setShowTutorial(true)
|
|
}
|
|
// Check if profile is incomplete
|
|
if (!user.profileComplete && user.role !== 'superadmin') {
|
|
setShowProfilePrompt(true)
|
|
} else {
|
|
setShowProfilePrompt(false)
|
|
}
|
|
} else if (!authLoading) {
|
|
setLoading(false)
|
|
}
|
|
}, [user, authLoading])
|
|
|
|
const getBrandName = (brandId) => {
|
|
if (!brandId) return null
|
|
const brand = brands.find(b => String(b._id || b.id) === String(brandId))
|
|
if (!brand) return null
|
|
return lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
|
}
|
|
|
|
const loadTeam = async () => {
|
|
try {
|
|
const data = await api.get('/users/team')
|
|
const members = Array.isArray(data) ? data : []
|
|
setTeamMembers(members)
|
|
return members
|
|
} catch (err) {
|
|
console.error('Failed to load team:', err)
|
|
return []
|
|
}
|
|
}
|
|
|
|
const loadTeams = async () => {
|
|
try {
|
|
const data = await api.get('/teams')
|
|
setTeams(Array.isArray(data) ? data : [])
|
|
} catch (err) {
|
|
console.error('Failed to load teams:', err)
|
|
}
|
|
}
|
|
|
|
const loadRoles = async () => {
|
|
try {
|
|
const data = await api.get('/roles')
|
|
setRoles(Array.isArray(data) ? data : [])
|
|
} catch (err) {
|
|
console.error('Failed to load roles:', err)
|
|
}
|
|
}
|
|
|
|
const loadInitialData = async () => {
|
|
try {
|
|
const [, brandsData] = await Promise.all([
|
|
loadTeam(),
|
|
api.get('/brands').then(d => Array.isArray(d) ? d : []).catch(() => []),
|
|
loadTeams(),
|
|
loadRoles(),
|
|
])
|
|
setBrands(brandsData)
|
|
} catch (err) {
|
|
console.error('Failed to load initial data:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleTutorialComplete = async () => {
|
|
try {
|
|
await api.patch('/users/me/tutorial', { completed: true })
|
|
setShowTutorial(false)
|
|
} catch (err) {
|
|
console.error('Failed to complete tutorial:', err)
|
|
}
|
|
}
|
|
|
|
if (authLoading || loading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
|
<div className="text-center">
|
|
<div className="w-12 h-12 border-4 border-brand-primary border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
|
<p className="text-text-secondary font-medium">{t('dashboard.loadingHub')}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}>
|
|
{/* Profile completion prompt */}
|
|
{showProfilePrompt && (
|
|
<div className="fixed top-4 end-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-amber-400 flex items-center justify-center text-white shrink-0">
|
|
⚠️
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-amber-900 mb-1">{t('profile.completeYourProfile')}</h3>
|
|
<p className="text-sm text-amber-800 mb-3">
|
|
{t('profile.completeDesc')}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setProfileForm({
|
|
name: user?.name || '',
|
|
team_role: user?.teamRole || user?.team_role || '',
|
|
phone: user?.phone || '',
|
|
brands: Array.isArray(user?.brands) ? user.brands.join(', ') : '',
|
|
})
|
|
setShowProfileModal(true)
|
|
}}
|
|
className="px-3 py-1.5 bg-amber-400 text-white text-sm font-medium rounded-lg hover:bg-amber-500 transition-colors"
|
|
>
|
|
{t('profile.completeProfileBtn')}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowProfilePrompt(false)}
|
|
className="px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-100 rounded-lg transition-colors"
|
|
>
|
|
{t('profile.later')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowProfilePrompt(false)}
|
|
className="text-amber-600 hover:text-amber-800 transition-colors"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Profile completion modal */}
|
|
<Modal isOpen={showProfileModal} onClose={() => setShowProfileModal(false)} title={t('profile.completeYourProfile')} size="md">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.name')}</label>
|
|
<input
|
|
type="text"
|
|
value={profileForm.name}
|
|
onChange={e => setProfileForm(f => ({ ...f, name: e.target.value }))}
|
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
placeholder={t('team.fullName')}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')} {t('team.optional')}</label>
|
|
<input
|
|
type="text"
|
|
value={profileForm.phone}
|
|
onChange={e => setProfileForm(f => ({ ...f, phone: 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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('settings.language')}</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setLang('en')}
|
|
className={`p-3 rounded-lg border-2 text-center transition-all ${
|
|
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 className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
<button
|
|
onClick={() => setShowProfileModal(false)}
|
|
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={async () => {
|
|
setProfileSaving(true)
|
|
try {
|
|
await api.patch('/users/me/profile', {
|
|
name: profileForm.name,
|
|
phone: profileForm.phone || null,
|
|
})
|
|
await checkAuth()
|
|
setShowProfileModal(false)
|
|
setShowProfilePrompt(false)
|
|
} catch (err) {
|
|
console.error('Profile save failed:', err)
|
|
} finally {
|
|
setProfileSaving(false)
|
|
}
|
|
}}
|
|
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"
|
|
>
|
|
{profileSaving ? t('common.loading') : t('team.saveProfile')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Tutorial overlay */}
|
|
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
|
|
|
|
<ErrorBoundary>
|
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="w-8 h-8 border-2 border-brand-primary/30 border-t-brand-primary rounded-full animate-spin" /></div>}>
|
|
<Routes>
|
|
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
|
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
|
|
<Route path="/reset-password" element={user ? <Navigate to="/" replace /> : <ResetPassword />} />
|
|
<Route path="/review/:token" element={<PublicReview />} />
|
|
<Route path="/review-post/:token" element={<PublicPostReview />} />
|
|
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
|
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
|
<Route path="/review-translation/:token" element={<PublicTranslationReview />} />
|
|
<Route path="/approve-budget/:token" element={<PublicBudgetApproval />} />
|
|
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
|
<Route index element={<Dashboard />} />
|
|
{hasModule('marketing') && <>
|
|
<Route path="posts/:id" element={<PostDetail />} />
|
|
<Route path="posts" element={<PostProduction />} />
|
|
<Route path="calendar" element={<PostCalendar />} />
|
|
<Route path="artefacts" element={<Artefacts />} />
|
|
<Route path="assets" element={<Assets />} />
|
|
<Route path="campaigns" element={<Campaigns />} />
|
|
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
|
<Route path="brands" element={<Brands />} />
|
|
<Route path="translations" element={<Translations />} />
|
|
</>}
|
|
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
|
|
<Route path="finance" element={<Finance />} />
|
|
<Route path="budgets" element={<Budgets />} />
|
|
</>}
|
|
{hasModule('projects') && <>
|
|
<Route path="projects" element={<Projects />} />
|
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
|
<Route path="tasks" element={<Tasks />} />
|
|
</>}
|
|
{hasModule('issues') && <Route path="issues" element={<Issues />} />}
|
|
<Route path="team" element={<Team />} />
|
|
<Route path="settings" element={<Settings />} />
|
|
</Route>
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
</Suspense>
|
|
</ErrorBoundary>
|
|
</AppContext.Provider>
|
|
)
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<LanguageProvider>
|
|
<AuthProvider>
|
|
<ToastProvider>
|
|
<ThemeProvider>
|
|
<AppContent />
|
|
</ThemeProvider>
|
|
</ToastProvider>
|
|
</AuthProvider>
|
|
</LanguageProvider>
|
|
)
|
|
}
|
|
|
|
export default App
|