From da161014af14863492a5e2fec06612fec6489fe6 Mon Sep 17 00:00:00 2001 From: fahed Date: Wed, 4 Mar 2026 15:55:15 +0300 Subject: [PATCH] feat: team-based visibility, roles management, unified users, UI fixes - 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 --- client/src/App.jsx | 33 +- client/src/components/CampaignDetailPanel.jsx | 19 +- client/src/components/ProjectEditPanel.jsx | 30 +- client/src/components/Sidebar.jsx | 19 +- client/src/components/TeamMemberPanel.jsx | 64 ++-- client/src/i18n/ar.json | 14 +- client/src/i18n/en.json | 14 +- client/src/pages/Budgets.jsx | 59 ++- client/src/pages/PostProduction.jsx | 175 +++++---- client/src/pages/Projects.jsx | 19 +- client/src/pages/Settings.jsx | 126 ++++++- client/src/pages/Team.jsx | 9 +- server/helpers.js | 27 ++ server/server.js | 355 ++++++++++++------ 14 files changed, 655 insertions(+), 308 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index d15cea8..769dea7 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -24,7 +24,7 @@ const Projects = lazy(() => import('./pages/Projects')) const ProjectDetail = lazy(() => import('./pages/ProjectDetail')) const Tasks = lazy(() => import('./pages/Tasks')) 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 Brands = lazy(() => import('./pages/Brands')) const Login = lazy(() => import('./pages/Login')) @@ -37,18 +37,11 @@ const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker')) const ForgotPassword = lazy(() => import('./pages/ForgotPassword')) 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: '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' }, + { value: 'contributor', label: 'Contributor' }, ] export const AppContext = createContext() @@ -59,6 +52,7 @@ function AppContent() { const [teamMembers, setTeamMembers] = useState([]) const [brands, setBrands] = useState([]) const [teams, setTeams] = useState([]) + const [roles, setRoles] = useState([]) const [loading, setLoading] = useState(true) const [showTutorial, setShowTutorial] = useState(false) const [showProfilePrompt, setShowProfilePrompt] = useState(false) @@ -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 () => { try { const [, brandsData] = await Promise.all([ loadTeam(), api.get('/brands').then(d => Array.isArray(d) ? d : []).catch(() => []), loadTeams(), + loadRoles(), ]) setBrands(brandsData) } catch (err) { @@ -151,7 +155,7 @@ function AppContent() { } return ( - + {/* Profile completion prompt */} {showProfilePrompt && (
@@ -312,9 +316,6 @@ function AppContent() { {hasModule('issues') && } />} } /> } /> - {user?.role === 'superadmin' && ( - } /> - )} } /> diff --git a/client/src/components/CampaignDetailPanel.jsx b/client/src/components/CampaignDetailPanel.jsx index 0308c02..a34dc6f 100644 --- a/client/src/components/CampaignDetailPanel.jsx +++ b/client/src/components/CampaignDetailPanel.jsx @@ -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 { useLanguage } from '../i18n/LanguageContext' import { PLATFORMS, getBrandColor } from '../utils/api' @@ -7,9 +7,11 @@ import Modal from './Modal' import SlidePanel from './SlidePanel' import CollapsibleSection from './CollapsibleSection' import BudgetBar from './BudgetBar' +import { AppContext } from '../App' export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) { const { t, lang, currencySymbol } = useLanguage() + const { teams } = useContext(AppContext) const [form, setForm] = useState({}) const [dirty, setDirty] = useState(false) const [saving, setSaving] = useState(false) @@ -24,6 +26,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet name: campaign.name || '', description: campaign.description || '', brand_id: campaign.brandId || campaign.brand_id || '', + team_id: campaign.team_id || '', status: campaign.status || 'planning', 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 || ''), @@ -63,6 +66,7 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet name: form.name, description: form.description, brand_id: form.brand_id ? Number(form.brand_id) : null, + team_id: form.team_id ? Number(form.team_id) : null, status: form.status, start_date: form.start_date, end_date: form.end_date, @@ -177,6 +181,19 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
+ {/* Team */} +
+ + +
+ {/* Platforms */}
diff --git a/client/src/components/ProjectEditPanel.jsx b/client/src/components/ProjectEditPanel.jsx index d3c7ae3..0ddef90 100644 --- a/client/src/components/ProjectEditPanel.jsx +++ b/client/src/components/ProjectEditPanel.jsx @@ -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 { useLanguage } from '../i18n/LanguageContext' import { api, getBrandColor } from '../utils/api' @@ -6,8 +6,10 @@ import CommentsSection from './CommentsSection' import Modal from './Modal' import SlidePanel from './SlidePanel' import CollapsibleSection from './CollapsibleSection' +import { AppContext } from '../App' export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) { + const { teams } = useContext(AppContext) const { t, lang } = useLanguage() const thumbnailInputRef = useRef(null) const [form, setForm] = useState({}) @@ -26,6 +28,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b description: project.description || '', brand_id: project.brandId || project.brand_id || '', owner_id: project.ownerId || project.owner_id || '', + team_id: project.team_id || '', status: project.status || 'active', 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) : '', @@ -54,6 +57,7 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b description: form.description, brand_id: form.brand_id ? Number(form.brand_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, start_date: form.start_date || null, due_date: form.due_date || null, @@ -195,16 +199,28 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
- - update('start_date', e.target.value)} + +
+
+ + 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" + /> +
+
navLink(item))}
- {/* Superadmin Only: Users Management */} - {currentUser?.role === 'superadmin' && ( - - `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' - }` - } - > - - {!collapsed && {t('nav.users')}} - - )} - {/* Settings (visible to all) */} 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 inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length const doneCount = memberTasks.filter(t => t.status === 'done').length @@ -233,35 +224,42 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave )}
+ {/* Permission Level (superadmin only) */} + {userRole === 'superadmin' && !isEditingSelf && ( +
+ + +
+ )} + + {/* Role (from Roles table) */}
- + {isEditingSelf ? ( r.value === form.role)?.label || form.role || '—'} + value={roleName || '—'} disabled className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed" /> - ) : userRole === 'manager' && isCreateMode ? ( - <> - -

