feat: team-based visibility, roles management, unified users, UI fixes
All checks were successful
Deploy / deploy (push) Successful in 12s
All checks were successful
Deploy / deploy (push) Successful in 12s
- Add Roles table with CRUD routes and Settings page management - Unify user management: remove Users page, enhance Team page with permission level + role dropdowns - Add team-based visibility scoping to projects, campaigns, posts, tasks, issues, artefacts, and dashboard - Add team_id to projects and campaigns (create + edit forms) - Add getUserTeamIds/getUserVisibilityContext helpers - Fix Budgets modal horizontal scroll (separate linked-to row) - Add collapsible filter bar to PostProduction page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ const Projects = lazy(() => import('./pages/Projects'))
|
|||||||
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
|
const ProjectDetail = lazy(() => import('./pages/ProjectDetail'))
|
||||||
const Tasks = lazy(() => import('./pages/Tasks'))
|
const Tasks = lazy(() => import('./pages/Tasks'))
|
||||||
const Team = lazy(() => import('./pages/Team'))
|
const Team = lazy(() => import('./pages/Team'))
|
||||||
const Users = lazy(() => import('./pages/Users'))
|
// Users page removed — unified into Team page
|
||||||
const Settings = lazy(() => import('./pages/Settings'))
|
const Settings = lazy(() => import('./pages/Settings'))
|
||||||
const Brands = lazy(() => import('./pages/Brands'))
|
const Brands = lazy(() => import('./pages/Brands'))
|
||||||
const Login = lazy(() => import('./pages/Login'))
|
const Login = lazy(() => import('./pages/Login'))
|
||||||
@@ -37,18 +37,11 @@ const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
|
|||||||
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
|
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
|
||||||
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
|
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
|
||||||
|
|
||||||
const TEAM_ROLES = [
|
// Permission levels (access control)
|
||||||
|
export const PERMISSION_LEVELS = [
|
||||||
|
{ value: 'superadmin', label: 'Super Admin' },
|
||||||
{ value: 'manager', label: 'Manager' },
|
{ value: 'manager', label: 'Manager' },
|
||||||
{ value: 'approver', label: 'Approver' },
|
{ value: 'contributor', label: 'Contributor' },
|
||||||
{ value: 'publisher', label: 'Publisher' },
|
|
||||||
{ value: 'content_creator', label: 'Content Creator' },
|
|
||||||
{ value: 'producer', label: 'Producer' },
|
|
||||||
{ value: 'designer', label: 'Designer' },
|
|
||||||
{ value: 'content_writer', label: 'Content Writer' },
|
|
||||||
{ value: 'social_media_manager', label: 'Social Media Manager' },
|
|
||||||
{ value: 'photographer', label: 'Photographer' },
|
|
||||||
{ value: 'videographer', label: 'Videographer' },
|
|
||||||
{ value: 'strategist', label: 'Strategist' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const AppContext = createContext()
|
export const AppContext = createContext()
|
||||||
@@ -59,6 +52,7 @@ function AppContent() {
|
|||||||
const [teamMembers, setTeamMembers] = useState([])
|
const [teamMembers, setTeamMembers] = useState([])
|
||||||
const [brands, setBrands] = useState([])
|
const [brands, setBrands] = useState([])
|
||||||
const [teams, setTeams] = useState([])
|
const [teams, setTeams] = useState([])
|
||||||
|
const [roles, setRoles] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showTutorial, setShowTutorial] = useState(false)
|
const [showTutorial, setShowTutorial] = useState(false)
|
||||||
const [showProfilePrompt, setShowProfilePrompt] = useState(false)
|
const [showProfilePrompt, setShowProfilePrompt] = useState(false)
|
||||||
@@ -115,12 +109,22 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
const loadInitialData = async () => {
|
||||||
try {
|
try {
|
||||||
const [, brandsData] = await Promise.all([
|
const [, brandsData] = await Promise.all([
|
||||||
loadTeam(),
|
loadTeam(),
|
||||||
api.get('/brands').then(d => Array.isArray(d) ? d : []).catch(() => []),
|
api.get('/brands').then(d => Array.isArray(d) ? d : []).catch(() => []),
|
||||||
loadTeams(),
|
loadTeams(),
|
||||||
|
loadRoles(),
|
||||||
])
|
])
|
||||||
setBrands(brandsData)
|
setBrands(brandsData)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -151,7 +155,7 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams }}>
|
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams, roles, loadRoles }}>
|
||||||
{/* Profile completion prompt */}
|
{/* Profile completion prompt */}
|
||||||
{showProfilePrompt && (
|
{showProfilePrompt && (
|
||||||
<div className="fixed top-4 right-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="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
||||||
@@ -312,9 +316,6 @@ function AppContent() {
|
|||||||
{hasModule('issues') && <Route path="issues" element={<Issues />} />}
|
{hasModule('issues') && <Route path="issues" element={<Issues />} />}
|
||||||
<Route path="team" element={<Team />} />
|
<Route path="team" element={<Team />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
{user?.role === 'superadmin' && (
|
|
||||||
<Route path="users" element={<Users />} />
|
|
||||||
)}
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { X, Trash2, DollarSign, Eye, MousePointer, Target } from 'lucide-react'
|
import { X, Trash2, DollarSign, Eye, MousePointer, Target } from 'lucide-react'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { PLATFORMS, getBrandColor } from '../utils/api'
|
import { PLATFORMS, getBrandColor } from '../utils/api'
|
||||||
@@ -7,9 +7,11 @@ import Modal from './Modal'
|
|||||||
import SlidePanel from './SlidePanel'
|
import SlidePanel from './SlidePanel'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
import BudgetBar from './BudgetBar'
|
import BudgetBar from './BudgetBar'
|
||||||
|
import { AppContext } from '../App'
|
||||||
|
|
||||||
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
|
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
|
||||||
const { t, lang, currencySymbol } = useLanguage()
|
const { t, lang, currencySymbol } = useLanguage()
|
||||||
|
const { teams } = useContext(AppContext)
|
||||||
const [form, setForm] = useState({})
|
const [form, setForm] = useState({})
|
||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -24,6 +26,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
|||||||
name: campaign.name || '',
|
name: campaign.name || '',
|
||||||
description: campaign.description || '',
|
description: campaign.description || '',
|
||||||
brand_id: campaign.brandId || campaign.brand_id || '',
|
brand_id: campaign.brandId || campaign.brand_id || '',
|
||||||
|
team_id: campaign.team_id || '',
|
||||||
status: campaign.status || 'planning',
|
status: campaign.status || 'planning',
|
||||||
start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : (campaign.start_date || ''),
|
start_date: campaign.startDate ? new Date(campaign.startDate).toISOString().slice(0, 10) : (campaign.start_date || ''),
|
||||||
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : (campaign.end_date || ''),
|
end_date: campaign.endDate ? new Date(campaign.endDate).toISOString().slice(0, 10) : (campaign.end_date || ''),
|
||||||
@@ -63,6 +66,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
|||||||
name: form.name,
|
name: form.name,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
||||||
|
team_id: form.team_id ? Number(form.team_id) : null,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
start_date: form.start_date,
|
start_date: form.start_date,
|
||||||
end_date: form.end_date,
|
end_date: form.end_date,
|
||||||
@@ -177,6 +181,19 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Team */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
|
||||||
|
<select
|
||||||
|
value={form.team_id}
|
||||||
|
onChange={e => update('team_id', 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="">{t('common.noTeam')}</option>
|
||||||
|
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Platforms */}
|
{/* Platforms */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.platforms')}</label>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef, useContext } from 'react'
|
||||||
import { X, Trash2, Upload } from 'lucide-react'
|
import { X, Trash2, Upload } from 'lucide-react'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, getBrandColor } from '../utils/api'
|
import { api, getBrandColor } from '../utils/api'
|
||||||
@@ -6,8 +6,10 @@ import CommentsSection from './CommentsSection'
|
|||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import SlidePanel from './SlidePanel'
|
import SlidePanel from './SlidePanel'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
|
import { AppContext } from '../App'
|
||||||
|
|
||||||
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
||||||
|
const { teams } = useContext(AppContext)
|
||||||
const { t, lang } = useLanguage()
|
const { t, lang } = useLanguage()
|
||||||
const thumbnailInputRef = useRef(null)
|
const thumbnailInputRef = useRef(null)
|
||||||
const [form, setForm] = useState({})
|
const [form, setForm] = useState({})
|
||||||
@@ -26,6 +28,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
|||||||
description: project.description || '',
|
description: project.description || '',
|
||||||
brand_id: project.brandId || project.brand_id || '',
|
brand_id: project.brandId || project.brand_id || '',
|
||||||
owner_id: project.ownerId || project.owner_id || '',
|
owner_id: project.ownerId || project.owner_id || '',
|
||||||
|
team_id: project.team_id || '',
|
||||||
status: project.status || 'active',
|
status: project.status || 'active',
|
||||||
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
|
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
|
||||||
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
|
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
|
||||||
@@ -54,6 +57,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
|||||||
description: form.description,
|
description: form.description,
|
||||||
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
||||||
owner_id: form.owner_id ? Number(form.owner_id) : null,
|
owner_id: form.owner_id ? Number(form.owner_id) : null,
|
||||||
|
team_id: form.team_id ? Number(form.team_id) : null,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
start_date: form.start_date || null,
|
start_date: form.start_date || null,
|
||||||
due_date: form.due_date || null,
|
due_date: form.due_date || null,
|
||||||
@@ -195,16 +199,28 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.startDate')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
|
||||||
<input
|
<select
|
||||||
type="date"
|
value={form.team_id}
|
||||||
value={form.start_date}
|
onChange={e => update('team_id', e.target.value)}
|
||||||
onChange={e => update('start_date', 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"
|
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="">{t('common.noTeam')}</option>
|
||||||
|
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.startDate')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.start_date}
|
||||||
|
onChange={e => update('start_date', 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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.dueDate')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.dueDate')}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom'
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
||||||
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
|
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, ChevronDown,
|
||||||
Sparkles, Shield, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
|
Sparkles, LogOut, User, Settings, Languages, Tag, LayoutList, Receipt, BarChart3, Palette, CalendarDays, AlertCircle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
@@ -167,23 +167,6 @@ export default function Sidebar({ collapsed, setCollapsed }) {
|
|||||||
{standaloneBottom.map(item => navLink(item))}
|
{standaloneBottom.map(item => navLink(item))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Superadmin Only: Users Management */}
|
|
||||||
{currentUser?.role === 'superadmin' && (
|
|
||||||
<NavLink
|
|
||||||
to="/users"
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
|
||||||
isActive
|
|
||||||
? 'bg-white/15 text-white shadow-sm'
|
|
||||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Shield className="w-5 h-5 shrink-0" />
|
|
||||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.users')}</span>}
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Settings (visible to all) */}
|
{/* Settings (visible to all) */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef, useContext } from 'react'
|
||||||
import { X, Trash2, ChevronDown, Check } from 'lucide-react'
|
import { X, Trash2, ChevronDown, Check } from 'lucide-react'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
@@ -6,20 +6,7 @@ import Modal from './Modal'
|
|||||||
import SlidePanel from './SlidePanel'
|
import SlidePanel from './SlidePanel'
|
||||||
import CollapsibleSection from './CollapsibleSection'
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
import StatusBadge from './StatusBadge'
|
import StatusBadge from './StatusBadge'
|
||||||
|
import { AppContext, PERMISSION_LEVELS } from '../App'
|
||||||
const ROLES = [
|
|
||||||
{ value: 'manager', label: 'Manager' },
|
|
||||||
{ value: 'approver', label: 'Approver' },
|
|
||||||
{ value: 'publisher', label: 'Publisher' },
|
|
||||||
{ value: 'content_creator', label: 'Content Creator' },
|
|
||||||
{ value: 'producer', label: 'Producer' },
|
|
||||||
{ value: 'designer', label: 'Designer' },
|
|
||||||
{ value: 'content_writer', label: 'Content Writer' },
|
|
||||||
{ value: 'social_media_manager', label: 'Social Media Manager' },
|
|
||||||
{ value: 'photographer', label: 'Photographer' },
|
|
||||||
{ value: 'videographer', label: 'Videographer' },
|
|
||||||
{ value: 'strategist', label: 'Strategist' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||||
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
||||||
@@ -31,6 +18,7 @@ const MODULE_COLORS = {
|
|||||||
|
|
||||||
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
||||||
const { t, lang } = useLanguage()
|
const { t, lang } = useLanguage()
|
||||||
|
const { roles } = useContext(AppContext)
|
||||||
const [form, setForm] = useState({})
|
const [form, setForm] = useState({})
|
||||||
const [dirty, setDirty] = useState(false)
|
const [dirty, setDirty] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -54,7 +42,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
name: member.name || '',
|
name: member.name || '',
|
||||||
email: member.email || '',
|
email: member.email || '',
|
||||||
password: '',
|
password: '',
|
||||||
role: member.team_role || member.role || 'content_writer',
|
permission_level: member.role || 'contributor',
|
||||||
|
role_id: member.role_id || '',
|
||||||
brands: Array.isArray(member.brands) ? member.brands : [],
|
brands: Array.isArray(member.brands) ? member.brands : [],
|
||||||
phone: member.phone || '',
|
phone: member.phone || '',
|
||||||
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
|
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
|
||||||
@@ -123,7 +112,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
name: form.name,
|
name: form.name,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
role: form.role,
|
role: form.permission_level,
|
||||||
|
role_id: form.role_id || null,
|
||||||
brands: form.brands || [],
|
brands: form.brands || [],
|
||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
modules: form.modules,
|
modules: form.modules,
|
||||||
@@ -143,7 +133,8 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initials = member.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() || '?'
|
const initials = member.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() || '?'
|
||||||
const roleName = (form.role || '').replace(/_/g, ' ')
|
const currentRole = roles.find(r => (r.Id || r.id) === form.role_id)
|
||||||
|
const roleName = currentRole?.name || member.role_name || member.team_role || ''
|
||||||
const todoCount = memberTasks.filter(t => t.status === 'todo').length
|
const todoCount = memberTasks.filter(t => t.status === 'todo').length
|
||||||
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
||||||
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
||||||
@@ -233,35 +224,42 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Permission Level (superadmin only) */}
|
||||||
|
{userRole === 'superadmin' && !isEditingSelf && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
|
||||||
|
<select
|
||||||
|
value={form.permission_level}
|
||||||
|
onChange={e => update('permission_level', 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"
|
||||||
|
>
|
||||||
|
{PERMISSION_LEVELS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role (from Roles table) */}
|
||||||
<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.role')}</label>
|
||||||
{isEditingSelf ? (
|
{isEditingSelf ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={ROLES.find(r => r.value === form.role)?.label || form.role || '—'}
|
value={roleName || '—'}
|
||||||
disabled
|
disabled
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
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
|
|
||||||
type="text"
|
|
||||||
value="Contributor"
|
|
||||||
disabled
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
value={form.role}
|
value={form.role_id || ''}
|
||||||
onChange={e => update('role', e.target.value)}
|
onChange={e => update('role_id', e.target.value ? Number(e.target.value) : null)}
|
||||||
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"
|
||||||
>
|
>
|
||||||
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
<option value="">{t('team.selectRole')}</option>
|
||||||
|
{roles.map(r => <option key={r.Id || r.id} value={r.Id || r.id}>{r.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -662,5 +662,17 @@
|
|||||||
"issues.selectTeam": "اختر فريقاً",
|
"issues.selectTeam": "اختر فريقاً",
|
||||||
"issues.publicSubmitTeam": "أي فريق يجب أن يتولى مشكلتك؟",
|
"issues.publicSubmitTeam": "أي فريق يجب أن يتولى مشكلتك؟",
|
||||||
"team.copyIssueLink": "نسخ رابط المشكلة",
|
"team.copyIssueLink": "نسخ رابط المشكلة",
|
||||||
"team.copyGenericIssueLink": "نسخ رابط المشاكل العام"
|
"team.copyGenericIssueLink": "نسخ رابط المشاكل العام",
|
||||||
|
"team.permissionLevel": "مستوى الصلاحية",
|
||||||
|
"team.role": "الدور",
|
||||||
|
"team.selectRole": "اختر دوراً...",
|
||||||
|
"common.team": "الفريق",
|
||||||
|
"common.noTeam": "بدون فريق",
|
||||||
|
"common.error": "حدث خطأ",
|
||||||
|
"settings.roles": "الأدوار",
|
||||||
|
"settings.rolesDesc": "حدد أدوار العمل مثل مصمم، استراتيجي، إلخ. يتم تعيينها لأعضاء الفريق بشكل منفصل عن مستويات الصلاحية.",
|
||||||
|
"settings.addRole": "إضافة دور",
|
||||||
|
"settings.roleName": "اسم الدور",
|
||||||
|
"settings.deleteRoleConfirm": "هل أنت متأكد من حذف هذا الدور؟",
|
||||||
|
"settings.noRoles": "لم يتم تحديد أدوار بعد. أضف أول دور."
|
||||||
}
|
}
|
||||||
@@ -662,5 +662,17 @@
|
|||||||
"issues.selectTeam": "Select a team",
|
"issues.selectTeam": "Select a team",
|
||||||
"issues.publicSubmitTeam": "Which team should handle your issue?",
|
"issues.publicSubmitTeam": "Which team should handle your issue?",
|
||||||
"team.copyIssueLink": "Copy Issue Link",
|
"team.copyIssueLink": "Copy Issue Link",
|
||||||
"team.copyGenericIssueLink": "Copy Public Issue Link"
|
"team.copyGenericIssueLink": "Copy Public Issue Link",
|
||||||
|
"team.permissionLevel": "Permission Level",
|
||||||
|
"team.role": "Role",
|
||||||
|
"team.selectRole": "Select role...",
|
||||||
|
"common.team": "Team",
|
||||||
|
"common.noTeam": "No team",
|
||||||
|
"common.error": "An error occurred",
|
||||||
|
"settings.roles": "Roles",
|
||||||
|
"settings.rolesDesc": "Define job roles like Designer, Strategist, etc. These are assigned to team members separately from permission levels.",
|
||||||
|
"settings.addRole": "Add Role",
|
||||||
|
"settings.roleName": "Role name",
|
||||||
|
"settings.deleteRoleConfirm": "Are you sure you want to delete this role?",
|
||||||
|
"settings.noRoles": "No roles defined yet. Add your first role."
|
||||||
}
|
}
|
||||||
@@ -410,39 +410,38 @@ export default function Budgets() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.category')}</label>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.category')}</label>
|
<select
|
||||||
|
value={form.category}
|
||||||
|
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
||||||
|
>
|
||||||
|
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.linkedTo')}</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<select
|
<select
|
||||||
value={form.category}
|
value={form.campaign_id}
|
||||||
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
|
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value, project_id: '' }))}
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
|
disabled={!!form.project_id}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
||||||
>
|
>
|
||||||
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
<option value="">{t('budgets.noCampaign')}</option>
|
||||||
|
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={form.project_id}
|
||||||
|
onChange={e => setForm(f => ({ ...f, project_id: e.target.value, campaign_id: '' }))}
|
||||||
|
disabled={!!form.campaign_id}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
||||||
|
>
|
||||||
|
<option value="">{t('budgets.noProject')}</option>
|
||||||
|
{projects.map(p => <option key={p._id || p.id} value={p._id || p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('budgets.linkedTo')}</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={form.campaign_id}
|
|
||||||
onChange={e => setForm(f => ({ ...f, campaign_id: e.target.value, project_id: '' }))}
|
|
||||||
disabled={!!form.project_id}
|
|
||||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
|
||||||
>
|
|
||||||
<option value="">{t('budgets.noCampaign')}</option>
|
|
||||||
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={form.project_id}
|
|
||||||
onChange={e => setForm(f => ({ ...f, project_id: e.target.value, campaign_id: '' }))}
|
|
||||||
disabled={!!form.campaign_id}
|
|
||||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none disabled:opacity-50 disabled:bg-surface-secondary"
|
|
||||||
>
|
|
||||||
<option value="">{t('budgets.noProject')}</option>
|
|
||||||
{projects.map(p => <option key={p._id || p.id} value={p._id || p.id}>{p.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { Plus, LayoutGrid, List, Search, X, FileText } from 'lucide-react'
|
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
@@ -37,6 +37,7 @@ export default function PostProduction() {
|
|||||||
const [moveError, setMoveError] = useState('')
|
const [moveError, setMoveError] = useState('')
|
||||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||||
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPosts()
|
loadPosts()
|
||||||
@@ -158,98 +159,110 @@ export default function PostProduction() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in">
|
<div className="space-y-4 animate-fade-in">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="space-y-2">
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
<input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
type="text"
|
|
||||||
placeholder={t('posts.searchPosts')}
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
|
||||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-tutorial="filters" className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<select
|
|
||||||
value={filters.brand}
|
|
||||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
|
||||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
||||||
>
|
|
||||||
<option value="">{t('posts.allBrands')}</option>
|
|
||||||
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={filters.platform}
|
|
||||||
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
|
|
||||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
||||||
>
|
|
||||||
<option value="">{t('posts.allPlatforms')}</option>
|
|
||||||
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={filters.assignedTo}
|
|
||||||
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
|
|
||||||
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
||||||
>
|
|
||||||
<option value="">{t('posts.allPeople')}</option>
|
|
||||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<DatePresetPicker
|
|
||||||
activePreset={activePreset}
|
|
||||||
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
|
|
||||||
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="text"
|
||||||
value={filters.periodFrom}
|
placeholder={t('posts.searchPosts')}
|
||||||
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
|
value={searchTerm}
|
||||||
title={t('posts.periodFrom')}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||||
/>
|
|
||||||
<span className="text-xs text-text-tertiary">–</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={filters.periodTo}
|
|
||||||
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
|
|
||||||
title={t('posts.periodTo')}
|
|
||||||
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('kanban')}
|
data-tutorial="filters"
|
||||||
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
onClick={() => setShowFilters(f => !f)}
|
||||||
|
className={`relative flex items-center gap-1.5 px-3 py-2 text-sm border rounded-lg transition-colors ${showFilters ? 'border-brand-primary bg-brand-primary/5 text-brand-primary' : 'border-border bg-white text-text-secondary hover:border-brand-primary/40'}`}
|
||||||
>
|
>
|
||||||
<LayoutGrid className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
|
{t('common.filter')}
|
||||||
|
{(filters.brand || filters.platform || filters.assignedTo || filters.periodFrom || filters.periodTo) && (
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-primary" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('kanban')}
|
||||||
|
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('list')}
|
||||||
|
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('list')}
|
data-tutorial="new-post"
|
||||||
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
onClick={openNew}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||||
>
|
>
|
||||||
<List className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
|
{t('posts.newPost')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{showFilters && (
|
||||||
data-tutorial="new-post"
|
<div className="flex items-center gap-2 flex-wrap animate-fade-in">
|
||||||
onClick={openNew}
|
<select
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
value={filters.brand}
|
||||||
>
|
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||||
<Plus className="w-4 h-4" />
|
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
{t('posts.newPost')}
|
>
|
||||||
</button>
|
<option value="">{t('posts.allBrands')}</option>
|
||||||
|
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filters.platform}
|
||||||
|
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
|
||||||
|
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
>
|
||||||
|
<option value="">{t('posts.allPlatforms')}</option>
|
||||||
|
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filters.assignedTo}
|
||||||
|
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
|
||||||
|
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
>
|
||||||
|
<option value="">{t('posts.allPeople')}</option>
|
||||||
|
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<DatePresetPicker
|
||||||
|
activePreset={activePreset}
|
||||||
|
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
|
||||||
|
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.periodFrom}
|
||||||
|
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
|
||||||
|
title={t('posts.periodFrom')}
|
||||||
|
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-text-tertiary">–</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.periodTo}
|
||||||
|
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
|
||||||
|
title={t('posts.periodTo')}
|
||||||
|
className="text-xs border border-border rounded-lg px-2 py-1.5 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{moveError && (
|
{moveError && (
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import { SkeletonCard } from '../components/SkeletonLoader'
|
|||||||
|
|
||||||
const EMPTY_PROJECT = {
|
const EMPTY_PROJECT = {
|
||||||
name: '', description: '', brand_id: '', status: 'active',
|
name: '', description: '', brand_id: '', status: 'active',
|
||||||
owner_id: '', start_date: '', due_date: '',
|
owner_id: '', start_date: '', due_date: '', team_id: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { teamMembers, brands } = useContext(AppContext)
|
const { teamMembers, brands, teams } = useContext(AppContext)
|
||||||
const { permissions } = useAuth()
|
const { permissions } = useAuth()
|
||||||
const [projects, setProjects] = useState([])
|
const [projects, setProjects] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -45,6 +45,7 @@ export default function Projects() {
|
|||||||
description: formData.description,
|
description: formData.description,
|
||||||
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
||||||
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
|
owner_id: formData.owner_id ? Number(formData.owner_id) : null,
|
||||||
|
team_id: formData.team_id ? Number(formData.team_id) : null,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
start_date: formData.start_date || null,
|
start_date: formData.start_date || null,
|
||||||
due_date: formData.due_date || null,
|
due_date: formData.due_date || null,
|
||||||
@@ -236,6 +237,20 @@ export default function Projects() {
|
|||||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">Team</label>
|
||||||
|
<select
|
||||||
|
value={formData.team_id}
|
||||||
|
onChange={e => setFormData(f => ({ ...f, team_id: 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="">No team</option>
|
||||||
|
{teams.map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
|
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
|
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload, Tag, Plus, Pencil, Trash2, X } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { useToast } from '../components/ToastContainer'
|
import { useToast } from '../components/ToastContainer'
|
||||||
import { CURRENCIES } from '../i18n/LanguageContext'
|
import { CURRENCIES } from '../i18n/LanguageContext'
|
||||||
|
import { AppContext } from '../App'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const ROLE_COLORS = [
|
||||||
|
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
|
||||||
|
'#EC4899', '#06B6D4', '#F97316', '#6366F1', '#14B8A6',
|
||||||
|
]
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { roles, loadRoles } = useContext(AppContext)
|
||||||
const [restarting, setRestarting] = useState(false)
|
const [restarting, setRestarting] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||||
@@ -176,6 +185,119 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Roles Management (Superadmin only) */}
|
||||||
|
{user?.role === 'superadmin' && <RolesSection roles={roles} loadRoles={loadRoles} t={t} toast={toast} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RolesSection({ roles, loadRoles, t, toast }) {
|
||||||
|
const [editingRole, setEditingRole] = useState(null)
|
||||||
|
const [newRole, setNewRole] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSave = async (role) => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (role.Id || role.id) {
|
||||||
|
await api.patch(`/roles/${role.Id || role.id}`, { name: role.name, color: role.color })
|
||||||
|
} else {
|
||||||
|
await api.post('/roles', { name: role.name, color: role.color })
|
||||||
|
}
|
||||||
|
await loadRoles()
|
||||||
|
setEditingRole(null)
|
||||||
|
setNewRole(null)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t('common.error'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (role) => {
|
||||||
|
if (!confirm(t('settings.deleteRoleConfirm'))) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/roles/${role.Id || role.id}`)
|
||||||
|
await loadRoles()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message || t('common.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-surface-primary rounded-xl border border-border overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Tag className="w-5 h-5 text-brand-primary" />
|
||||||
|
{t('settings.roles')}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setNewRole({ name: '', color: ROLE_COLORS[roles.length % ROLE_COLORS.length] })}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t('settings.addRole')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-sm text-text-tertiary mb-4">{t('settings.rolesDesc')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{roles.map(role => (
|
||||||
|
<div key={role.Id || role.id} className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-surface-secondary transition-colors">
|
||||||
|
{editingRole?.Id === role.Id ? (
|
||||||
|
<RoleForm role={editingRole} onChange={setEditingRole} onSave={() => handleSave(editingRole)} onCancel={() => setEditingRole(null)} saving={saving} t={t} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 rounded-full shrink-0" style={{ backgroundColor: role.color || '#94A3B8' }} />
|
||||||
|
<span className="flex-1 text-sm font-medium text-text-primary">{role.name}</span>
|
||||||
|
<button onClick={() => setEditingRole({ ...role })} className="p-1.5 text-text-tertiary hover:text-brand-primary rounded-lg hover:bg-surface-tertiary transition-colors">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(role)} className="p-1.5 text-text-tertiary hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{newRole && (
|
||||||
|
<div className="p-3 rounded-lg border-2 border-dashed border-brand-primary/30 bg-brand-primary/5">
|
||||||
|
<RoleForm role={newRole} onChange={setNewRole} onSave={() => handleSave(newRole)} onCancel={() => setNewRole(null)} saving={saving} t={t} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{roles.length === 0 && !newRole && (
|
||||||
|
<p className="text-sm text-text-tertiary text-center py-6">{t('settings.noRoles')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleForm({ role, onChange, onSave, onCancel, saving, t }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={role.color || '#94A3B8'}
|
||||||
|
onChange={e => onChange({ ...role, color: e.target.value })}
|
||||||
|
className="w-8 h-8 rounded-lg border border-border cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={role.name}
|
||||||
|
onChange={e => onChange({ ...role, name: e.target.value })}
|
||||||
|
placeholder={t('settings.roleName')}
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={onSave} disabled={!role.name || saving} className="px-3 py-1.5 text-sm font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors">
|
||||||
|
{saving ? '...' : t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} className="p-1.5 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-surface-tertiary transition-colors">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ export default function Team() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
team_role: data.role,
|
role: data.role,
|
||||||
|
role_id: data.role_id,
|
||||||
brands: data.brands,
|
brands: data.brands,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
modules: data.modules,
|
modules: data.modules,
|
||||||
@@ -176,7 +177,7 @@ export default function Team() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl font-bold text-text-primary">{selectedMember.name}</h2>
|
<h2 className="text-xl font-bold text-text-primary">{selectedMember.name}</h2>
|
||||||
<p className="text-sm text-text-secondary capitalize">{(selectedMember.team_role || selectedMember.role)?.replace('_', ' ')}</p>
|
<p className="text-sm text-text-secondary capitalize">{selectedMember.role_name || selectedMember.team_role || ''}</p>
|
||||||
{selectedMember.email && (
|
{selectedMember.email && (
|
||||||
<p className="text-sm text-text-tertiary mt-1">{selectedMember.email}</p>
|
<p className="text-sm text-text-tertiary mt-1">{selectedMember.email}</p>
|
||||||
)}
|
)}
|
||||||
@@ -499,7 +500,7 @@ export default function Team() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
||||||
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
|
<p className="text-xs text-text-tertiary capitalize">{member.role_name || member.team_role || ''}</p>
|
||||||
</div>
|
</div>
|
||||||
{member.brands && member.brands.length > 0 && (
|
{member.brands && member.brands.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 shrink-0">
|
<div className="flex flex-wrap gap-1 shrink-0">
|
||||||
@@ -543,7 +544,7 @@ export default function Team() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
||||||
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
|
<p className="text-xs text-text-tertiary capitalize">{member.role_name || member.team_role || ''}</p>
|
||||||
</div>
|
</div>
|
||||||
{member.brands && member.brands.length > 0 && (
|
{member.brands && member.brands.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 shrink-0">
|
<div className="flex flex-wrap gap-1 shrink-0">
|
||||||
|
|||||||
@@ -102,6 +102,31 @@ function stripSensitiveFields(data) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all team IDs for a user
|
||||||
|
async function getUserTeamIds(userId) {
|
||||||
|
const entries = await nocodb.list('TeamMembers', { where: `(user_id,eq,${userId})`, limit: 200 });
|
||||||
|
return new Set(entries.map(e => e.team_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get full visibility context for a user (team IDs + team project/campaign IDs)
|
||||||
|
async function getUserVisibilityContext(userId) {
|
||||||
|
const myTeamIds = await getUserTeamIds(userId);
|
||||||
|
if (myTeamIds.size === 0) return { myTeamIds, teamProjectIds: new Set(), teamCampaignIds: new Set() };
|
||||||
|
|
||||||
|
// Fetch projects and campaigns that belong to the user's teams
|
||||||
|
const allProjects = await nocodb.list('Projects', { limit: 2000 });
|
||||||
|
const allCampaigns = await nocodb.list('Campaigns', { limit: 2000 });
|
||||||
|
|
||||||
|
const teamProjectIds = new Set(
|
||||||
|
allProjects.filter(p => p.team_id && myTeamIds.has(p.team_id)).map(p => p.Id)
|
||||||
|
);
|
||||||
|
const teamCampaignIds = new Set(
|
||||||
|
allCampaigns.filter(c => c.team_id && myTeamIds.has(c.team_id)).map(c => c.Id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { myTeamIds, teamProjectIds, teamCampaignIds };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getRecordName,
|
getRecordName,
|
||||||
batchResolveNames,
|
batchResolveNames,
|
||||||
@@ -111,5 +136,7 @@ module.exports = {
|
|||||||
sanitizeWhereValue,
|
sanitizeWhereValue,
|
||||||
getUserModules,
|
getUserModules,
|
||||||
stripSensitiveFields,
|
stripSensitiveFields,
|
||||||
|
getUserTeamIds,
|
||||||
|
getUserVisibilityContext,
|
||||||
_nameCache,
|
_nameCache,
|
||||||
};
|
};
|
||||||
|
|||||||
355
server/server.js
355
server/server.js
@@ -11,7 +11,7 @@ const SqliteStore = require('connect-sqlite3')(session);
|
|||||||
const nocodb = require('./nocodb');
|
const nocodb = require('./nocodb');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config');
|
const { PORT, UPLOADS_DIR, SETTINGS_PATH, DEFAULTS, QUERY_LIMITS, ALL_MODULES, TABLE_NAME_MAP, COMMENT_ENTITY_TYPES } = require('./config');
|
||||||
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields } = require('./helpers');
|
const { getRecordName, batchResolveNames, parseApproverIds, safeJsonParse, pickBodyFields, sanitizeWhereValue, getUserModules, stripSensitiveFields, getUserTeamIds, getUserVisibilityContext } = require('./helpers');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -125,6 +125,11 @@ function requireOwnerOrRole(table, ...allowedRoles) {
|
|||||||
if (row.created_by_user_id === req.session.userId) return next();
|
if (row.created_by_user_id === req.session.userId) return next();
|
||||||
if (row.assigned_to_id && row.assigned_to_id === req.session.userId) return next();
|
if (row.assigned_to_id && row.assigned_to_id === req.session.userId) return next();
|
||||||
if (row.owner_id && row.owner_id === req.session.userId) return next();
|
if (row.owner_id && row.owner_id === req.session.userId) return next();
|
||||||
|
// Manager team-based access: if resource has team_id and manager is in that team
|
||||||
|
if (req.session.userRole === 'manager' && row.team_id) {
|
||||||
|
const myTeamIds = await getUserTeamIds(req.session.userId);
|
||||||
|
if (myTeamIds.has(row.team_id)) return next();
|
||||||
|
}
|
||||||
return res.status(403).json({ error: 'You can only modify your own items' });
|
return res.status(403).json({ error: 'You can only modify your own items' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Owner check error:', err);
|
console.error('Owner check error:', err);
|
||||||
@@ -139,8 +144,8 @@ const FK_COLUMNS = {
|
|||||||
Tasks: ['project_id', 'assigned_to_id', 'created_by_user_id'],
|
Tasks: ['project_id', 'assigned_to_id', 'created_by_user_id'],
|
||||||
CampaignTracks: ['campaign_id'],
|
CampaignTracks: ['campaign_id'],
|
||||||
CampaignAssignments: ['campaign_id', 'member_id', 'assigner_id'],
|
CampaignAssignments: ['campaign_id', 'member_id', 'assigner_id'],
|
||||||
Projects: ['brand_id', 'owner_id', 'created_by_user_id'],
|
Projects: ['brand_id', 'owner_id', 'created_by_user_id', 'team_id'],
|
||||||
Campaigns: ['brand_id', 'created_by_user_id'],
|
Campaigns: ['brand_id', 'created_by_user_id', 'team_id'],
|
||||||
Posts: ['brand_id', 'assigned_to_id', 'campaign_id', 'track_id', 'created_by_user_id'],
|
Posts: ['brand_id', 'assigned_to_id', 'campaign_id', 'track_id', 'created_by_user_id'],
|
||||||
Assets: ['brand_id', 'campaign_id', 'uploader_id'],
|
Assets: ['brand_id', 'campaign_id', 'uploader_id'],
|
||||||
PostAttachments: ['post_id'],
|
PostAttachments: ['post_id'],
|
||||||
@@ -149,6 +154,7 @@ const FK_COLUMNS = {
|
|||||||
BudgetEntries: ['campaign_id', 'project_id'],
|
BudgetEntries: ['campaign_id', 'project_id'],
|
||||||
Artefacts: ['project_id', 'campaign_id'],
|
Artefacts: ['project_id', 'campaign_id'],
|
||||||
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
||||||
|
Users: ['role_id'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Maps link column names to FK field names for migration
|
// Maps link column names to FK field names for migration
|
||||||
@@ -395,6 +401,10 @@ const REQUIRED_TABLES = {
|
|||||||
{ title: 'uploaded_by', uidt: 'SingleLineText' },
|
{ title: 'uploaded_by', uidt: 'SingleLineText' },
|
||||||
{ title: 'created_at', uidt: 'DateTime' },
|
{ title: 'created_at', uidt: 'DateTime' },
|
||||||
],
|
],
|
||||||
|
Roles: [
|
||||||
|
{ title: 'name', uidt: 'SingleLineText' },
|
||||||
|
{ title: 'color', uidt: 'SingleLineText' },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function ensureRequiredTables() {
|
async function ensureRequiredTables() {
|
||||||
@@ -831,84 +841,25 @@ app.patch('/api/users/me/tutorial', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── USER MANAGEMENT (Superadmin only) ──────────────────────────
|
// ─── USER MANAGEMENT ────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
|
app.get('/api/users', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = await nocodb.list('Users', { sort: '-CreatedAt' });
|
const users = await nocodb.list('Users', { sort: 'name' });
|
||||||
res.json(stripSensitiveFields(users));
|
// Enrich with role_name
|
||||||
|
let roles = [];
|
||||||
|
try { roles = await nocodb.list('Roles', { limit: QUERY_LIMITS.medium }); } catch {}
|
||||||
|
const roleMap = {};
|
||||||
|
for (const r of roles) roleMap[r.Id] = r.name;
|
||||||
|
res.json(stripSensitiveFields(users.map(u => ({
|
||||||
|
...u, id: u.Id, _id: u.Id,
|
||||||
|
role_name: u.role_id ? (roleMap[u.role_id] || null) : null,
|
||||||
|
}))));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to load users' });
|
res.status(500).json({ error: 'Failed to load users' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/users', requireAuth, requireRole('superadmin'), async (req, res) => {
|
|
||||||
const { name, email, password, role, avatar, team_role, brands, phone, modules } = req.body;
|
|
||||||
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' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = await nocodb.list('Users', { where: `(email,eq,${sanitizeWhereValue(email)})`, limit: 1 });
|
|
||||||
if (existing.length > 0) return res.status(409).json({ error: 'Email already exists' });
|
|
||||||
|
|
||||||
const defaultPassword = password || 'changeme123';
|
|
||||||
const passwordHash = await bcrypt.hash(defaultPassword, 10);
|
|
||||||
const created = await nocodb.create('Users', {
|
|
||||||
name, email, role, avatar: avatar || null,
|
|
||||||
team_role: team_role || null,
|
|
||||||
brands: JSON.stringify(brands || []),
|
|
||||||
phone: phone || null,
|
|
||||||
modules: JSON.stringify(modules || ALL_MODULES),
|
|
||||||
password_hash: passwordHash,
|
|
||||||
});
|
|
||||||
const user = await nocodb.get('Users', created.Id);
|
|
||||||
res.status(201).json(stripSensitiveFields({ ...user, id: user.Id, _id: user.Id }));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Create user error:', err);
|
|
||||||
res.status(500).json({ error: 'Failed to create user' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.patch('/api/users/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
try {
|
|
||||||
const existing = await nocodb.get('Users', id);
|
|
||||||
if (!existing) return res.status(404).json({ error: 'User not found' });
|
|
||||||
if (req.body.role && !['superadmin', 'manager', 'contributor'].includes(req.body.role)) return res.status(400).json({ error: 'Invalid role' });
|
|
||||||
|
|
||||||
const data = {};
|
|
||||||
for (const f of ['name', 'email', 'role', 'avatar', 'team_role', 'phone']) {
|
|
||||||
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) {
|
|
||||||
data.password_hash = await bcrypt.hash(req.body.password, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(data).length > 0) await nocodb.update('Users', id, data);
|
|
||||||
const user = await nocodb.get('Users', id);
|
|
||||||
res.json(stripSensitiveFields(user));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Update user error:', err);
|
|
||||||
res.status(500).json({ error: 'Failed to update user' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete('/api/users/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
if (Number(id) === req.session.userId) return res.status(400).json({ error: 'Cannot delete your own account' });
|
|
||||||
try {
|
|
||||||
const user = await nocodb.get('Users', id);
|
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
||||||
await nocodb.delete('Users', id);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: 'Failed to delete user' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── ASSIGNABLE USERS ───────────────────────────────────────────
|
// ─── ASSIGNABLE USERS ───────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/users/assignable', requireAuth, async (req, res) => {
|
app.get('/api/users/assignable', requireAuth, async (req, res) => {
|
||||||
@@ -945,20 +896,24 @@ app.get('/api/users/team', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach teams to each user
|
// Attach teams + role_name to each user
|
||||||
let allTeamMembers = [];
|
let allTeamMembers = [];
|
||||||
let allTeams = [];
|
let allTeams = [];
|
||||||
|
let roles = [];
|
||||||
try {
|
try {
|
||||||
allTeamMembers = await nocodb.list('TeamMembers', { limit: QUERY_LIMITS.max });
|
allTeamMembers = await nocodb.list('TeamMembers', { limit: QUERY_LIMITS.max });
|
||||||
allTeams = await nocodb.list('Teams', { limit: QUERY_LIMITS.medium });
|
allTeams = await nocodb.list('Teams', { limit: QUERY_LIMITS.medium });
|
||||||
} catch (err) { console.error('Load teams for user list:', err.message); }
|
roles = await nocodb.list('Roles', { limit: QUERY_LIMITS.medium });
|
||||||
|
} catch (err) { console.error('Load teams/roles for user list:', err.message); }
|
||||||
const teamMap = {};
|
const teamMap = {};
|
||||||
for (const t of allTeams) teamMap[t.Id] = t.name;
|
for (const t of allTeams) teamMap[t.Id] = t.name;
|
||||||
|
const roleMap = {};
|
||||||
|
for (const r of roles) roleMap[r.Id] = r.name;
|
||||||
|
|
||||||
res.json(stripSensitiveFields(filtered.map(u => {
|
res.json(stripSensitiveFields(filtered.map(u => {
|
||||||
const userTeamEntries = allTeamMembers.filter(tm => tm.user_id === u.Id);
|
const userTeamEntries = allTeamMembers.filter(tm => tm.user_id === u.Id);
|
||||||
const teams = userTeamEntries.map(tm => ({ id: tm.team_id, name: teamMap[tm.team_id] || 'Unknown' }));
|
const teams = userTeamEntries.map(tm => ({ id: tm.team_id, name: teamMap[tm.team_id] || 'Unknown' }));
|
||||||
return { ...u, id: u.Id, _id: u.Id, teams };
|
return { ...u, id: u.Id, _id: u.Id, teams, role_name: u.role_id ? (roleMap[u.role_id] || null) : null };
|
||||||
})));
|
})));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Team list error:', err);
|
console.error('Team list error:', err);
|
||||||
@@ -967,13 +922,16 @@ app.get('/api/users/team', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
const { name, email, password, team_role, brands, phone, role } = req.body;
|
const { name, email, password, team_role, brands, phone, role, role_id, avatar } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
if (!email) return res.status(400).json({ error: 'Email is required' });
|
if (!email) return res.status(400).json({ error: 'Email is required' });
|
||||||
|
|
||||||
let userRole = role || 'contributor';
|
let userRole = role || 'contributor';
|
||||||
if (req.session.userRole === 'manager' && userRole !== 'contributor') {
|
if (req.session.userRole === 'manager' && userRole !== 'contributor') {
|
||||||
return res.status(403).json({ error: 'Managers can only create users with contributor role' });
|
return res.status(403).json({ error: 'Managers can only create users with contributor permission level' });
|
||||||
|
}
|
||||||
|
if (userRole && !['superadmin', 'manager', 'contributor'].includes(userRole)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid permission level' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -987,6 +945,8 @@ app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), a
|
|||||||
brands: JSON.stringify(brands || []), phone: phone || null,
|
brands: JSON.stringify(brands || []), phone: phone || null,
|
||||||
modules: JSON.stringify(req.body.modules || ALL_MODULES),
|
modules: JSON.stringify(req.body.modules || ALL_MODULES),
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash,
|
||||||
|
role_id: role_id || null,
|
||||||
|
avatar: avatar || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await nocodb.get('Users', created.Id);
|
const user = await nocodb.get('Users', created.Id);
|
||||||
@@ -1003,11 +963,25 @@ app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager
|
|||||||
if (!existing) return res.status(404).json({ error: 'User not found' });
|
if (!existing) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
const data = {};
|
const data = {};
|
||||||
for (const f of ['name', 'email', 'team_role', 'phone']) {
|
for (const f of ['name', 'email', 'team_role', 'phone', 'avatar']) {
|
||||||
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.brands !== undefined) data.brands = JSON.stringify(req.body.brands);
|
||||||
if (req.body.modules !== undefined) data.modules = JSON.stringify(req.body.modules);
|
if (req.body.modules !== undefined) data.modules = JSON.stringify(req.body.modules);
|
||||||
|
if (req.body.role_id !== undefined) data.role_id = req.body.role_id;
|
||||||
|
|
||||||
|
// Only superadmin can change permission level (role field)
|
||||||
|
if (req.body.role !== undefined && req.session.userRole === 'superadmin') {
|
||||||
|
if (!['superadmin', 'manager', 'contributor'].includes(req.body.role)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid permission level' });
|
||||||
|
}
|
||||||
|
data.role = req.body.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password change
|
||||||
|
if (req.body.password) {
|
||||||
|
data.password_hash = await bcrypt.hash(req.body.password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
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' });
|
||||||
|
|
||||||
@@ -1021,6 +995,7 @@ app.patch('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
app.delete('/api/users/team/:id', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
|
if (Number(req.params.id) === req.session.userId) return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||||
try {
|
try {
|
||||||
const user = await nocodb.get('Users', req.params.id);
|
const user = await nocodb.get('Users', req.params.id);
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
@@ -1154,11 +1129,18 @@ app.get('/api/posts', requireAuth, async (req, res) => {
|
|||||||
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
||||||
const posts = await nocodb.list('Posts', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium });
|
const posts = await nocodb.list('Posts', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium });
|
||||||
|
|
||||||
// Visibility filtering for contributors
|
// Team-based visibility filtering
|
||||||
let filtered = posts;
|
let filtered = posts;
|
||||||
if (req.session.userRole === 'contributor') {
|
const userId = req.session.userId;
|
||||||
|
if (req.session.userRole === 'manager') {
|
||||||
|
const { teamCampaignIds } = await getUserVisibilityContext(userId);
|
||||||
filtered = filtered.filter(p =>
|
filtered = filtered.filter(p =>
|
||||||
p.created_by_user_id === req.session.userId || p.assigned_to_id === req.session.userId
|
p.created_by_user_id === userId || p.assigned_to_id === userId ||
|
||||||
|
(p.campaign_id && teamCampaignIds.has(p.campaign_id)) || !p.campaign_id
|
||||||
|
);
|
||||||
|
} else if (req.session.userRole === 'contributor') {
|
||||||
|
filtered = filtered.filter(p =>
|
||||||
|
p.created_by_user_id === userId || p.assigned_to_id === userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1568,30 +1550,40 @@ app.get('/api/campaigns', requireAuth, async (req, res) => {
|
|||||||
campaigns = campaigns.filter(c => c.brand_id === Number(req.query.brand_id));
|
campaigns = campaigns.filter(c => c.brand_id === Number(req.query.brand_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-superadmin scoping
|
// Team-based visibility scoping
|
||||||
if (req.session.userRole !== 'superadmin') {
|
const userId = req.session.userId;
|
||||||
const userId = req.session.userId;
|
if (req.session.userRole === 'manager') {
|
||||||
|
const myTeamIds = await getUserTeamIds(userId);
|
||||||
const myCampaignIds = await getUserCampaignIds(userId);
|
const myCampaignIds = await getUserCampaignIds(userId);
|
||||||
campaigns = campaigns.filter(c => {
|
campaigns = campaigns.filter(c =>
|
||||||
return c.created_by_user_id === userId || myCampaignIds.has(c.Id);
|
c.created_by_user_id === userId || myCampaignIds.has(c.Id) ||
|
||||||
});
|
(c.team_id && myTeamIds.has(c.team_id)) || !c.team_id
|
||||||
|
);
|
||||||
|
} else if (req.session.userRole === 'contributor') {
|
||||||
|
const myCampaignIds = await getUserCampaignIds(userId);
|
||||||
|
campaigns = campaigns.filter(c =>
|
||||||
|
c.created_by_user_id === userId || myCampaignIds.has(c.Id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich with names
|
// Enrich with names
|
||||||
const brandIds = new Set(), userIds = new Set();
|
const brandIds = new Set(), userIds = new Set(), teamIds = new Set();
|
||||||
for (const c of campaigns) {
|
for (const c of campaigns) {
|
||||||
if (c.brand_id) brandIds.add(c.brand_id);
|
if (c.brand_id) brandIds.add(c.brand_id);
|
||||||
if (c.created_by_user_id) userIds.add(c.created_by_user_id);
|
if (c.created_by_user_id) userIds.add(c.created_by_user_id);
|
||||||
|
if (c.team_id) teamIds.add(c.team_id);
|
||||||
}
|
}
|
||||||
const names = await batchResolveNames({
|
const names = await batchResolveNames({
|
||||||
brand: { table: 'Brands', ids: [...brandIds] },
|
brand: { table: 'Brands', ids: [...brandIds] },
|
||||||
user: { table: 'Users', ids: [...userIds] },
|
user: { table: 'Users', ids: [...userIds] },
|
||||||
|
team: { table: 'Teams', ids: [...teamIds] },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(campaigns.map(c => ({
|
res.json(campaigns.map(c => ({
|
||||||
...c,
|
...c,
|
||||||
brand_name: names[`brand:${c.brand_id}`] || null,
|
brand_name: names[`brand:${c.brand_id}`] || null,
|
||||||
creator_user_name: names[`user:${c.created_by_user_id}`] || null,
|
creator_user_name: names[`user:${c.created_by_user_id}`] || null,
|
||||||
|
team_name: names[`team:${c.team_id}`] || null,
|
||||||
})));
|
})));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('GET /campaigns error:', err);
|
console.error('GET /campaigns error:', err);
|
||||||
@@ -1615,14 +1607,15 @@ app.get('/api/campaigns/:id', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const brandName = await getRecordName('Brands', campaign.brand_id);
|
const brandName = await getRecordName('Brands', campaign.brand_id);
|
||||||
res.json({ ...campaign, brand_name: brandName });
|
const teamName = await getRecordName('Teams', campaign.team_id);
|
||||||
|
res.json({ ...campaign, brand_name: brandName, team_name: teamName });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to load campaign' });
|
res.status(500).json({ error: 'Failed to load campaign' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
const { name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms } = req.body;
|
const { name, description, brand_id, start_date, end_date, status, color, budget, goals, platforms, team_id } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' });
|
if (!start_date || !end_date) return res.status(400).json({ error: 'Start and end dates are required' });
|
||||||
|
|
||||||
@@ -1640,6 +1633,7 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
|
|||||||
budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0, cost_per_click: 0,
|
budget_spent: 0, revenue: 0, impressions: 0, clicks: 0, conversions: 0, cost_per_click: 0,
|
||||||
notes: '',
|
notes: '',
|
||||||
brand_id: brand_id ? Number(brand_id) : null,
|
brand_id: brand_id ? Number(brand_id) : null,
|
||||||
|
team_id: team_id ? Number(team_id) : null,
|
||||||
created_by_user_id: req.session.userId,
|
created_by_user_id: req.session.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1655,6 +1649,7 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as
|
|||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
...campaign,
|
...campaign,
|
||||||
brand_name: await getRecordName('Brands', campaign.brand_id),
|
brand_name: await getRecordName('Brands', campaign.brand_id),
|
||||||
|
team_name: await getRecordName('Teams', campaign.team_id),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create campaign error:', err);
|
console.error('Create campaign error:', err);
|
||||||
@@ -1677,13 +1672,18 @@ app.patch('/api/campaigns/:id', requireAuth, requireOwnerOrRole('campaigns', 'su
|
|||||||
}
|
}
|
||||||
if (body.platforms !== undefined) data.platforms = JSON.stringify(body.platforms);
|
if (body.platforms !== undefined) data.platforms = JSON.stringify(body.platforms);
|
||||||
if (body.brand_id !== undefined) data.brand_id = body.brand_id ? Number(body.brand_id) : null;
|
if (body.brand_id !== undefined) data.brand_id = body.brand_id ? Number(body.brand_id) : null;
|
||||||
|
if (body.team_id !== undefined) data.team_id = body.team_id ? Number(body.team_id) : null;
|
||||||
|
|
||||||
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' });
|
||||||
|
|
||||||
await nocodb.update('Campaigns', req.params.id, data);
|
await nocodb.update('Campaigns', req.params.id, data);
|
||||||
|
|
||||||
const campaign = await nocodb.get('Campaigns', req.params.id);
|
const campaign = await nocodb.get('Campaigns', req.params.id);
|
||||||
res.json({ ...campaign, brand_name: await getRecordName('Brands', campaign.brand_id) });
|
res.json({
|
||||||
|
...campaign,
|
||||||
|
brand_name: await getRecordName('Brands', campaign.brand_id),
|
||||||
|
team_name: await getRecordName('Teams', campaign.team_id),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Update campaign error:', err);
|
console.error('Update campaign error:', err);
|
||||||
res.status(500).json({ error: 'Failed to update campaign' });
|
res.status(500).json({ error: 'Failed to update campaign' });
|
||||||
@@ -2128,17 +2128,33 @@ app.get('/api/projects', requireAuth, async (req, res) => {
|
|||||||
if (req.query.owner_id) whereParts.push(`(owner_id,eq,${sanitizeWhereValue(req.query.owner_id)})`);
|
if (req.query.owner_id) whereParts.push(`(owner_id,eq,${sanitizeWhereValue(req.query.owner_id)})`);
|
||||||
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
||||||
|
|
||||||
const projects = await nocodb.list('Projects', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.medium });
|
let projects = await nocodb.list('Projects', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.medium });
|
||||||
|
|
||||||
const brandIds = new Set(), userIds = new Set();
|
// Team-based visibility filtering
|
||||||
|
const userId = req.session.userId;
|
||||||
|
if (req.session.userRole === 'manager') {
|
||||||
|
const myTeamIds = await getUserTeamIds(userId);
|
||||||
|
projects = projects.filter(p =>
|
||||||
|
p.created_by_user_id === userId || p.owner_id === userId ||
|
||||||
|
(p.team_id && myTeamIds.has(p.team_id)) || !p.team_id
|
||||||
|
);
|
||||||
|
} else if (req.session.userRole === 'contributor') {
|
||||||
|
projects = projects.filter(p =>
|
||||||
|
p.created_by_user_id === userId || p.owner_id === userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const brandIds = new Set(), userIds = new Set(), teamIds = new Set();
|
||||||
for (const p of projects) {
|
for (const p of projects) {
|
||||||
if (p.brand_id) brandIds.add(p.brand_id);
|
if (p.brand_id) brandIds.add(p.brand_id);
|
||||||
if (p.owner_id) userIds.add(p.owner_id);
|
if (p.owner_id) userIds.add(p.owner_id);
|
||||||
if (p.created_by_user_id) userIds.add(p.created_by_user_id);
|
if (p.created_by_user_id) userIds.add(p.created_by_user_id);
|
||||||
|
if (p.team_id) teamIds.add(p.team_id);
|
||||||
}
|
}
|
||||||
const names = await batchResolveNames({
|
const names = await batchResolveNames({
|
||||||
brand: { table: 'Brands', ids: [...brandIds] },
|
brand: { table: 'Brands', ids: [...brandIds] },
|
||||||
user: { table: 'Users', ids: [...userIds] },
|
user: { table: 'Users', ids: [...userIds] },
|
||||||
|
team: { table: 'Teams', ids: [...teamIds] },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(projects.map(p => ({
|
res.json(projects.map(p => ({
|
||||||
@@ -2146,6 +2162,7 @@ app.get('/api/projects', requireAuth, async (req, res) => {
|
|||||||
brand_name: names[`brand:${p.brand_id}`] || null,
|
brand_name: names[`brand:${p.brand_id}`] || null,
|
||||||
owner_name: names[`user:${p.owner_id}`] || null,
|
owner_name: names[`user:${p.owner_id}`] || null,
|
||||||
creator_user_name: names[`user:${p.created_by_user_id}`] || null,
|
creator_user_name: names[`user:${p.created_by_user_id}`] || null,
|
||||||
|
team_name: names[`team:${p.team_id}`] || null,
|
||||||
thumbnail_url: p.thumbnail ? `/api/uploads/${p.thumbnail}` : null,
|
thumbnail_url: p.thumbnail ? `/api/uploads/${p.thumbnail}` : null,
|
||||||
})));
|
})));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2162,6 +2179,7 @@ app.get('/api/projects/:id', requireAuth, async (req, res) => {
|
|||||||
brand_name: await getRecordName('Brands', project.brand_id),
|
brand_name: await getRecordName('Brands', project.brand_id),
|
||||||
owner_name: await getRecordName('Users', project.owner_id),
|
owner_name: await getRecordName('Users', project.owner_id),
|
||||||
creator_user_name: await getRecordName('Users', project.created_by_user_id),
|
creator_user_name: await getRecordName('Users', project.created_by_user_id),
|
||||||
|
team_name: await getRecordName('Teams', project.team_id),
|
||||||
thumbnail_url: project.thumbnail ? `/api/uploads/${project.thumbnail}` : null,
|
thumbnail_url: project.thumbnail ? `/api/uploads/${project.thumbnail}` : null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2170,7 +2188,7 @@ app.get('/api/projects/:id', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||||
const { name, description, brand_id, owner_id, status, priority, start_date, due_date } = req.body;
|
const { name, description, brand_id, owner_id, status, priority, start_date, due_date, team_id } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2180,6 +2198,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), asy
|
|||||||
start_date: start_date || null, due_date: due_date || null,
|
start_date: start_date || null, due_date: due_date || null,
|
||||||
brand_id: brand_id ? Number(brand_id) : null,
|
brand_id: brand_id ? Number(brand_id) : null,
|
||||||
owner_id: owner_id ? Number(owner_id) : null,
|
owner_id: owner_id ? Number(owner_id) : null,
|
||||||
|
team_id: team_id ? Number(team_id) : null,
|
||||||
created_by_user_id: req.session.userId,
|
created_by_user_id: req.session.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2188,6 +2207,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), asy
|
|||||||
...project,
|
...project,
|
||||||
brand_name: await getRecordName('Brands', project.brand_id),
|
brand_name: await getRecordName('Brands', project.brand_id),
|
||||||
owner_name: await getRecordName('Users', project.owner_id),
|
owner_name: await getRecordName('Users', project.owner_id),
|
||||||
|
team_name: await getRecordName('Teams', project.team_id),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create project error:', err);
|
console.error('Create project error:', err);
|
||||||
@@ -2207,6 +2227,7 @@ app.patch('/api/projects/:id', requireAuth, requireOwnerOrRole('projects', 'supe
|
|||||||
}
|
}
|
||||||
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
|
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
|
||||||
if (req.body.owner_id !== undefined) data.owner_id = req.body.owner_id ? Number(req.body.owner_id) : null;
|
if (req.body.owner_id !== undefined) data.owner_id = req.body.owner_id ? Number(req.body.owner_id) : null;
|
||||||
|
if (req.body.team_id !== undefined) data.team_id = req.body.team_id ? Number(req.body.team_id) : null;
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) {
|
if (Object.keys(data).length === 0) {
|
||||||
return res.status(400).json({ error: 'No fields to update' });
|
return res.status(400).json({ error: 'No fields to update' });
|
||||||
@@ -2219,6 +2240,7 @@ app.patch('/api/projects/:id', requireAuth, requireOwnerOrRole('projects', 'supe
|
|||||||
...project,
|
...project,
|
||||||
brand_name: await getRecordName('Brands', project.brand_id),
|
brand_name: await getRecordName('Brands', project.brand_id),
|
||||||
owner_name: await getRecordName('Users', project.owner_id),
|
owner_name: await getRecordName('Users', project.owner_id),
|
||||||
|
team_name: await getRecordName('Teams', project.team_id),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Update project error:', err);
|
console.error('Update project error:', err);
|
||||||
@@ -2279,10 +2301,17 @@ app.get('/api/tasks', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
let tasks = await nocodb.list('Tasks', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.max });
|
let tasks = await nocodb.list('Tasks', { where, sort: '-CreatedAt', limit: QUERY_LIMITS.max });
|
||||||
|
|
||||||
// Visibility filtering for contributors
|
// Team-based visibility filtering
|
||||||
if (req.session.userRole === 'contributor') {
|
const userId = req.session.userId;
|
||||||
|
if (req.session.userRole === 'manager') {
|
||||||
|
const { teamProjectIds } = await getUserVisibilityContext(userId);
|
||||||
tasks = tasks.filter(t =>
|
tasks = tasks.filter(t =>
|
||||||
t.created_by_user_id === req.session.userId || t.assigned_to_id === req.session.userId
|
t.created_by_user_id === userId || t.assigned_to_id === userId ||
|
||||||
|
(t.project_id && teamProjectIds.has(t.project_id)) || !t.project_id
|
||||||
|
);
|
||||||
|
} else if (req.session.userRole === 'contributor') {
|
||||||
|
tasks = tasks.filter(t =>
|
||||||
|
t.created_by_user_id === userId || t.assigned_to_id === userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2586,12 +2615,14 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
|||||||
nocodb.list('CampaignAssignments', { limit: QUERY_LIMITS.max }),
|
nocodb.list('CampaignAssignments', { limit: QUERY_LIMITS.max }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build user's campaign IDs for scoping
|
// Build team-based scoping context
|
||||||
let myCampaignIds;
|
let myTeamIds = new Set();
|
||||||
|
let myCampaignIds = new Set();
|
||||||
if (!isSuperadmin) {
|
if (!isSuperadmin) {
|
||||||
myCampaignIds = new Set();
|
myTeamIds = await getUserTeamIds(userId);
|
||||||
for (const c of allCampaigns) {
|
for (const c of allCampaigns) {
|
||||||
if (c.created_by_user_id === userId) myCampaignIds.add(c.Id);
|
if (c.created_by_user_id === userId) myCampaignIds.add(c.Id);
|
||||||
|
if (req.session.userRole === 'manager' && c.team_id && myTeamIds.has(c.team_id)) myCampaignIds.add(c.Id);
|
||||||
}
|
}
|
||||||
for (const a of allAssignments) {
|
for (const a of allAssignments) {
|
||||||
if (a.member_id === userId && a.campaign_id) {
|
if (a.member_id === userId && a.campaign_id) {
|
||||||
@@ -2600,10 +2631,26 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build team project IDs for managers
|
||||||
|
let myProjectIds = new Set();
|
||||||
|
if (!isSuperadmin) {
|
||||||
|
for (const p of allProjects) {
|
||||||
|
if (p.created_by_user_id === userId || p.owner_id === userId) myProjectIds.add(p.Id);
|
||||||
|
if (req.session.userRole === 'manager' && p.team_id && myTeamIds.has(p.team_id)) myProjectIds.add(p.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
let posts = allPosts;
|
let posts = allPosts;
|
||||||
if (!isSuperadmin) {
|
if (!isSuperadmin) {
|
||||||
posts = allPosts.filter(p => !p.campaign_id || myCampaignIds.has(p.campaign_id));
|
if (req.session.userRole === 'manager') {
|
||||||
|
posts = allPosts.filter(p =>
|
||||||
|
p.created_by_user_id === userId || p.assigned_to_id === userId ||
|
||||||
|
(p.campaign_id && myCampaignIds.has(p.campaign_id)) || !p.campaign_id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
posts = allPosts.filter(p => p.created_by_user_id === userId || p.assigned_to_id === userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const postsByStatus = {};
|
const postsByStatus = {};
|
||||||
for (const p of posts) {
|
for (const p of posts) {
|
||||||
@@ -2613,16 +2660,23 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
|||||||
// Campaigns
|
// Campaigns
|
||||||
let campaigns = allCampaigns;
|
let campaigns = allCampaigns;
|
||||||
if (!isSuperadmin) {
|
if (!isSuperadmin) {
|
||||||
campaigns = allCampaigns.filter(c => myCampaignIds.has(c.Id));
|
campaigns = allCampaigns.filter(c => myCampaignIds.has(c.Id) || c.created_by_user_id === userId);
|
||||||
}
|
}
|
||||||
const activeCampaigns = campaigns.filter(c => c.status === 'active').length;
|
const activeCampaigns = campaigns.filter(c => c.status === 'active').length;
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
let tasks = allTasks;
|
let tasks = allTasks;
|
||||||
if (!isSuperadmin) {
|
if (!isSuperadmin) {
|
||||||
tasks = allTasks.filter(t =>
|
if (req.session.userRole === 'manager') {
|
||||||
t.created_by_user_id === userId || t.assigned_to_id === userId
|
tasks = allTasks.filter(t =>
|
||||||
);
|
t.created_by_user_id === userId || t.assigned_to_id === userId ||
|
||||||
|
(t.project_id && myProjectIds.has(t.project_id)) || !t.project_id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tasks = allTasks.filter(t =>
|
||||||
|
t.created_by_user_id === userId || t.assigned_to_id === userId
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const overdueTasks = tasks.filter(t => t.due_date && new Date(t.due_date) < new Date() && t.status !== 'done').length;
|
const overdueTasks = tasks.filter(t => t.due_date && new Date(t.due_date) < new Date() && t.status !== 'done').length;
|
||||||
const tasksByStatus = {};
|
const tasksByStatus = {};
|
||||||
@@ -2633,7 +2687,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => {
|
|||||||
// Projects
|
// Projects
|
||||||
let projects = allProjects;
|
let projects = allProjects;
|
||||||
if (!isSuperadmin) {
|
if (!isSuperadmin) {
|
||||||
projects = allProjects.filter(p => p.created_by_user_id === userId);
|
projects = allProjects.filter(p => myProjectIds.has(p.Id) || p.created_by_user_id === userId || p.owner_id === userId);
|
||||||
}
|
}
|
||||||
const activeProjects = projects.filter(p => p.status === 'active').length;
|
const activeProjects = projects.filter(p => p.status === 'active').length;
|
||||||
|
|
||||||
@@ -2920,9 +2974,18 @@ app.get('/api/artefacts', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
let artefacts = await nocodb.list('Artefacts', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium });
|
let artefacts = await nocodb.list('Artefacts', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium });
|
||||||
|
|
||||||
// Filter by permission: contributors see only their own
|
// Team-based visibility filtering
|
||||||
if (req.session.userRole === 'contributor') {
|
const userId = req.session.userId;
|
||||||
artefacts = artefacts.filter(a => a.created_by_user_id === req.session.userId);
|
if (req.session.userRole === 'manager') {
|
||||||
|
const { teamProjectIds, teamCampaignIds } = await getUserVisibilityContext(userId);
|
||||||
|
artefacts = artefacts.filter(a =>
|
||||||
|
a.created_by_user_id === userId ||
|
||||||
|
(a.project_id && teamProjectIds.has(a.project_id)) ||
|
||||||
|
(a.campaign_id && teamCampaignIds.has(a.campaign_id)) ||
|
||||||
|
(!a.project_id && !a.campaign_id)
|
||||||
|
);
|
||||||
|
} else if (req.session.userRole === 'contributor') {
|
||||||
|
artefacts = artefacts.filter(a => a.created_by_user_id === userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich with names
|
// Enrich with names
|
||||||
@@ -3785,11 +3848,23 @@ app.get('/api/issues', requireAuth, async (req, res) => {
|
|||||||
if (brand_id) conditions.push({ field: 'brand_id', op: 'eq', value: sanitizeWhereValue(brand_id) });
|
if (brand_id) conditions.push({ field: 'brand_id', op: 'eq', value: sanitizeWhereValue(brand_id) });
|
||||||
if (team_id) conditions.push({ field: 'team_id', op: 'eq', value: sanitizeWhereValue(team_id) });
|
if (team_id) conditions.push({ field: 'team_id', op: 'eq', value: sanitizeWhereValue(team_id) });
|
||||||
|
|
||||||
const issues = await nocodb.list('Issues', {
|
let issues = await nocodb.list('Issues', {
|
||||||
where: conditions,
|
where: conditions,
|
||||||
sort: sort || '-created_at',
|
sort: sort || '-created_at',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Team-based visibility filtering
|
||||||
|
const userId = req.session.userId;
|
||||||
|
if (req.session.userRole === 'manager') {
|
||||||
|
const myTeamIds = await getUserTeamIds(userId);
|
||||||
|
issues = issues.filter(i =>
|
||||||
|
i.assigned_to_id === userId ||
|
||||||
|
(i.team_id && myTeamIds.has(i.team_id)) || !i.team_id
|
||||||
|
);
|
||||||
|
} else if (req.session.userRole === 'contributor') {
|
||||||
|
issues = issues.filter(i => i.assigned_to_id === userId);
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve brand and team names
|
// Resolve brand and team names
|
||||||
const names = await batchResolveNames({
|
const names = await batchResolveNames({
|
||||||
brand: { table: 'Brands', ids: issues.map(i => i.brand_id) },
|
brand: { table: 'Brands', ids: issues.map(i => i.brand_id) },
|
||||||
@@ -4207,6 +4282,62 @@ process.on('unhandledRejection', (err) => {
|
|||||||
console.error('[UNHANDLED REJECTION]', err);
|
console.error('[UNHANDLED REJECTION]', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── ROLES ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/roles', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const roles = await nocodb.list('Roles', { sort: 'name', limit: QUERY_LIMITS.medium });
|
||||||
|
res.json(roles.map(r => ({ ...r, id: r.Id, _id: r.Id })));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Roles list error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load roles' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/roles', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
const { name, color } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||||
|
try {
|
||||||
|
const created = await nocodb.create('Roles', { name, color: color || null });
|
||||||
|
const role = await nocodb.get('Roles', created.Id);
|
||||||
|
res.status(201).json({ ...role, id: role.Id, _id: role.Id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create role error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create role' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/roles/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await nocodb.get('Roles', req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Role not found' });
|
||||||
|
const data = {};
|
||||||
|
if (req.body.name !== undefined) data.name = req.body.name;
|
||||||
|
if (req.body.color !== undefined) data.color = req.body.color;
|
||||||
|
if (Object.keys(data).length > 0) await nocodb.update('Roles', req.params.id, data);
|
||||||
|
const role = await nocodb.get('Roles', req.params.id);
|
||||||
|
res.json({ ...role, id: role.Id, _id: role.Id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update role error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update role' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/roles/:id', requireAuth, requireRole('superadmin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const existing = await nocodb.get('Roles', req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Role not found' });
|
||||||
|
// Check if any users have this role
|
||||||
|
const usersWithRole = await nocodb.list('Users', { where: `(role_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: 1 });
|
||||||
|
if (usersWithRole.length > 0) return res.status(409).json({ error: 'Cannot delete role that is assigned to users' });
|
||||||
|
await nocodb.delete('Roles', req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete role error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete role' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── APP SETTINGS API ───────────────────────────────────────────
|
// ─── APP SETTINGS API ───────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/settings/app', requireAuth, (req, res) => {
|
app.get('/api/settings/app', requireAuth, (req, res) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user