const AdministrationView = ({ currentUser, userProperties, isActiveView, pendingKbDraft, onClearPendingKbDraft }) => { const RoleModal = window.RoleModal; const ArticleModal = window.ArticleModal; const PropertiesManagementModal = window.PropertiesManagementModal; const { useState, useEffect, useRef } = React; const [activeTab, setActiveTab] = useState('roles'); const [roles, setRoles] = useState([]); const [articles, setArticles] = useState([]); const [loading, setLoading] = useState(true); const [showRoleModal, setShowRoleModal] = useState(false); const [showArticleModal, setShowArticleModal] = useState(false); const [editingRole, setEditingRole] = useState(null); const [editingArticle, setEditingArticle] = useState(null); const [saving, setSaving] = useState(false); // Knowledge Base Explorer state - database-driven navigation const [kbItems, setKbItems] = useState([]); const [kbPath, setKbPath] = useState([]); // Breadcrumb path: [{id, title}, ...] const [kbCurrentFolderId, setKbCurrentFolderId] = useState(null); const [kbSearch, setKbSearch] = useState(''); const [kbPropertyFilter, setKbPropertyFilter] = useState(''); const [kbShowPending, setKbShowPending] = useState(false); const [showFolderModal, setShowFolderModal] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [showHistoryModal, setShowHistoryModal] = useState(false); const [historyVersions, setHistoryVersions] = useState([]); const [historyArticleId, setHistoryArticleId] = useState(null); const [previewArticle, setPreviewArticle] = useState(null); const [returnedToMe, setReturnedToMe] = useState([]); // Rejected KB articles returned to the author const [openDocActionsId, setOpenDocActionsId] = useState(null); // Which document's Actions menu is open const [actionsDropdownPosition, setActionsDropdownPosition] = useState({ top: 0, right: 0 }); const [kbAddChoiceOpen, setKbAddChoiceOpen] = useState(false); // Audit Logs state const [auditLogs, setAuditLogs] = useState([]); const [auditLogsLimit, setAuditLogsLimit] = useState(100); const [auditLogDetailView, setAuditLogDetailView] = useState(null); // log entry shown in details modal // Human-readable audit summary for table and modal (no raw JSON for normal view) const getAuditSummary = (detailsObj, log) => { if (!detailsObj || typeof detailsObj !== 'object') { if (log?.details && typeof log.details === 'string') return log.details.slice(0, 80) + (log.details.length > 80 ? '…' : ''); return 'Details recorded'; } const d = detailsObj; if (d.note && typeof d.note === 'string') { const note = d.note.trim(); const n = d.affected_users; if (n != null && n !== '') return n === 1 ? `${note} (1 user affected).` : `${note} (${n} user(s) affected).`; return note; } if (d.changes && typeof d.changes === 'object') { const count = Object.keys(d.changes).length; return count === 1 ? '1 field changed' : `${count} fields changed`; } if (d.updated_values && typeof d.updated_values === 'object') { const keys = Object.keys(d.updated_values); if (keys.length === 0) return 'No values updated'; const labels = keys.map(k => k.replace(/_/g, ' ')).join(', '); return keys.length === 1 ? `Updated: ${labels}` : `Updated: ${labels}`; } if (Array.isArray(d.updated) && d.updated.length) { const labels = d.updated.map(f => f.replace(/_/g, ' ')).join(', '); return d.updated.length === 1 ? `Updated: ${labels}` : `Updated: ${labels}`; } if (d.action === 'reorder') return 'Order of items was reordered'; if (d.title != null) { const title = String(d.title).slice(0, 50) + (String(d.title).length > 50 ? '…' : ''); if (d.restored_from != null) return `Article "${title}" restored from version ${d.restored_from}`; if (d.reason != null) return `Article "${title}" rejected: ${String(d.reason).slice(0, 60)}${String(d.reason).length > 60 ? '…' : ''}`; if (log?.action === 'approve') return `Article "${title}" approved`; if (log?.action === 'reject') return `Article "${title}" rejected`; if (log?.action === 'delete') return d.is_folder ? `Folder "${title}" deleted` : `Article "${title}" deleted`; if (log?.action === 'update') return `Article "${title}" updated`; if (log?.action === 'create') return d.is_folder ? `Folder "${title}" created` : `Article "${title}" created`; return `"${title}"`; } if (d.name != null && d.display_name != null) return log?.action === 'create' ? `Role "${d.display_name}" created` : `Role "${d.display_name}" (${d.name})`; if (d.name != null) return log?.action === 'create' ? `Created: "${String(d.name).slice(0, 40)}"` : `"${String(d.name).slice(0, 40)}"`; if (d.reason != null) return String(d.reason).slice(0, 80) + (String(d.reason).length > 80 ? '…' : ''); if (d.status != null) return `Status: ${d.status}`; if (d.code != null || d.property_code != null) return d.name != null ? `"${d.name}"` : (d.code || d.property_code || ''); return 'Details recorded'; }; useEffect(() => { loadData(); }, [activeTab]); // When arriving from AI chat with a KB draft, open the article modal with the draft and clear the pending draft useEffect(() => { if (!isActiveView || !pendingKbDraft) return; setEditingArticle({ title: pendingKbDraft.title || '', content: pendingKbDraft.content || '', parent_id: null, ...(pendingKbDraft.category && { category: pendingKbDraft.category }) }); setShowArticleModal(true); onClearPendingKbDraft?.(); }, [isActiveView, pendingKbDraft]); // Administration (Roles & Permissions, Audit) is only for users with Manage Settings. KB-only users see only Knowledge Base; they must not see Roles or Audit. const hasManageRolesOrSettings = currentUser?.can_manage_settings === true; const canRoles = hasManageRolesOrSettings; const canKb = !!(window.canViewKnowledgeBase && window.canViewKnowledgeBase(currentUser)) || !!(window.canManageKnowledgeBase && window.canManageKnowledgeBase(currentUser)) || !!(window.canApproveKnowledgeBase && window.canApproveKnowledgeBase(currentUser)) || !!(window.canSubmitKnowledgeBase && window.canSubmitKnowledgeBase(currentUser)) || !!(window.canEditKnowledgeBase && window.canEditKnowledgeBase(currentUser)); // Audit: only requires can_view_audit_logs (independent of Manage Settings). const canAudit = currentUser?.can_view_audit_logs === true; // KB action flags: only render buttons when user has the specific permission (do not load button at all without permission). const canManageKb = !!(window.canManageKnowledgeBase && window.canManageKnowledgeBase(currentUser)); const canApproveKb = !!(window.canApproveKnowledgeBase && window.canApproveKnowledgeBase(currentUser)); const canSubmitKb = !!(window.canSubmitKnowledgeBase && window.canSubmitKnowledgeBase(currentUser)); const canEditKb = !!(window.canEditKnowledgeBase && window.canEditKnowledgeBase(currentUser)); useEffect(() => { const allowed = [canRoles && 'roles', canKb && 'kb', canAudit && 'audit'].filter(Boolean); if (allowed.length && !allowed.includes(activeTab)) setActiveTab(allowed[0]); }, [canRoles, canKb, canAudit]); const hasAccessModifiers = (item) => { if (!item) return false; const arr = (v) => Array.isArray(v) && v.length > 0; return arr(item.applies_to_properties) || arr(item.allowed_users) || arr(item.allowed_roles) || arr(item.allowed_brands); }; const getAccessModifierTooltip = (item) => { if (!item) return ''; const parts = []; if (Array.isArray(item.applies_to_properties) && item.applies_to_properties.length) parts.push('Property: ' + item.applies_to_properties.join(', ')); if (Array.isArray(item.allowed_roles) && item.allowed_roles.length) parts.push('Role: ' + item.allowed_roles.length + (item.allowed_roles.length === 1 ? ' role' : ' roles')); if (Array.isArray(item.allowed_users) && item.allowed_users.length) parts.push('User: ' + item.allowed_users.join(', ')); if (Array.isArray(item.allowed_brands) && item.allowed_brands.length) parts.push('Brand: ' + item.allowed_brands.join(', ')); return parts.length ? 'Access restricted to: ' + parts.join(' • ') : ''; }; const loadData = async () => { setLoading(true); try { if (activeTab === 'roles' && canRoles) { const res = await fetch('/api/roles', { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }); if (res.ok) { const data = await res.json(); setRoles(data.roles || []); } } else if (activeTab === 'kb') { const [listRes, returnedRes] = await Promise.all([ fetch('/api/knowledge-base', { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }), fetch('/api/knowledge-base/returned-to-me', { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }) ]); if (listRes.ok) { const data = await listRes.json(); setKbItems(data.items || []); setArticles(data.items || []); } if (returnedRes.ok) { const data = await returnedRes.json(); setReturnedToMe(data.items || []); } else { setReturnedToMe([]); } } else if (activeTab === 'audit' && canAudit) { const res = await fetch(`/api/audit-logs?limit=${auditLogsLimit}`, { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }); if (res.ok) { const data = await res.json(); setAuditLogs(data.logs || []); } else { setAuditLogs([]); } } } catch (error) { console.error('Failed to load data:', error); } finally { setLoading(false); } }; // Refetch when this view becomes active; keep ref updated so interval/callbacks see latest loadData const loadDataRef = useRef(loadData); loadDataRef.current = loadData; const hasModalOpen = showRoleModal || showArticleModal || showFolderModal || showHistoryModal || !!previewArticle; const hasModalOpenRef = useRef(hasModalOpen); hasModalOpenRef.current = hasModalOpen; useEffect(() => { if (!isActiveView || hasModalOpenRef.current) return; if (typeof loadDataRef.current === 'function') loadDataRef.current(); }, [isActiveView, activeTab]); useEffect(() => { if (!isActiveView) return; const interval = setInterval(() => { if (hasModalOpenRef.current) return; if (typeof loadDataRef.current === 'function') loadDataRef.current(); }, 45000); return () => clearInterval(interval); }, [isActiveView]); const handleSaveRole = async (roleData) => { setSaving(true); try { const url = editingRole ? `/api/roles/${editingRole.id}` : '/api/roles'; const method = editingRole ? 'PUT' : 'POST'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'X-OTO-User': currentUser.Username }, body: JSON.stringify(roleData) }); const data = await res.json().catch(() => ({})); if (res.ok) { setShowRoleModal(false); setEditingRole(null); loadData(); window.otoEvents?.emit('roles-updated'); window.otoEvents?.emit('current-user-updated'); window.otoNotify?.toast('Role saved successfully!', 'success'); if (data.view_all_properties_removed && data.affected_users_count) { window.otoNotify?.toast( `View All Properties was removed. Property access was cleared for ${data.affected_users_count} user(s) with this role. They need to be assigned to properties.`, 'warning', { duration: 8000 } ); } } else { window.otoNotify?.toast(data.detail || 'Failed to save role', 'error'); } } catch (error) { window.otoNotify?.toast('Error saving role', 'error'); } finally { setSaving(false); } }; const handleDeleteRole = async (roleId) => { const ok = await window.otoNotify?.confirm({ title: 'Delete role', message: 'Are you sure you want to delete this role?', confirmLabel: 'Delete', danger: true }); if (!ok) return; try { const res = await fetch(`/api/roles/${roleId}`, { method: 'DELETE', headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }); if (res.ok) { loadData(); window.otoEvents?.emit('roles-updated'); window.otoNotify?.toast('Role deleted successfully!', 'success'); } else { const error = await res.json(); window.otoNotify?.toast(error.detail || 'Failed to delete role', 'error'); } } catch (error) { window.otoNotify?.toast('Error deleting role', 'error'); } }; const handleReorderRoles = async (roleOrders) => { try { const res = await fetch('/api/roles/reorder', { method: 'PUT', headers: { 'Content-Type': 'application/json', ...(window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username }) }, body: JSON.stringify({ role_orders: roleOrders }) }); if (res.ok) { loadData(); window.otoEvents?.emit('roles-updated'); window.otoNotify?.toast('Role order updated!', 'success'); } else { const error = await res.json(); window.otoNotify?.toast(error.detail || 'Failed to reorder roles', 'error'); } } catch (error) { window.otoNotify?.toast('Error reordering roles', 'error'); } }; const handleSaveArticle = async (articleData) => { setSaving(true); try { const url = (editingArticle && editingArticle.id) ? `/api/knowledge-base/${editingArticle.id}` : '/api/knowledge-base'; const method = (editingArticle && editingArticle.id) ? 'PUT' : 'POST'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'X-OTO-User': currentUser.Username }, body: JSON.stringify(articleData) }); if (res.ok) { const result = await res.json(); setShowArticleModal(false); setEditingArticle(null); loadData(); window.otoNotify?.toast((editingArticle && editingArticle.id) ? 'Item updated successfully!' : 'Item created successfully!', 'success'); return { success: true, data: result }; } else { const error = await res.json(); window.otoNotify?.toast(error.detail || 'Failed to save', 'error'); return { success: false }; } } catch (error) { window.otoNotify?.toast('Error saving: ' + error.message, 'error'); return { success: false }; } finally { setSaving(false); } }; const handleDeleteArticle = async (articleId) => { const ok = await window.otoNotify?.confirm({ title: 'Delete article', message: 'Are you sure you want to delete this article?', confirmLabel: 'Delete', danger: true }); if (!ok) return; try { const res = await fetch(`/api/knowledge-base/${articleId}`, { method: 'DELETE', headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }); if (res.ok) { loadData(); window.otoNotify?.toast('Article deleted successfully!', 'success'); } else { const error = await res.json(); window.otoNotify?.toast(error.detail || 'Failed to delete article', 'error'); } } catch (error) { window.otoNotify?.toast('Error deleting article', 'error'); } }; const handleApproveArticle = async (articleId) => { try { const res = await fetch(`/api/knowledge-base/${articleId}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-OTO-User': currentUser.Username } }); if (res.ok) { loadData(); window.otoNotify?.toast('Article approved successfully!', 'success'); } else { const error = await res.json(); window.otoNotify?.toast(error.detail || 'Failed to approve article', 'error'); } } catch (error) { window.otoNotify?.toast('Error approving article: ' + error.message, 'error'); } }; const handleRejectArticle = async (articleId) => { const reason = await window.otoNotify?.prompt({ title: 'Reject article', message: 'Please provide a reason for rejection:', placeholder: 'Reason...', submitLabel: 'Reject' }); if (!reason || !reason.trim()) { window.otoNotify?.toast('Rejection reason is required', 'error'); return; } try { const res = await fetch(`/api/knowledge-base/${articleId}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-OTO-User': currentUser.Username }, body: JSON.stringify({ reason: reason.trim() }) }); if (res.ok) { loadData(); window.otoNotify?.toast('Article rejected', 'success'); } else { const error = await res.json(); window.otoNotify?.toast(error.detail || 'Failed to reject article', 'error'); } } catch (error) { window.otoNotify?.toast('Error rejecting article: ' + error.message, 'error'); } }; const handleViewArticle = async (articleId) => { setOpenDocActionsId(null); try { const res = await fetch(`/api/knowledge-base/${articleId}`, { headers: window.getApiHeaders ? window.getApiHeaders(currentUser.Username) : { 'X-OTO-User': currentUser.Username } }); if (res.ok) { const data = await res.json(); const article = data.article || data; // Ensure attachments array exists (API should include it, but ensure it's always an array) if (!article.attachments) { article.attachments = []; } setPreviewArticle(article); } else { const err = await res.json(); window.otoNotify?.toast(err.detail || 'Failed to load article', 'error'); } } catch (e) { window.otoNotify?.toast('Failed to load article', 'error'); } }; const normalizeKbMetadata = (meta) => { if (!meta) return {}; if (typeof meta === 'string') { try { return JSON.parse(meta); } catch { return {}; } } if (typeof meta === 'object') return meta; return {}; }; const getKbAudience = (article) => { const m = normalizeKbMetadata(article?.metadata); const a = (m.audience || m.group || ''); if (String(a).toLowerCase() === 'oto') return 'Corporate'; return a; }; const getKbAttachments = (article) => { const m = normalizeKbMetadata(article?.metadata); return Array.isArray(m.attachments) ? m.attachments : []; }; if (loading) { return (
{isKbOnly ? 'Browse and manage knowledge base articles' : 'Roles & Permissions, Knowledge Base, and Audit Logs'}
| Order | Role | Description | Permissions | Actions |
|---|---|---|---|---|
|
|
{role.display_name}
Rank: {role.sort_order ?? 0}
|
{role.description || '—'} |
{Object.entries(role || {})
.filter(([key, value]) => key.startsWith('can_') && value === true)
.map(([key]) => (
{key.replace('can_', '').replace(/_/g, ' ')}
))
}
|
|
Database-driven folder navigation
What would you like to add?
These articles were rejected. You can edit and resubmit, or discard.
Reason: {a.rejected_reason}
}Updated {a.updated_at ? new Date(a.updated_at).toLocaleString() : ''}
Review each edit: open the article to view full content, then approve or reject the change.
{pending.length > 0 ? (📍 {a.category}
}{a.content?.substring(0, 150)}...
Created by {a.created_by} • {a.created_at ? new Date(a.created_at).toLocaleDateString() : ''} {(a.updated_by || a.updated_at) && ( <> • Last edit: {a.updated_by || '—'} • {a.updated_at ? new Date(a.updated_at).toLocaleString() : ''}> )}
🎉
All caught up!
📍 {folder.category}
)}{folder.content || 'Folder'}
📍 {doc.category}
)}{doc.content?.substring(0, 150)}...
By {doc.created_by} • {new Date(doc.created_at).toLocaleDateString()}
📂
This folder is empty
Add folders or documents to get started
System activity and change tracking
No audit logs found
Logs will appear here as system activity occurs.
| Timestamp | User | Action | Entity | Details |
|---|---|---|---|---|
| {timestamp} | {log.username || 'N/A'} | {log.action || 'N/A'} | {log.entity_type ? ( {log.entity_display_name ? (log.entity_type === 'role' || log.entity_type === 'roles' ? `Role: ${log.entity_display_name}` : `${log.entity_type}: ${log.entity_display_name}${log.entity_id ? ` (#${log.entity_id})` : ''}`) : `${log.entity_type}${log.entity_id ? ` #${log.entity_id}` : ''}`} ) : ( — )} |
{summary || —}
|
{typeof auditLogDetailView.details === 'string' ? auditLogDetailView.details : JSON.stringify(d, null, 2)}
{auditLogDetailView.details && typeof auditLogDetailView.details === 'string' ? auditLogDetailView.details : '—'}
{auditLogDetailView.details &&No field values changed (e.g. only reorder or no-op save).
) : (The following values were updated:
{summary}