{t('team.fixedRole')}

- ) : ( )}
+
-
-
- +
+ + +
+ +
+ +
+ -
-
- -
- - -
diff --git a/client/src/pages/PostProduction.jsx b/client/src/pages/PostProduction.jsx index b898697..cdee61a 100644 --- a/client/src/pages/PostProduction.jsx +++ b/client/src/pages/PostProduction.jsx @@ -1,5 +1,5 @@ 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 { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../i18n/LanguageContext' @@ -37,6 +37,7 @@ export default function PostProduction() { const [moveError, setMoveError] = useState('') const [selectedIds, setSelectedIds] = useState(new Set()) const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) + const [showFilters, setShowFilters] = useState(false) useEffect(() => { loadPosts() @@ -158,98 +159,110 @@ export default function PostProduction() { return (
{/* Toolbar */} -
-
- - 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" - /> -
- -
-
- - - - - -
- -
- { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }} - onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }} - /> - -
+
+
+
+ { 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" - /> - - { 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" + 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" />
-
-
-
+ +
+ + +
+
- + {showFilters && ( +
+ + + + + + + { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }} + onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }} + /> + +
+ { 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" + /> + + { 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" + /> +
+
+ )}
{moveError && ( diff --git a/client/src/pages/Projects.jsx b/client/src/pages/Projects.jsx index 92d610d..1bd2da4 100644 --- a/client/src/pages/Projects.jsx +++ b/client/src/pages/Projects.jsx @@ -11,12 +11,12 @@ import { SkeletonCard } from '../components/SkeletonLoader' const EMPTY_PROJECT = { name: '', description: '', brand_id: '', status: 'active', - owner_id: '', start_date: '', due_date: '', + owner_id: '', start_date: '', due_date: '', team_id: '', } export default function Projects() { const navigate = useNavigate() - const { teamMembers, brands } = useContext(AppContext) + const { teamMembers, brands, teams } = useContext(AppContext) const { permissions } = useAuth() const [projects, setProjects] = useState([]) const [loading, setLoading] = useState(true) @@ -45,6 +45,7 @@ export default function Projects() { description: formData.description, brand_id: formData.brand_id ? Number(formData.brand_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, start_date: formData.start_date || null, due_date: formData.due_date || null, @@ -236,6 +237,20 @@ export default function Projects() { {teamMembers.map(m => )}
+
+ + +
+
+ +
+ + {/* Roles Management (Superadmin only) */} + {user?.role === 'superadmin' && } +
+ ) +} + +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 ( +
+
+

+ + {t('settings.roles')} +

+ +
+
+

{t('settings.rolesDesc')}

+
+ {roles.map(role => ( +
+ {editingRole?.Id === role.Id ? ( + handleSave(editingRole)} onCancel={() => setEditingRole(null)} saving={saving} t={t} /> + ) : ( + <> +
+ {role.name} + + + + )} +
+ ))} + {newRole && ( +
+ handleSave(newRole)} onCancel={() => setNewRole(null)} saving={saving} t={t} /> +
+ )} + {roles.length === 0 && !newRole && ( +

{t('settings.noRoles')}

+ )} +
+
+
+ ) +} + +function RoleForm({ role, onChange, onSave, onCancel, saving, t }) { + return ( +
+ onChange({ ...role, color: e.target.value })} + className="w-8 h-8 rounded-lg border border-border cursor-pointer" + /> + 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 + /> + +
) } diff --git a/client/src/pages/Team.jsx b/client/src/pages/Team.jsx index 8ad72d4..05fcd2c 100644 --- a/client/src/pages/Team.jsx +++ b/client/src/pages/Team.jsx @@ -58,7 +58,8 @@ export default function Team() { const payload = { name: data.name, email: data.email, - team_role: data.role, + role: data.role, + role_id: data.role_id, brands: data.brands, phone: data.phone, modules: data.modules, @@ -176,7 +177,7 @@ export default function Team() {

{selectedMember.name}

-

{(selectedMember.team_role || selectedMember.role)?.replace('_', ' ')}

+

{selectedMember.role_name || selectedMember.team_role || ''}

{selectedMember.email && (

{selectedMember.email}

)} @@ -499,7 +500,7 @@ export default function Team() {

{member.name}

-

{(member.team_role || member.role)?.replace('_', ' ')}

+

{member.role_name || member.team_role || ''}

{member.brands && member.brands.length > 0 && (
@@ -543,7 +544,7 @@ export default function Team() {

{member.name}

-

{(member.team_role || member.role)?.replace('_', ' ')}

+

{member.role_name || member.team_role || ''}

{member.brands && member.brands.length > 0 && (
diff --git a/server/helpers.js b/server/helpers.js index 5853f41..da181ce 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -102,6 +102,31 @@ function stripSensitiveFields(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 = { getRecordName, batchResolveNames, @@ -111,5 +136,7 @@ module.exports = { sanitizeWhereValue, getUserModules, stripSensitiveFields, + getUserTeamIds, + getUserVisibilityContext, _nameCache, }; diff --git a/server/server.js b/server/server.js index 2d26df9..1400334 100644 --- a/server/server.js +++ b/server/server.js @@ -11,7 +11,7 @@ const SqliteStore = require('connect-sqlite3')(session); const nocodb = require('./nocodb'); const crypto = require('crypto'); 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(); @@ -125,6 +125,11 @@ function requireOwnerOrRole(table, ...allowedRoles) { 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.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' }); } catch (err) { console.error('Owner check error:', err); @@ -139,8 +144,8 @@ const FK_COLUMNS = { Tasks: ['project_id', 'assigned_to_id', 'created_by_user_id'], CampaignTracks: ['campaign_id'], CampaignAssignments: ['campaign_id', 'member_id', 'assigner_id'], - Projects: ['brand_id', 'owner_id', 'created_by_user_id'], - Campaigns: ['brand_id', 'created_by_user_id'], + Projects: ['brand_id', 'owner_id', 'created_by_user_id', 'team_id'], + Campaigns: ['brand_id', 'created_by_user_id', 'team_id'], Posts: ['brand_id', 'assigned_to_id', 'campaign_id', 'track_id', 'created_by_user_id'], Assets: ['brand_id', 'campaign_id', 'uploader_id'], PostAttachments: ['post_id'], @@ -149,6 +154,7 @@ const FK_COLUMNS = { BudgetEntries: ['campaign_id', 'project_id'], Artefacts: ['project_id', 'campaign_id'], Issues: ['brand_id', 'assigned_to_id', 'team_id'], + Users: ['role_id'], }; // Maps link column names to FK field names for migration @@ -395,6 +401,10 @@ const REQUIRED_TABLES = { { title: 'uploaded_by', uidt: 'SingleLineText' }, { title: 'created_at', uidt: 'DateTime' }, ], + Roles: [ + { title: 'name', uidt: 'SingleLineText' }, + { title: 'color', uidt: 'SingleLineText' }, + ], }; 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 { - const users = await nocodb.list('Users', { sort: '-CreatedAt' }); - res.json(stripSensitiveFields(users)); + const users = await nocodb.list('Users', { sort: 'name' }); + // 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) { 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 ─────────────────────────────────────────── 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 allTeams = []; + let roles = []; try { allTeamMembers = await nocodb.list('TeamMembers', { limit: QUERY_LIMITS.max }); 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 = {}; 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 => { 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' })); - 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) { 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) => { - 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 (!email) return res.status(400).json({ error: 'Email is required' }); let userRole = role || '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 { @@ -987,6 +945,8 @@ app.post('/api/users/team', requireAuth, requireRole('superadmin', 'manager'), a brands: JSON.stringify(brands || []), phone: phone || null, modules: JSON.stringify(req.body.modules || ALL_MODULES), password_hash: passwordHash, + role_id: role_id || null, + avatar: avatar || null, }); 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' }); 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.brands !== undefined) data.brands = JSON.stringify(req.body.brands); 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' }); @@ -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) => { + if (Number(req.params.id) === req.session.userId) return res.status(400).json({ error: 'Cannot delete your own account' }); try { const user = await nocodb.get('Users', req.params.id); 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 posts = await nocodb.list('Posts', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium }); - // Visibility filtering for contributors + // Team-based visibility filtering 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 => - 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)); } - // Non-superadmin scoping - if (req.session.userRole !== 'superadmin') { - const userId = req.session.userId; + // Team-based visibility scoping + const userId = req.session.userId; + if (req.session.userRole === 'manager') { + const myTeamIds = await getUserTeamIds(userId); const myCampaignIds = await getUserCampaignIds(userId); - campaigns = campaigns.filter(c => { - return c.created_by_user_id === userId || myCampaignIds.has(c.Id); - }); + campaigns = campaigns.filter(c => + 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 - const brandIds = new Set(), userIds = new Set(); + const brandIds = new Set(), userIds = new Set(), teamIds = new Set(); for (const c of campaigns) { if (c.brand_id) brandIds.add(c.brand_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({ brand: { table: 'Brands', ids: [...brandIds] }, user: { table: 'Users', ids: [...userIds] }, + team: { table: 'Teams', ids: [...teamIds] }, }); res.json(campaigns.map(c => ({ ...c, brand_name: names[`brand:${c.brand_id}`] || null, creator_user_name: names[`user:${c.created_by_user_id}`] || null, + team_name: names[`team:${c.team_id}`] || null, }))); } catch (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); - 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) { res.status(500).json({ error: 'Failed to load campaign' }); } }); 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 (!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, notes: '', brand_id: brand_id ? Number(brand_id) : null, + team_id: team_id ? Number(team_id) : null, created_by_user_id: req.session.userId, }); @@ -1655,6 +1649,7 @@ app.post('/api/campaigns', requireAuth, requireRole('superadmin', 'manager'), as res.status(201).json({ ...campaign, brand_name: await getRecordName('Brands', campaign.brand_id), + team_name: await getRecordName('Teams', campaign.team_id), }); } catch (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.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' }); await nocodb.update('Campaigns', req.params.id, data); 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) { console.error('Update campaign error:', err); 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)})`); 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) { if (p.brand_id) brandIds.add(p.brand_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.team_id) teamIds.add(p.team_id); } const names = await batchResolveNames({ brand: { table: 'Brands', ids: [...brandIds] }, user: { table: 'Users', ids: [...userIds] }, + team: { table: 'Teams', ids: [...teamIds] }, }); 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, owner_name: names[`user:${p.owner_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, }))); } catch (err) { @@ -2162,6 +2179,7 @@ app.get('/api/projects/:id', requireAuth, async (req, res) => { brand_name: await getRecordName('Brands', project.brand_id), owner_name: await getRecordName('Users', project.owner_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, }); } 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) => { - 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' }); try { @@ -2180,6 +2198,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), asy start_date: start_date || null, due_date: due_date || null, brand_id: brand_id ? Number(brand_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, }); @@ -2188,6 +2207,7 @@ app.post('/api/projects', requireAuth, requireRole('superadmin', 'manager'), asy ...project, brand_name: await getRecordName('Brands', project.brand_id), owner_name: await getRecordName('Users', project.owner_id), + team_name: await getRecordName('Teams', project.team_id), }); } catch (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.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) { return res.status(400).json({ error: 'No fields to update' }); @@ -2219,6 +2240,7 @@ app.patch('/api/projects/:id', requireAuth, requireOwnerOrRole('projects', 'supe ...project, brand_name: await getRecordName('Brands', project.brand_id), owner_name: await getRecordName('Users', project.owner_id), + team_name: await getRecordName('Teams', project.team_id), }); } catch (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 }); - // Visibility filtering for contributors - if (req.session.userRole === 'contributor') { + // Team-based visibility filtering + const userId = req.session.userId; + if (req.session.userRole === 'manager') { + const { teamProjectIds } = await getUserVisibilityContext(userId); 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 }), ]); - // Build user's campaign IDs for scoping - let myCampaignIds; + // Build team-based scoping context + let myTeamIds = new Set(); + let myCampaignIds = new Set(); if (!isSuperadmin) { - myCampaignIds = new Set(); + myTeamIds = await getUserTeamIds(userId); for (const c of allCampaigns) { 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) { 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 let posts = allPosts; 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 = {}; for (const p of posts) { @@ -2613,16 +2660,23 @@ app.get('/api/dashboard', requireAuth, async (req, res) => { // Campaigns let campaigns = allCampaigns; 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; // Tasks let tasks = allTasks; if (!isSuperadmin) { - tasks = allTasks.filter(t => - t.created_by_user_id === userId || t.assigned_to_id === userId - ); + if (req.session.userRole === 'manager') { + 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 tasksByStatus = {}; @@ -2633,7 +2687,7 @@ app.get('/api/dashboard', requireAuth, async (req, res) => { // Projects let projects = allProjects; 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; @@ -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 }); - // Filter by permission: contributors see only their own - if (req.session.userRole === 'contributor') { - artefacts = artefacts.filter(a => a.created_by_user_id === req.session.userId); + // Team-based visibility filtering + const userId = 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 @@ -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 (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, 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 const names = await batchResolveNames({ brand: { table: 'Brands', ids: issues.map(i => i.brand_id) }, @@ -4207,6 +4282,62 @@ process.on('unhandledRejection', (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.get('/api/settings/app', requireAuth, (req, res) => {