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>
This commit is contained in:
michael 2026-02-19 08:36:38 -06:00
parent d21036a0de
commit 05e74becfe
11 changed files with 590 additions and 86 deletions

View file

@ -20,8 +20,11 @@ import { Auditing } from './components/Auditing';
import { Login } from './components/Login';
import { WIPReviewer } from './components/WIPReviewer';
import { KnowledgeBase } from './components/KnowledgeBase';
import { UserManagement } from './components/UserManagement';
import { AgencyFilterBar } from './components/AgencyFilterBar';
import { UserProvider, useUser } from './contexts/UserContext';
type View = 'Home' | 'Analytics' | 'Campaigns' | 'WIP Reviewer' | 'CopyGenAI' | 'Settings' | 'Profile' | 'Auditing' | 'Knowledge Base';
type View = 'Home' | 'Analytics' | 'Campaigns' | 'WIP Reviewer' | 'CopyGenAI' | 'Settings' | 'Profile' | 'Auditing' | 'Knowledge Base' | 'User Management';
export interface DropdownOptions {
campaigns: string[];
@ -36,6 +39,43 @@ const App: React.FC = () => {
const isAuthenticated = useIsAuthenticated();
const { instance: msalInstance, inProgress } = useMsal();
// Initialize API service with MSAL instance BEFORE rendering UserProvider
useEffect(() => {
if (msalInstance) {
apiService.setMsalInstance(msalInstance);
}
}, [msalInstance]);
// Show loading spinner during MSAL authentication interactions
if (inProgress !== InteractionStatus.None) {
return (
<div className="fixed inset-0 bg-primary-blue flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<svg className="animate-spin h-12 w-12 text-active-blue" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-white/80 text-sm">Authenticating...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Login />;
}
return (
<UserProvider>
<AppContent msalInstance={msalInstance} />
</UserProvider>
);
};
const AppContent: React.FC<{ msalInstance: any }> = ({ msalInstance }) => {
const isAuthenticated = true; // We're inside the authenticated boundary
const { user, isLoading: isUserLoading, canWrite, canSeeAnalytics, canSeeAuditing, canSeeKnowledgeBase, canSeeSettings, canSeeUserManagement, canEditSettings, isSuperAdmin, isOversightAdmin } = useUser();
// Get initial state from URL
const initialUrlState = parseUrlState();
@ -46,12 +86,9 @@ const App: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [isLoadingData, setIsLoadingData] = useState(true);
// Initialize API service with MSAL instance for authenticated requests
useEffect(() => {
if (msalInstance) {
apiService.setMsalInstance(msalInstance);
}
}, [msalInstance]);
// Agency filter state (session-level, for oversight_admin / super_admin)
const [selectedAgencyId, setSelectedAgencyId] = useState<string | null>(null);
const showAgencyFilter = isSuperAdmin || isOversightAdmin;
// Dropdown options now loaded from API
const [dropdownOptions, setDropdownOptions] = useState<DropdownOptions>({
@ -97,14 +134,14 @@ const App: React.FC = () => {
const [campaigns, setCampaigns] = useState<any[]>([]);
const [campaignProofs, setCampaignProofs] = useState<Record<string, any[]>>({});
// Load campaigns from API when authenticated
// Load campaigns from API when authenticated (re-fetch when agency filter changes)
useEffect(() => {
const loadCampaigns = async () => {
if (!isAuthenticated) return;
if (!isAuthenticated || isUserLoading) return;
setIsLoadingData(true);
try {
const response = await apiService.getCampaigns();
const response = await apiService.getCampaigns(selectedAgencyId || undefined);
setCampaigns(response.map(c => apiService.convertCampaignToFrontend(c)));
} catch (error) {
console.error('Failed to load campaigns:', error);
@ -115,23 +152,24 @@ const App: React.FC = () => {
};
loadCampaigns();
}, [isAuthenticated]);
}, [isAuthenticated, isUserLoading, selectedAgencyId]);
// Audit items now loaded from API instead of localStorage
const [flaggedItems, setFlaggedItems] = useState<FlaggedItem[]>([]);
const [resolvedItems, setResolvedItems] = useState<ResolvedItem[]>([]);
const [errorItems, setErrorItems] = useState<ErrorItem[]>([]);
// Load audit items from API when authenticated
// Load audit items from API when authenticated (re-fetch when agency filter changes)
useEffect(() => {
const loadAuditItems = async () => {
if (!isAuthenticated) return;
if (!isAuthenticated || isUserLoading) return;
try {
const agencyFilter = selectedAgencyId || undefined;
const [flagged, resolved, errors] = await Promise.all([
apiService.getFlaggedItems(),
apiService.getResolvedItems(),
apiService.getErrorItems(),
apiService.getFlaggedItems(agencyFilter),
apiService.getResolvedItems(agencyFilter),
apiService.getErrorItems(agencyFilter),
]);
setFlaggedItems(flagged.map(i => apiService.convertFlaggedItemToFrontend(i)));
setResolvedItems(resolved.map(i => apiService.convertResolvedItemToFrontend(i)));
@ -142,7 +180,7 @@ const App: React.FC = () => {
};
loadAuditItems();
}, [isAuthenticated]);
}, [isAuthenticated, isUserLoading, selectedAgencyId]);
// Sync state changes to URL
useEffect(() => {
@ -791,10 +829,12 @@ const App: React.FC = () => {
}
};
const readOnly = !canWrite;
const renderContent = () => {
switch (currentView) {
case 'Analytics':
return <Analytics />;
return <Analytics agencyId={selectedAgencyId || undefined} />;
case 'Profile':
return <Profile onLogout={handleLogout} msalInstance={msalInstance} />;
case 'CopyGenAI':
@ -820,18 +860,21 @@ const App: React.FC = () => {
onResolveSubmit={handleResolveSubmit}
flaggedItems={flaggedItems}
resolvedItems={resolvedItems}
readOnly={readOnly}
/>;
case 'WIP Reviewer':
return <WIPReviewer dropdownOptions={dropdownOptions} msalInstance={msalInstance} />;
case 'Auditing':
return <Auditing
flaggedItems={flaggedItems}
return <Auditing
flaggedItems={flaggedItems}
resolvedItems={resolvedItems}
errorItems={errorItems}
onNavigate={handleNavigateToAuditedItem}
onNavigate={handleNavigateToAuditedItem}
/>;
case 'Knowledge Base':
return <KnowledgeBase />;
case 'User Management':
return <UserManagement />;
case 'Settings':
return <Settings
options={dropdownOptions}
@ -843,6 +886,7 @@ const App: React.FC = () => {
onRemoveSubChannel={handleRemoveSubChannel}
onAddProofType={handleAddProofType}
onRemoveProofType={handleRemoveProofType}
readOnly={!canEditSettings}
/>;
case 'Home':
default:
@ -855,8 +899,8 @@ const App: React.FC = () => {
}
};
// Show loading spinner during MSAL authentication interactions
if (inProgress !== InteractionStatus.None) {
// Show loading spinner while user profile is loading
if (isUserLoading) {
return (
<div className="fixed inset-0 bg-primary-blue flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
@ -864,16 +908,12 @@ const App: React.FC = () => {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-white/80 text-sm">Authenticating...</p>
<p className="text-white/80 text-sm">Loading user profile...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Login />;
}
// Determine background color based on view to avoid grey bar on Home view
const mainBgColor = currentView === 'Home' ? 'bg-white' : 'bg-grey-100';
@ -885,11 +925,17 @@ const App: React.FC = () => {
<Sidebar
activeItem={currentView}
onNavigate={(view) => handleNavigate(view as View)}
userName={userInfo?.name}
userEmail={userInfo?.email}
isAdmin={true}
userName={user?.name || userInfo?.name}
userEmail={user?.email || userInfo?.email}
userRole={user?.role || 'basic_user'}
/>
<div className="flex-1 flex flex-col overflow-y-auto">
{showAgencyFilter && (
<AgencyFilterBar
selectedAgencyId={selectedAgencyId}
onAgencyChange={setSelectedAgencyId}
/>
)}
<main className="flex-1 flex flex-col min-h-full">
{renderContent()}
</main>

View file

@ -0,0 +1,46 @@
import React, { useState, useEffect } from 'react';
import apiService from '../services/apiService';
import type { AgencyResponse } from '../services/apiService';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
interface AgencyFilterBarProps {
selectedAgencyId: string | null;
onAgencyChange: (agencyId: string | null) => void;
}
export const AgencyFilterBar: React.FC<AgencyFilterBarProps> = ({ selectedAgencyId, onAgencyChange }) => {
const [agencies, setAgencies] = useState<AgencyResponse[]>([]);
useEffect(() => {
const loadAgencies = async () => {
try {
const data = await apiService.getAgencies();
setAgencies(data);
} catch (err) {
console.error('Failed to load agencies for filter:', err);
}
};
loadAgencies();
}, []);
return (
<div className="bg-white border-b border-grey-300 px-6 py-3 flex items-center gap-3 flex-shrink-0">
<label className="text-sm font-medium text-primary-blue whitespace-nowrap">
Filter by Agency:
</label>
<div className="relative max-w-xs">
<select
value={selectedAgencyId || ''}
onChange={(e) => onAgencyChange(e.target.value || null)}
className="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 min-w-[200px]"
>
<option value="">All Agencies</option>
{agencies.map(a => (
<option key={a.id} value={a.id}>{a.name}</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>
</div>
);
};

View file

@ -29,14 +29,15 @@ const TrendIndicator: React.FC<{ trend: 'up' | 'down' | 'stable' }> = ({ trend }
return <div className="flex items-center gap-1.5 text-grey-700"><StableLine/> Stable</div>;
};
export const Analytics: React.FC = () => {
export const Analytics: React.FC<{ agencyId?: string }> = ({ agencyId }) => {
const [analytics, setAnalytics] = useState<AnalyticsResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadAnalytics = async () => {
setIsLoading(true);
try {
const data = await apiService.getAnalytics();
const data = await apiService.getAnalytics(agencyId);
setAnalytics(data);
} catch (error) {
console.error('Failed to load analytics:', error);
@ -45,7 +46,7 @@ export const Analytics: React.FC = () => {
}
};
loadAnalytics();
}, []);
}, [agencyId]);
// Calculate stats from API data
// Exclude errors from denominator since they weren't successfully reviewed

View file

@ -275,7 +275,8 @@ const CampaignList: React.FC<{
onOpenModal: () => void;
onCampaignStatusChange: (campaignName: string, newStatus: 'In Progress' | 'Completed') => void;
onDeleteCampaign: (campaign: typeof initialCampaigns[0]) => void;
}> = ({ onSelectCampaign, campaigns, onOpenModal, onCampaignStatusChange, onDeleteCampaign }) => {
readOnly?: boolean;
}> = ({ onSelectCampaign, campaigns, onOpenModal, onCampaignStatusChange, onDeleteCampaign, readOnly = false }) => {
const [showCompleted, setShowCompleted] = useState(true);
const [selectedCampaigns, setSelectedCampaigns] = useState<Set<string>>(new Set());
const [campaignToDelete, setCampaignToDelete] = useState<typeof initialCampaigns[0] | null>(null);
@ -396,20 +397,22 @@ const CampaignList: React.FC<{
<label htmlFor="show-completed" className="text-sm font-medium text-black-title whitespace-nowrap">Show Completed</label>
<ToggleSwitch enabled={showCompleted} onChange={setShowCompleted} />
</div>
<button
onClick={onOpenModal}
className="flex items-center gap-2 bg-active-blue text-white font-semibold py-2.5 px-6 rounded-full hover:bg-active-blue/90 transition-colors duration-300"
>
<PlusIcon className="h-5 w-5" />
Create New Campaign
</button>
{!readOnly && (
<button
onClick={onOpenModal}
className="flex items-center gap-2 bg-active-blue text-white font-semibold py-2.5 px-6 rounded-full hover:bg-active-blue/90 transition-colors duration-300"
>
<PlusIcon className="h-5 w-5" />
Create New Campaign
</button>
)}
</div>
</div>
</header>
<section>
{/* Bulk Actions Bar */}
{selectedCampaigns.size > 0 && (
{!readOnly && selectedCampaigns.size > 0 && (
<div className="mb-4 bg-info-light border border-active-blue rounded-[10px] p-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-active-blue">
@ -438,6 +441,7 @@ const CampaignList: React.FC<{
<table className="min-w-full">
<thead className="bg-lime">
<tr>
{!readOnly && (
<th scope="col" className="px-4 py-3 text-left">
<input
type="checkbox"
@ -450,13 +454,14 @@ const CampaignList: React.FC<{
aria-label="Select all campaigns"
/>
</th>
)}
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Campaign Name</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Proofs</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Status</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Created By</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Owning Agency</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Last Modified</th>
<th scope="col" className="relative px-4 py-3"><span className="sr-only">Actions</span></th>
{!readOnly && <th scope="col" className="relative px-4 py-3"><span className="sr-only">Actions</span></th>}
</tr>
</thead>
<tbody className="divide-y divide-grey-300">
@ -468,6 +473,7 @@ const CampaignList: React.FC<{
className={`hover:bg-info-light cursor-pointer ${isSelected ? 'bg-info-light' : index % 2 === 0 ? 'bg-white' : 'bg-grey-100'}`}
onClick={() => onSelectCampaign(campaign.name)}
>
{!readOnly && (
<td className="px-4 py-4 whitespace-nowrap">
<input
type="checkbox"
@ -478,6 +484,7 @@ const CampaignList: React.FC<{
aria-label={`Select ${campaign.name}`}
/>
</td>
)}
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-primary-blue">{campaign.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-primary-blue">{campaign.proofs}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm w-40">
@ -488,6 +495,7 @@ const CampaignList: React.FC<{
onClick={(e) => e.stopPropagation()}
className={getStatusSelectClasses(campaign.status)}
aria-label={`Change status for ${campaign.name}`}
disabled={readOnly}
>
<option value="In Progress">In Progress</option>
<option value="Completed">Completed</option>
@ -498,6 +506,7 @@ const CampaignList: React.FC<{
<td className="px-6 py-4 whitespace-nowrap text-sm text-primary-blue">{campaign.agencyLead}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-primary-blue">{campaign.agency}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-primary-blue">{formatDate(campaign.lastModified)}</td>
{!readOnly && (
<td className="px-4 py-4 whitespace-nowrap text-sm text-right">
<button
onClick={(e) => handleSingleDelete(campaign, e)}
@ -509,6 +518,7 @@ const CampaignList: React.FC<{
<TrashIcon className="h-5 w-5" />
</button>
</td>
)}
</tr>
);
})}
@ -1714,6 +1724,7 @@ interface CampaignsProps {
onResolveSubmit: (resolveData: Omit<ResolvedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => void;
flaggedItems: FlaggedItem[];
resolvedItems: ResolvedItem[];
readOnly?: boolean;
}
export const Campaigns: React.FC<CampaignsProps> = ({
@ -1736,6 +1747,7 @@ export const Campaigns: React.FC<CampaignsProps> = ({
onResolveSubmit,
flaggedItems,
resolvedItems,
readOnly = false,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
@ -1784,18 +1796,21 @@ export const Campaigns: React.FC<CampaignsProps> = ({
return (
<>
<CreateCampaignModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onAddCampaign={onAddNewCampaign}
brandGuidelines={dropdownOptions.brandGuidelines}
/>
{!readOnly && (
<CreateCampaignModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onAddCampaign={onAddNewCampaign}
brandGuidelines={dropdownOptions.brandGuidelines}
/>
)}
<CampaignList
onSelectCampaign={onSelectCampaign}
campaigns={campaigns}
onOpenModal={() => setIsModalOpen(true)}
onCampaignStatusChange={onCampaignStatusChange}
onDeleteCampaign={(campaign) => onDeleteCampaign(campaign.name)}
onOpenModal={readOnly ? () => {} : () => setIsModalOpen(true)}
onCampaignStatusChange={readOnly ? () => {} : onCampaignStatusChange}
onDeleteCampaign={readOnly ? () => {} : (campaign) => onDeleteCampaign(campaign.name)}
readOnly={readOnly}
/>
</>
);

View file

@ -278,12 +278,13 @@ interface SettingsProps {
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,
export const Settings: React.FC<SettingsProps> = ({
options,
onAddCampaign,
onRemoveCampaign,
onAddChannel,
@ -292,6 +293,7 @@ export const Settings: React.FC<SettingsProps> = ({
onRemoveSubChannel,
onAddProofType,
onRemoveProofType,
readOnly = false,
}) => {
const [activeTab, setActiveTab] = useState<Tab>('Channels');
const [selectedChannel, setSelectedChannel] = useState<string>('');
@ -323,6 +325,12 @@ export const Settings: React.FC<SettingsProps> = ({
<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">
@ -364,6 +372,7 @@ export const Settings: React.FC<SettingsProps> = ({
onAdd={onAddCampaign}
onRemove={onRemoveCampaign}
placeholder="e.g. Q4 Marketing"
disabled={readOnly}
/>
</div>
)}
@ -376,6 +385,7 @@ export const Settings: React.FC<SettingsProps> = ({
onAdd={onAddChannel}
onRemove={onRemoveChannel}
placeholder="e.g. Social, OOH"
disabled={readOnly}
/>
</div>
)}
@ -402,7 +412,7 @@ export const Settings: React.FC<SettingsProps> = ({
items={selectedChannel ? Object.keys(options.channels[selectedChannel]) : []}
onAdd={(val) => onAddSubChannel(selectedChannel, val)}
onRemove={(val) => onRemoveSubChannel(selectedChannel, val)}
disabled={!selectedChannel}
disabled={readOnly || !selectedChannel}
placeholder="e.g. Meta, Video"
/>
</div>
@ -457,7 +467,7 @@ export const Settings: React.FC<SettingsProps> = ({
}
onAdd={(val) => onAddProofType(selectedChannel, selectedSubChannel, val)}
onRemove={(val) => onRemoveProofType(selectedChannel, selectedSubChannel, val)}
disabled={!selectedChannel || !selectedSubChannel}
disabled={readOnly || !selectedChannel || !selectedSubChannel}
placeholder="e.g. In-feed 1x1, 300x600"
/>
</div>

View file

@ -8,16 +8,22 @@ import { UserIcon } from './icons/UserIcon';
import { CampaignsIcon } from './icons/CampaignsIcon';
import { AuditIcon } from './icons/AuditIcon';
import { KnowledgeBaseIcon } from './icons/KnowledgeBaseIcon';
import type { UserRole } from '../types';
const navigation = [
{ name: 'Home', icon: DashboardIcon },
{ name: 'Campaigns', icon: CampaignsIcon },
// { name: 'WIP Reviewer', icon: ClipboardIcon }, // Hidden: Moved to Settings > Beta
// { name: 'CopyGenAI', icon: CopyGenAIIcon }, // Hidden: Moved to Settings > Beta
{ name: 'Analytics', icon: AnalyticsIcon },
{ name: 'Auditing', icon: AuditIcon },
{ name: 'Knowledge Base', icon: KnowledgeBaseIcon, adminOnly: true },
{ name: 'Settings', icon: SettingsIcon },
interface NavItem {
name: string;
icon: React.FC<{ className?: string }>;
roles: UserRole[];
}
const navigation: NavItem[] = [
{ name: 'Home', icon: DashboardIcon, roles: ['super_admin', 'oversight_admin', 'agency_admin', 'basic_user'] },
{ name: 'Campaigns', icon: CampaignsIcon, roles: ['super_admin', 'oversight_admin', 'agency_admin', 'basic_user'] },
{ name: 'Analytics', icon: AnalyticsIcon, roles: ['super_admin', 'oversight_admin', 'agency_admin'] },
{ name: 'Auditing', icon: AuditIcon, roles: ['super_admin', 'oversight_admin'] },
{ name: 'Knowledge Base', icon: KnowledgeBaseIcon, roles: ['super_admin'] },
{ name: 'User Management', icon: UserIcon, roles: ['super_admin'] },
{ name: 'Settings', icon: SettingsIcon, roles: ['super_admin', 'oversight_admin', 'agency_admin'] },
];
interface SidebarProps {
@ -25,10 +31,10 @@ interface SidebarProps {
onNavigate: (viewName: string) => void;
userName?: string;
userEmail?: string;
isAdmin?: boolean;
userRole: UserRole;
}
export const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, userName, userEmail, isAdmin = true }) => {
export const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, userName, userEmail, userRole }) => {
return (
<aside className="w-72 flex-shrink-0 bg-primary-blue text-slate-200 flex flex-col border-r border-white/10 font-sans">
{/* Brand Header */}
@ -47,24 +53,20 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, userNa
{/* Navigation */}
<nav className="flex-1 pr-4 pl-0 py-8 space-y-2 overflow-y-auto">
{navigation.filter(item => !(item as any).adminOnly || isAdmin).map((item) => {
{navigation.filter(item => item.roles.includes(userRole)).map((item) => {
const Icon = item.icon;
const isActive = item.name === activeItem;
const isComingSoon = (item as any).isComingSoon;
return (
<button
key={item.name}
onClick={() => !isComingSoon && onNavigate(item.name)}
onClick={() => onNavigate(item.name)}
className={`group w-full flex items-center pl-8 pr-4 py-3.5 text-left text-sm font-medium transition-all duration-300 ease-in-out ${
isActive
? 'bg-white text-primary-blue shadow-lg rounded-r-[10px] rounded-l-none'
: isComingSoon
? 'text-slate-600 cursor-not-allowed rounded-[10px]'
: 'text-slate-300 hover:bg-white/10 hover:text-white rounded-[10px]'
}`}
aria-current={isActive ? 'page' : undefined}
disabled={isComingSoon}
>
<Icon className={`h-5 w-5 mr-4 transition-transform duration-300 ${isActive ? 'scale-110' : 'group-hover:scale-110'}`} />
<span className="flex-1 tracking-wide">{item.name}</span>

View file

@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react';
import apiService from '../services/apiService';
import type { UserManagementResponse, AgencyResponse } from '../services/apiService';
import { UserIcon } from './icons/UserIcon';
import { PlusIcon } from './icons/PlusIcon';
import { ChevronDownIcon } from './icons/ChevronDownIcon';
import type { UserRole } from '../types';
const ROLE_OPTIONS: { value: UserRole; label: string }[] = [
{ value: 'super_admin', label: 'Super Admin' },
{ value: 'oversight_admin', label: 'Oversight Admin' },
{ value: 'agency_admin', label: 'Agency Admin' },
{ value: 'basic_user', label: 'Basic User' },
];
const formatDate = (iso: string) => {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
export const UserManagement: React.FC = () => {
const [users, setUsers] = useState<UserManagementResponse[]>([]);
const [agencies, setAgencies] = useState<AgencyResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// New agency form
const [newAgencyName, setNewAgencyName] = useState('');
const [isCreatingAgency, setIsCreatingAgency] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
const [usersData, agenciesData] = await Promise.all([
apiService.getUsers(),
apiService.getAgencies(),
]);
setUsers(usersData);
setAgencies(agenciesData);
} catch (err) {
console.error('Failed to load user management data:', err);
setError('Failed to load data. You may not have permission.');
} finally {
setIsLoading(false);
}
};
const handleRoleChange = async (userId: string, newRole: string) => {
try {
const updated = await apiService.updateUser(userId, { role: newRole });
setUsers(prev => prev.map(u => u.id === userId ? updated : u));
} catch (err) {
console.error('Failed to update user role:', err);
setError('Failed to update user role.');
}
};
const handleAgencyChange = async (userId: string, agencyId: string | null) => {
try {
const updated = await apiService.updateUser(userId, { agency_id: agencyId });
setUsers(prev => prev.map(u => u.id === userId ? updated : u));
} catch (err) {
console.error('Failed to update user agency:', err);
setError('Failed to update user agency.');
}
};
const handleCreateAgency = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAgencyName.trim()) return;
setIsCreatingAgency(true);
try {
const newAgency = await apiService.createAgency(newAgencyName.trim());
setAgencies(prev => [...prev, newAgency].sort((a, b) => a.name.localeCompare(b.name)));
setNewAgencyName('');
} catch (err) {
console.error('Failed to create agency:', err);
setError('Failed to create agency.');
} finally {
setIsCreatingAgency(false);
}
};
if (isLoading) {
return (
<div className="p-8 flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3">
<svg className="animate-spin h-8 w-8 text-active-blue" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<p className="text-grey-700 text-sm">Loading user management...</p>
</div>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8 h-full bg-white overflow-y-auto">
<header className="mb-6">
<h1 className="text-3xl lg:text-4xl font-semibold text-primary-blue">User Management</h1>
<p className="text-base lg:text-lg text-primary-blue mt-1">
Manage user roles and agency assignments. Users are provisioned automatically via Azure AD.
</p>
</header>
{error && (
<div className="mb-4 p-3 bg-error/10 border border-error/30 rounded-[10px] text-error text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 font-semibold hover:underline">Dismiss</button>
</div>
)}
{/* Users Table */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-primary-blue mb-4">Users ({users.length})</h2>
<div className="bg-white rounded-[10px] shadow-md overflow-hidden border border-grey-300">
<div className="overflow-x-auto">
<table className="min-w-full">
<thead className="bg-lime">
<tr>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Role</th>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Agency</th>
<th className="px-6 py-3 text-left text-xs font-bold text-black-title uppercase tracking-wider">Created</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-3">
<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">
<div className="relative max-w-[180px]">
<select
value={user.role}
onChange={(e) => handleRoleChange(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"
>
{ROLE_OPTIONS.map(r => (
<option key={r.value} value={r.value}>{r.label}</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">
<div className="relative max-w-[200px]">
<select
value={user.agency_id || ''}
onChange={(e) => handleAgencyChange(user.id, e.target.value || null)}
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"
>
<option value="">None (Unassigned)</option>
{agencies.map(a => (
<option key={a.id} value={a.id}>{a.name}</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-sm text-grey-700">
{formatDate(user.created_at)}
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-grey-700">
No users found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
{/* Agency Management */}
<section>
<h2 className="text-xl font-semibold text-primary-blue mb-4">Agencies ({agencies.length})</h2>
<div className="max-w-xl">
<form onSubmit={handleCreateAgency} className="flex gap-2 mb-4">
<input
type="text"
value={newAgencyName}
onChange={(e) => setNewAgencyName(e.target.value)}
placeholder="New agency name..."
className="flex-grow p-2 border-2 border-grey-700 rounded-[10px] focus:ring-2 focus:ring-active-blue focus:border-active-blue transition text-black-title"
/>
<button
type="submit"
disabled={!newAgencyName.trim() || isCreatingAgency}
className="bg-active-blue text-white font-semibold py-2 px-6 rounded-full hover:bg-active-blue/90 transition-colors disabled:bg-grey-700 disabled:cursor-not-allowed flex items-center gap-2"
>
<PlusIcon className="h-4 w-4" />
Add
</button>
</form>
<div className="space-y-2">
{agencies.map(agency => (
<div
key={agency.id}
className="flex items-center justify-between bg-grey-100 p-3 rounded-[10px] border border-grey-300"
>
<span className="text-black-title font-medium">{agency.name}</span>
<span className="text-xs text-grey-700">
{users.filter(u => u.agency_id === agency.id).length} user(s)
</span>
</div>
))}
{agencies.length === 0 && (
<div className="text-center py-6 text-grey-700 bg-grey-100 rounded-[10px]">
No agencies created yet.
</div>
)}
</div>
</div>
</section>
</div>
);
};

View file

@ -0,0 +1,84 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import apiService from '../services/apiService';
import type { UserRole, AppUser } from '../types';
interface UserContextValue {
user: AppUser | null;
isLoading: boolean;
/** Convenience booleans derived from user.role */
isSuperAdmin: boolean;
isOversightAdmin: boolean;
canWrite: boolean;
canSeeAnalytics: boolean;
canSeeAuditing: boolean;
canSeeKnowledgeBase: boolean;
canSeeSettings: boolean;
canSeeUserManagement: boolean;
canEditSettings: boolean;
/** Re-fetch user from backend (e.g. after role change) */
refresh: () => Promise<void>;
}
const UserContext = createContext<UserContextValue>({
user: null,
isLoading: true,
isSuperAdmin: false,
isOversightAdmin: false,
canWrite: false,
canSeeAnalytics: false,
canSeeAuditing: false,
canSeeKnowledgeBase: false,
canSeeSettings: false,
canSeeUserManagement: false,
canEditSettings: false,
refresh: async () => {},
});
export const useUser = () => useContext(UserContext);
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AppUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchUser = async () => {
try {
const me = await apiService.getMe();
setUser({
id: me.id,
email: me.email,
name: me.name,
role: me.role as UserRole,
agencyId: me.agency_id,
agencyName: me.agency_name,
});
} catch (error) {
console.error('Failed to fetch current user:', error);
setUser(null);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchUser();
}, []);
const role = user?.role;
const value: UserContextValue = {
user,
isLoading,
isSuperAdmin: role === 'super_admin',
isOversightAdmin: role === 'oversight_admin',
canWrite: role !== 'oversight_admin' && role != null,
canSeeAnalytics: role === 'super_admin' || role === 'oversight_admin' || role === 'agency_admin',
canSeeAuditing: role === 'super_admin' || role === 'oversight_admin',
canSeeKnowledgeBase: role === 'super_admin',
canSeeSettings: role === 'super_admin' || role === 'oversight_admin' || role === 'agency_admin',
canSeeUserManagement: role === 'super_admin',
canEditSettings: role === 'super_admin',
refresh: fetchUser,
};
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
};

View file

@ -138,9 +138,15 @@ class ApiService {
return response.json();
}
// Current user endpoint
async getMe(): Promise<CurrentUserResponse> {
return this.fetch<CurrentUserResponse>('/me');
}
// Campaign endpoints
async getCampaigns(): Promise<CampaignResponse[]> {
return this.fetch<CampaignResponse[]>('/campaigns');
async getCampaigns(agencyId?: string): Promise<CampaignResponse[]> {
const params = agencyId ? `?agency_id=${agencyId}` : '';
return this.fetch<CampaignResponse[]>(`/campaigns${params}`);
}
async getCampaign(id: string): Promise<CampaignResponse> {
@ -241,21 +247,25 @@ class ApiService {
});
}
async getFlaggedItems(): Promise<FlaggedItemResponse[]> {
return this.fetch<FlaggedItemResponse[]>('/audit/flagged');
async getFlaggedItems(agencyId?: string): Promise<FlaggedItemResponse[]> {
const params = agencyId ? `?agency_id=${agencyId}` : '';
return this.fetch<FlaggedItemResponse[]>(`/audit/flagged${params}`);
}
async getResolvedItems(): Promise<ResolvedItemResponse[]> {
return this.fetch<ResolvedItemResponse[]>('/audit/resolved');
async getResolvedItems(agencyId?: string): Promise<ResolvedItemResponse[]> {
const params = agencyId ? `?agency_id=${agencyId}` : '';
return this.fetch<ResolvedItemResponse[]>(`/audit/resolved${params}`);
}
async getErrorItems(): Promise<ErrorItemResponse[]> {
return this.fetch<ErrorItemResponse[]>('/audit/errors');
async getErrorItems(agencyId?: string): Promise<ErrorItemResponse[]> {
const params = agencyId ? `?agency_id=${agencyId}` : '';
return this.fetch<ErrorItemResponse[]>(`/audit/errors${params}`);
}
// Analytics endpoint
async getAnalytics(): Promise<AnalyticsResponse> {
return this.fetch<AnalyticsResponse>('/analytics');
async getAnalytics(agencyId?: string): Promise<AnalyticsResponse> {
const params = agencyId ? `?agency_id=${agencyId}` : '';
return this.fetch<AnalyticsResponse>(`/analytics${params}`);
}
// Helper to convert API response to frontend format
@ -385,6 +395,25 @@ class ApiService {
});
}
// User management endpoints (super_admin only)
async getUsers(): Promise<UserManagementResponse[]> {
return this.fetch<UserManagementResponse[]>('/users');
}
async updateUser(userId: string, data: { role?: string; agency_id?: string | null }): Promise<UserManagementResponse> {
return this.fetch<UserManagementResponse>(`/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async createAgency(name: string): Promise<AgencyResponse> {
return this.fetch<AgencyResponse>('/agencies', {
method: 'POST',
body: JSON.stringify({ name }),
});
}
// Agency endpoints
async getAgencies(): Promise<AgencyResponse[]> {
return this.fetch<AgencyResponse[]>('/agencies');
@ -480,5 +509,24 @@ export interface AgencyResponse {
name: string;
}
export interface CurrentUserResponse {
id: string;
email: string;
name: string;
role: string;
agency_id: string | null;
agency_name: string | null;
}
export interface UserManagementResponse {
id: string;
email: string;
name: string;
role: string;
agency: string | null;
agency_id: string | null;
created_at: string;
}
export const apiService = new ApiService();
export default apiService;

View file

@ -1,6 +1,18 @@
// Fix: Broke a circular dependency by defining the AgentName type directly in this file instead of importing it.
export type AgentName = 'Legal Agent' | 'Brand Agent' | 'Channel Best Practices Agent' | 'Channel Tech Specs Agent';
// RBAC types
export type UserRole = 'super_admin' | 'oversight_admin' | 'agency_admin' | 'basic_user';
export interface AppUser {
id: string;
email: string;
name: string;
role: UserRole;
agencyId: string | null;
agencyName: string | null;
}
export type AgentStatus = 'pending' | 'in-progress' | 'complete' | 'issues-found' | 'error';
export type ReviewStatus = {

View file

@ -1,4 +1,4 @@
type View = 'Home' | 'Analytics' | 'Campaigns' | 'WIP Reviewer' | 'CopyGenAI' | 'Settings' | 'Profile' | 'Auditing';
type View = 'Home' | 'Analytics' | 'Campaigns' | 'WIP Reviewer' | 'CopyGenAI' | 'Settings' | 'Profile' | 'Auditing' | 'Knowledge Base' | 'User Management';
export interface UrlNavigationState {
view: View;
@ -6,7 +6,7 @@ export interface UrlNavigationState {
proofId: string | null;
}
const VALID_VIEWS: View[] = ['Home', 'Analytics', 'Campaigns', 'WIP Reviewer', 'CopyGenAI', 'Settings', 'Profile', 'Auditing'];
const VALID_VIEWS: View[] = ['Home', 'Analytics', 'Campaigns', 'WIP Reviewer', 'CopyGenAI', 'Settings', 'Profile', 'Auditing', 'Knowledge Base', 'User Management'];
export function parseUrlState(): UrlNavigationState {
const params = new URLSearchParams(window.location.search);