modcomms/frontend/components/Settings.tsx
michael 05e74becfe Add frontend RBAC: UserContext, role-based sidebar, agency filter, user management
- Add UserRole type and AppUser interface to types.ts
- Create UserContext with useUser() hook providing role-based permission booleans
- Split App into App (auth wrapper) + AppContent (uses UserContext)
- Update Sidebar to filter nav items by UserRole instead of boolean isAdmin
- Add User Management nav item (super_admin only)
- Add AgencyFilterBar component for oversight_admin/super_admin session-level filtering
- Pass agencyId to getCampaigns, getAnalytics, audit endpoints in apiService
- Add getMe, getUsers, updateUser, createAgency to apiService
- Build UserManagement page with user table (role/agency dropdowns) and agency CRUD
- Add readOnly prop to Campaigns (hides create/delete/status-toggle for oversight_admin)
- Add readOnly prop to Settings (disables all ManagementCards, shows view-only banner)
- Pass agencyId to Analytics component for filtered data
- Update urlState with Knowledge Base and User Management views

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:36:38 -06:00

486 lines
25 KiB
TypeScript
Executable file

import React, { useState, useEffect } from 'react';
import type { DropdownOptions } from '../App';
import apiService from '../services/apiService';
import { TrashIcon } from './icons/TrashIcon';
import { UserIcon } from './icons/UserIcon';
import { PlusIcon } from './icons/PlusIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
interface ManagementCardProps {
title: string;
items: string[];
onAdd: (item: string) => void;
onRemove: (item: string) => void;
disabled?: boolean;
placeholder?: string;
}
const ManagementCard: React.FC<ManagementCardProps> = ({ title, items, onAdd, onRemove, disabled = false, placeholder }) => {
const [newItem, setNewItem] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (newItem.trim()) {
onAdd(newItem.trim());
setNewItem('');
}
};
return (
<div className="p-6 flex flex-col h-full">
<h2 className="text-xl font-semibold text-primary-blue mb-4">{title}</h2>
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
placeholder={placeholder || `New ${title.slice(0, -1)}...`}
className="flex-grow p-2 border-2 border-grey-700 rounded-[10px] focus:ring-2 focus:ring-active-blue focus:border-active-blue transition disabled:bg-grey-100 disabled:cursor-not-allowed text-black-title"
disabled={disabled}
/>
<button
type="submit"
className="bg-active-blue text-white font-semibold py-2 px-6 rounded-full hover:bg-active-blue/90 transition-colors duration-300 disabled:bg-grey-700 disabled:cursor-not-allowed"
disabled={!newItem.trim() || disabled}
>
Add
</button>
</form>
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
{items.length > 0 ? (
<ul className="space-y-2">
{items.map((item) => (
<li
key={item}
className="flex items-center justify-between bg-grey-100 p-2.5 rounded-[10px] border border-grey-300 group hover:border-active-blue/30 transition-colors"
>
<span className="text-black-title">{item}</span>
<button
onClick={() => onRemove(item)}
className={`text-grey-700 hover:text-error transition-opacity ${disabled ? 'opacity-0 cursor-not-allowed' : 'opacity-0 group-hover:opacity-100'}`}
title={`Remove ${item}`}
disabled={disabled}
>
<TrashIcon className="h-5 w-5" />
</button>
</li>
))}
</ul>
) : (
<div className="text-center py-8 text-grey-700 bg-grey-100 rounded-[10px]">
No items found.
</div>
)}
</div>
</div>
);
};
// --- USER MANAGEMENT ---
interface User {
id: string;
name: string;
email: string;
agency: string;
}
const INITIAL_USERS: User[] = [
{ id: '1', name: "Steve O'Donoghue", email: "steveodonoghue@oliver.agency", agency: "OLIVER Agency" },
{ id: '2', name: "Jane Doe", email: "jane.doe@barclays.com", agency: "Barclays" },
{ id: '3', name: "Sarah Jenkins", email: "sarah.jenkins@mindshare.com", agency: "Mindshare" },
];
// Fallback agencies if API fails
const DEFAULT_AGENCIES = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"];
const UsersTab: React.FC = () => {
const [users, setUsers] = useState<User[]>(() => {
const saved = localStorage.getItem('barclays_modcomms_users');
return saved ? JSON.parse(saved) : INITIAL_USERS;
});
const [agencies, setAgencies] = useState<string[]>(DEFAULT_AGENCIES);
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newAgency, setNewAgency] = useState('');
// Load agencies from API
useEffect(() => {
const loadAgencies = async () => {
try {
const response = await apiService.getAgencies();
const agencyNames = response.map(a => a.name);
setAgencies(agencyNames.length > 0 ? agencyNames : DEFAULT_AGENCIES);
if (agencyNames.length > 0 && !newAgency) {
setNewAgency(agencyNames[0]);
}
} catch (error) {
console.error('Failed to load agencies:', error);
setAgencies(DEFAULT_AGENCIES);
}
};
loadAgencies();
}, []);
useEffect(() => {
localStorage.setItem('barclays_modcomms_users', JSON.stringify(users));
}, [users]);
const handleAddUser = (e: React.FormEvent) => {
e.preventDefault();
if (newName && newEmail) {
const newUser: User = {
id: Date.now().toString(),
name: newName,
email: newEmail,
agency: newAgency
};
setUsers([...users, newUser]);
setNewName('');
setNewEmail('');
}
};
const handleAgencyChange = (userId: string, newAgency: string) => {
setUsers(users.map(u => u.id === userId ? { ...u, agency: newAgency } : u));
};
const handleDeleteUser = (userId: string) => {
if (window.confirm('Are you sure you want to remove this user?')) {
setUsers(users.filter(u => u.id !== userId));
}
};
return (
<div className="space-y-8">
{/* Add User Section */}
<div className="p-6">
<h3 className="text-lg font-semibold text-primary-blue mb-4 flex items-center gap-2">
<PlusIcon className="h-5 w-5 text-active-blue" />
Add New User
</h3>
<form onSubmit={handleAddUser} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div>
<label className="block text-xs font-medium text-black-title mb-1">Name</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full p-2 border-2 border-grey-700 rounded-[10px] focus:ring-2 focus:ring-active-blue focus:border-active-blue text-black-title"
placeholder="e.g. John Smith"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-black-title mb-1">Email</label>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="w-full p-2 border-2 border-grey-700 rounded-[10px] focus:ring-2 focus:ring-active-blue focus:border-active-blue text-black-title"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-black-title mb-1">Agency</label>
<div className="relative">
<select
value={newAgency}
onChange={(e) => setNewAgency(e.target.value)}
className="w-full p-2 border-2 border-grey-700 rounded-[10px] focus:ring-2 focus:ring-active-blue focus:border-active-blue appearance-none text-black-title"
>
{agencies.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-700 pointer-events-none" />
</div>
</div>
<button
type="submit"
className="bg-active-blue text-white font-semibold py-2 px-6 rounded-full hover:bg-active-blue/90 transition-colors"
>
Add User
</button>
</form>
</div>
{/* User List */}
<div className="overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full">
<thead className="bg-lime">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Email</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Assigned Agency</th>
<th scope="col" className="relative px-6 py-3"><span className="sr-only">Actions</span></th>
</tr>
</thead>
<tbody className="divide-y divide-grey-300">
{users.map((user, index) => (
<tr key={user.id} className={index % 2 === 0 ? 'bg-white' : 'bg-grey-100'}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-info-light flex items-center justify-center text-active-blue">
<UserIcon className="h-4 w-4" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-black-title">{user.name}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-black-title">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-black-title">
<div className="relative max-w-xs">
<select
value={user.agency}
onChange={(e) => handleAgencyChange(user.id, e.target.value)}
className="w-full bg-white border-2 border-active-blue text-black-title py-1.5 pl-3 pr-8 rounded-[10px] text-sm focus:outline-none focus:ring-2 focus:ring-active-blue appearance-none"
>
{agencies.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-active-blue pointer-events-none" />
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleDeleteUser(user.id)}
className="text-grey-700 hover:text-error transition-colors"
title="Remove User"
>
<TrashIcon className="h-5 w-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
// --- SETTINGS COMPONENT ---
interface SettingsProps {
options: DropdownOptions;
onAddCampaign: (value: string) => void;
onRemoveCampaign: (value: string) => void;
onAddChannel: (value: string) => void;
onRemoveChannel: (value: string) => void;
onAddSubChannel: (channel: string, value: string) => void;
onRemoveSubChannel: (channel: string, value: string) => void;
onAddProofType: (channel: string, subChannel: string, value: string) => void;
onRemoveProofType: (channel: string, subChannel: string, value: string) => void;
readOnly?: boolean;
}
type Tab = 'Campaigns' | 'Channels' | 'SubChannels' | 'ProofTypes' | 'Users';
export const Settings: React.FC<SettingsProps> = ({
options,
onAddCampaign,
onRemoveCampaign,
onAddChannel,
onRemoveChannel,
onAddSubChannel,
onRemoveSubChannel,
onAddProofType,
onRemoveProofType,
readOnly = false,
}) => {
const [activeTab, setActiveTab] = useState<Tab>('Channels');
const [selectedChannel, setSelectedChannel] = useState<string>('');
const [selectedSubChannel, setSelectedSubChannel] = useState<string>('');
// Update selected channel if it disappears
useEffect(() => {
if (selectedChannel && !options.channels[selectedChannel]) {
setSelectedChannel('');
setSelectedSubChannel('');
}
}, [options.channels, selectedChannel]);
// Update selected sub-channel if it disappears
useEffect(() => {
if (selectedChannel && selectedSubChannel) {
const subChannels = options.channels[selectedChannel] || {};
if (!subChannels[selectedSubChannel]) {
setSelectedSubChannel('');
}
}
}, [options.channels, selectedChannel, selectedSubChannel]);
return (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-white flex flex-col">
<header className="mb-6 flex-shrink-0">
<h1 className="text-3xl lg:text-4xl font-semibold text-primary-blue">Settings</h1>
<p className="text-base lg:text-lg text-primary-blue mt-1">
Configure application defaults and user access.
</p>
{readOnly && (
<div className="mt-3 inline-flex items-center gap-2 bg-info-light border border-active-blue text-active-blue text-sm font-medium px-4 py-2 rounded-[10px]">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
View-only mode
</div>
)}
</header>
<div className="flex-1 flex flex-col min-h-0">
{/* Tabs Header */}
<div className="border-b border-grey-300 flex-shrink-0">
<nav className="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
{[
{ id: 'Campaigns', label: 'Campaigns' },
{ id: 'Channels', label: 'Channels' },
{ id: 'SubChannels', label: 'Sub-channels' },
{ id: 'ProofTypes', label: 'Proof types' },
// { id: 'Users', label: 'Users' }, // Hidden: not connected to backend user system
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as Tab)}
className={`
whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors
${activeTab === tab.id
? 'border-active-blue text-active-blue'
: 'border-transparent text-black-title hover:text-active-blue hover:border-grey-300'
}
`}
aria-current={activeTab === tab.id ? 'page' : undefined}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tabs Content */}
<div className="flex-1 bg-transparent pt-6 overflow-y-auto">
{activeTab === 'Campaigns' && (
<div className="max-w-3xl">
<ManagementCard
title="Campaigns"
items={options.campaigns}
onAdd={onAddCampaign}
onRemove={onRemoveCampaign}
placeholder="e.g. Q4 Marketing"
disabled={readOnly}
/>
</div>
)}
{activeTab === 'Channels' && (
<div className="max-w-3xl">
<ManagementCard
title="Channels"
items={Object.keys(options.channels)}
onAdd={onAddChannel}
onRemove={onRemoveChannel}
placeholder="e.g. Social, OOH"
disabled={readOnly}
/>
</div>
)}
{activeTab === 'SubChannels' && (
<div className="max-w-3xl space-y-4">
<div className="bg-white p-4 rounded-[10px] border border-grey-300 shadow-sm">
<label className="block text-sm font-medium text-black-title mb-2">Select Parent Channel</label>
<div className="relative">
<select
value={selectedChannel}
onChange={(e) => setSelectedChannel(e.target.value)}
className={`w-full p-2 border-2 rounded-[10px] focus:ring-2 focus:ring-active-blue appearance-none text-black-title ${selectedChannel ? 'bg-info-light border-active-blue' : 'border-active-blue'}`}
>
<option value="" disabled>Select Channel</option>
{Object.keys(options.channels).map(c => <option key={c} value={c}>{c}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-active-blue pointer-events-none"/>
</div>
</div>
<ManagementCard
title={selectedChannel ? `Sub-Channels for ${selectedChannel}` : "Sub-Channels"}
items={selectedChannel ? Object.keys(options.channels[selectedChannel]) : []}
onAdd={(val) => onAddSubChannel(selectedChannel, val)}
onRemove={(val) => onRemoveSubChannel(selectedChannel, val)}
disabled={readOnly || !selectedChannel}
placeholder="e.g. Meta, Video"
/>
</div>
)}
{activeTab === 'ProofTypes' && (
<div className="max-w-3xl space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-[10px] border border-grey-300 shadow-sm">
<label className="block text-sm font-medium text-black-title mb-2">Select Channel</label>
<div className="relative">
<select
value={selectedChannel}
onChange={(e) => {
setSelectedChannel(e.target.value);
setSelectedSubChannel('');
}}
className={`w-full p-2 border-2 rounded-[10px] focus:ring-2 focus:ring-active-blue appearance-none text-black-title ${selectedChannel ? 'bg-info-light border-active-blue' : 'border-active-blue'}`}
>
<option value="" disabled>Select Channel</option>
{Object.keys(options.channels).map(c => <option key={c} value={c}>{c}</option>)}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-active-blue pointer-events-none"/>
</div>
</div>
<div className="bg-white p-4 rounded-[10px] border border-grey-300 shadow-sm">
<label className="block text-sm font-medium text-black-title mb-2">Select Sub-Channel</label>
<div className="relative">
<select
value={selectedSubChannel}
onChange={(e) => setSelectedSubChannel(e.target.value)}
className={`w-full p-2 border-2 rounded-[10px] focus:ring-2 focus:ring-active-blue appearance-none text-black-title disabled:bg-grey-100 disabled:border-grey-300 ${selectedSubChannel ? 'bg-info-light border-active-blue' : 'border-active-blue'}`}
disabled={!selectedChannel}
>
<option value="" disabled>Select Sub-Channel</option>
{selectedChannel && Object.keys(options.channels[selectedChannel]).map(sc => (
<option key={sc} value={sc}>{sc}</option>
))}
</select>
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-active-blue pointer-events-none"/>
</div>
</div>
</div>
<ManagementCard
title={selectedSubChannel ? `Proof Types for ${selectedSubChannel}` : "Proof Types"}
items={
(selectedChannel && selectedSubChannel)
? (options.channels[selectedChannel][selectedSubChannel] || [])
: []
}
onAdd={(val) => onAddProofType(selectedChannel, selectedSubChannel, val)}
onRemove={(val) => onRemoveProofType(selectedChannel, selectedSubChannel, val)}
disabled={readOnly || !selectedChannel || !selectedSubChannel}
placeholder="e.g. In-feed 1x1, 300x600"
/>
</div>
)}
{/* Users tab hidden: not connected to backend user system
{activeTab === 'Users' && (
<UsersTab />
)}
*/}
</div>
</div>
</div>
);
};