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:
parent
d21036a0de
commit
05e74becfe
11 changed files with 590 additions and 86 deletions
108
frontend/App.tsx
108
frontend/App.tsx
|
|
@ -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>
|
||||
|
|
|
|||
46
frontend/components/AgencyFilterBar.tsx
Normal file
46
frontend/components/AgencyFilterBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
240
frontend/components/UserManagement.tsx
Normal file
240
frontend/components/UserManagement.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
84
frontend/contexts/UserContext.tsx
Normal file
84
frontend/contexts/UserContext.tsx
Normal 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>;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue