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 (
); } const isKbOnly = canKb && !canRoles; return (

{isKbOnly ? 'Knowledge Base' : 'Administration'}

{isKbOnly ? 'Browse and manage knowledge base articles' : 'Roles & Permissions, Knowledge Base, and Audit Logs'}

{/* Tabs */}
{canRoles && ( )} {canKb && ( )} {canAudit && ( )}
{/* Roles Tab */} {activeTab === 'roles' && canRoles && (

Roles

{roles.filter(r => r.name !== 'administrator' && r.name !== 'administrators' && r.name !== 'system').map((role, index, filteredRoles) => { const canMoveUp = index > 0; const canMoveDown = index < filteredRoles.length - 1; const buildRoleOrdersFromList = (orderedRoles) => { const total = orderedRoles.length; // Highest (top) gets highest sort_order return orderedRoles.map((r, i) => ({ role_id: r.id, sort_order: (total - 1 - i) * 10, })); }; const handleMoveUp = async () => { if (!canMoveUp) return; const newList = [...filteredRoles]; // Swap current with previous [newList[index - 1], newList[index]] = [newList[index], newList[index - 1]]; const roleOrders = buildRoleOrdersFromList(newList); await handleReorderRoles(roleOrders); }; const handleMoveDown = async () => { if (!canMoveDown) return; const newList = [...filteredRoles]; // Swap current with next [newList[index], newList[index + 1]] = [newList[index + 1], newList[index]]; const roleOrders = buildRoleOrdersFromList(newList); await handleReorderRoles(roleOrders); }; return ( ); })}
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, ' ')} )) }
)} {/* Knowledge Base Tab — only render when user has at least one KB permission */} {activeTab === 'kb' && canKb && (

Knowledge Base

Database-driven folder navigation

{canApproveKb && !kbShowPending && ( )} {(kbPath.length > 0 || kbShowPending) && ( )} {(canManageKb || canSubmitKb) && !kbShowPending && ( <> {kbAddChoiceOpen && (
setKbAddChoiceOpen(false)}>
e.stopPropagation()}>

Add to Knowledge Base

What would you like to add?

{canManageKb && ( )} {(canManageKb || canSubmitKb) && ( )}
)} )}
{kbPath.length > 0 && !kbShowPending && (
{kbPath.map((crumb, idx) => ( ))}
)}
setKbSearch(e.target.value)} placeholder="Search..." className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent" />
{/* Rejected articles returned to the current user — only show section if user can submit/edit/manage */} {!kbShowPending && (canSubmitKb || canEditKb || canManageKb) && (returnedToMe || []).length > 0 && (

Returned to you

These articles were rejected. You can edit and resubmit, or discard.

{(returnedToMe || []).map(a => (
{a.title} {hasAccessModifiers(a) && ( 🔒 Access )}
{a.rejected_reason &&

Reason: {a.rejected_reason}

}

Updated {a.updated_at ? new Date(a.updated_at).toLocaleString() : ''}

{(canEditKb || canSubmitKb) && ( )} {(canManageKb || canEditKb || canSubmitKb) && ( )}
))}
)}
{(() => { const StatusBadge = ({ status }) => { const cfg = { active: { bg: 'bg-primary/10', text: 'text-primary', label: 'Active' }, pending_review: { bg: 'bg-primary/10', text: 'text-primary', label: 'Pending' }, rejected: { bg: 'bg-red-100', text: 'text-red-800', label: 'Rejected' } }; const s = cfg[status] || cfg.active; return {s.label}; }; const AccessModifierIndicator = ({ item, className = '' }) => { if (!hasAccessModifiers(item)) return null; const tip = getAccessModifierTooltip(item); return ( 🔒 Access ); }; if (kbShowPending) { const pending = (articles || []).filter(a => a.status === 'pending_review' && !a.is_folder); return (

Pending Verification ({pending.length})

Review each edit: open the article to view full content, then approve or reject the change.

{pending.length > 0 ? (
{pending.map(a => { const canApproveReject = canApproveKb; const isAuthor = (a.created_by || '').trim() === (currentUser?.Username || '').trim(); const canRevoke = isAuthor && (canEditKb || canSubmitKb); return (

{a.title}

{a.category &&

📍 {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() : ''} )}

{canApproveReject && ( <> )} {canRevoke && ( )}
); })}
) : (

🎉

All caught up!

)}
); } // When searching: search ALL items globally // When not searching: show only current folder items const itemsToShow = kbSearch ? (kbItems || []) // GLOBAL SEARCH - all items : (kbPath.length === 0 ? (kbItems || []).filter(item => !item.parent_id) : (kbItems || []).filter(item => item.parent_id === kbCurrentFolderId)); const filtered = kbSearch ? itemsToShow.filter(item => { const q = kbSearch.toLowerCase(); // Search title and category (path), NOT content const searchText = `${item.title} ${item.category || ''}`.toLowerCase(); return searchText.includes(q); }) : itemsToShow; const folders = filtered.filter(item => item.is_folder); const documents = filtered.filter(item => !item.is_folder); return (
{folders.length > 0 && (

Folders

{folders.map(folder => (
{/* Main folder area - clickable */}
{ // If searching, build path from category to show correct breadcrumb if (kbSearch && folder.category) { // Category is like "Corporate/CapEx" - need to build path with IDs const categoryParts = folder.category.split('/'); const newPath = []; // Build path by finding parent folders in kbItems for (let i = 0; i < categoryParts.length - 1; i++) { const partTitle = categoryParts[i]; const parentFolder = (kbItems || []).find(item => item.is_folder && item.title === partTitle ); if (parentFolder) { newPath.push({ id: parentFolder.id, title: parentFolder.title }); } } // Add current folder newPath.push({ id: folder.id, title: folder.title }); setKbPath(newPath); setKbCurrentFolderId(folder.id); setKbSearch(''); // Clear search when navigating } else { // Normal navigation - append to current path setKbPath([...kbPath, { id: folder.id, title: folder.title }]); setKbCurrentFolderId(folder.id); } }} className="cursor-pointer" >
📁

{folder.title}

{kbSearch && folder.category && (

📍 {folder.category}

)}

{folder.content || 'Folder'}

{/* Action buttons — only render when user has permission (button not in DOM otherwise) */}
{canManageKb && ( )} {canManageKb && ( )}
))}
)} {documents.length > 0 && (

Documents

{documents.map(doc => { const isAuthor = (doc.created_by || '').trim() === (currentUser?.Username || '').trim(); const canEditDoc = canEditKb || canManageKb || (isAuthor && (doc.status === 'pending_review' || doc.status === 'rejected')); const canDeleteDoc = canManageKb || (isAuthor && (doc.status === 'pending_review' || doc.status === 'rejected') && (canEditKb || canSubmitKb)); const canApproveReject = canApproveKb && doc.status === 'pending_review'; const showActions = true; // Always show Actions so View is available return (

{doc.title}

{kbSearch && doc.category && (

📍 {doc.category}

)}

{doc.content?.substring(0, 150)}...

By {doc.created_by} • {new Date(doc.created_at).toLocaleDateString()}

{showActions && (
{openDocActionsId === doc.id && ( <>
setOpenDocActionsId(null)} aria-hidden="true" />
{canApproveReject && ( <> )} { setOpenDocActionsId(null); setHistoryVersions(versions); setHistoryArticleId(articleId); setEditingArticle(article); setShowHistoryModal(true); }} /> {canEditDoc && ( )} {canDeleteDoc && ( )}
)}
)}
); })}
)} {folders.length === 0 && documents.length === 0 && (

📂

This folder is empty

Add folders or documents to get started

)}
); })()}
)}
{/* Role Modal */} {showRoleModal && ( { setShowRoleModal(false); setEditingRole(null); }} saving={saving} /> )} {/* Article Modal */} {showArticleModal && ( { setShowArticleModal(false); setEditingArticle(null); }} saving={saving} currentUser={currentUser} userProperties={userProperties} kbPath={kbPath} onOpenHistory={(articleId, versions) => { setHistoryArticleId(articleId); setHistoryVersions(versions || []); setShowHistoryModal(true); }} /> )} {/* Version History Modal */} {showHistoryModal && historyArticleId && ( { setShowHistoryModal(false); setHistoryArticleId(null); setHistoryVersions([]); }} versions={historyVersions || []} historyVersions={historyVersions || []} article={editingArticle} versioningMode={editingArticle?.versioning_mode || 'history'} currentVersionId={historyArticleId} onRestore={async (historyId) => { try { if (!historyArticleId) throw new Error("Missing article id"); const res = await fetch(`/api/knowledge-base/${historyArticleId}/restore`, { method: "POST", headers: { "Content-Type": "application/json", "X-OTO-User": localStorage.getItem('oto_username') || currentUser.Username }, body: JSON.stringify({ history_id: historyId }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || "Restore failed"); } window.otoNotify?.toast('Version restored successfully!', 'success'); setShowHistoryModal(false); setHistoryArticleId(null); setHistoryVersions([]); // Reload the data if (typeof loadData === 'function') { await loadData(); } } catch (e) { console.error('Restore error:', e); window.otoNotify?.toast(e.message || 'Restore failed', 'error'); } }} /> )} {/* Document preview modal: full article info + content */} {previewArticle && (() => { const MarkdownText = window.MarkdownText; const statusLabel = { active: 'Active', pending_review: 'Pending review', rejected: 'Rejected' }[previewArticle.status] || previewArticle.status; const statusColors = { active: 'bg-primary/10 text-primary', pending_review: 'bg-primary/10 text-primary', rejected: 'bg-red-100 text-red-800' }; const tags = Array.isArray(previewArticle.tags) ? previewArticle.tags : []; const attachments = previewArticle.attachments || []; const formatFileSize = (bytes) => { if (!bytes) return ''; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; return (
setPreviewArticle(null)}>
e.stopPropagation()}> {/* Header */}

{previewArticle.title}

{statusLabel} By {previewArticle.author_name || previewArticle.created_by} {previewArticle.created_at && ( {new Date(previewArticle.created_at).toLocaleString()} )}
{/* Content */}
{/* Metadata Section */}
{previewArticle.category && (
Category
{previewArticle.category}
)} {(previewArticle.property_name || previewArticle.property_code) && (
Property
{previewArticle.property_name || previewArticle.property_code}
)} {previewArticle.visibility && (
Visibility
{previewArticle.visibility}
)} {previewArticle.updated_by && (
Last Updated
{previewArticle.updated_by}
{previewArticle.updated_at && (
{new Date(previewArticle.updated_at).toLocaleString()}
)}
)} {previewArticle.status === 'active' && previewArticle.approved_by && (
Approved By
{previewArticle.approved_by}
{previewArticle.approved_at && (
{new Date(previewArticle.approved_at).toLocaleString()}
)}
)}
{previewArticle.status === 'rejected' && previewArticle.rejected_reason && (
Rejection Reason
{previewArticle.rejected_reason}
)}
{/* Tags */} {tags.length > 0 && (
Tags
{tags.map(t => ( {t} ))}
)} {/* Attachments */} {attachments.length > 0 && ( )} {/* Content */}
Content
{MarkdownText ? ( ) : (
{previewArticle.content || (No content)}
)}
{/* Footer */}
{(currentUser.can_approve_knowledge_base || currentUser.can_manage_knowledge_base) && previewArticle.status === 'pending_review' && ( <> )}
); })()} {/* Audit Logs Tab */} {activeTab === 'audit' && canAudit && (

Audit Logs

System activity and change tracking

{auditLogs.length === 0 ? (

No audit logs found

Logs will appear here as system activity occurs.

) : (
{auditLogs.map((log) => { const timestamp = log.timestamp ? new Date(log.timestamp).toLocaleString() : 'N/A'; const detailsObj = typeof log.details === 'string' ? (() => { try { return JSON.parse(log.details); } catch { return null; } })() : log.details; const summary = getAuditSummary(detailsObj, log); return ( ); })}
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 || }
)}
{/* Audit log full-details modal */} {auditLogDetailView && (
setAuditLogDetailView(null)} >
e.stopPropagation()} >

Audit log details

Timestamp
{auditLogDetailView.timestamp ? new Date(auditLogDetailView.timestamp).toLocaleString() : 'N/A'}
User
{auditLogDetailView.username || 'N/A'}
Action
{auditLogDetailView.action || 'N/A'}
Entity
{auditLogDetailView.entity_display_name ? (auditLogDetailView.entity_type === 'role' || auditLogDetailView.entity_type === 'roles' ? `Role: ${auditLogDetailView.entity_display_name}` : `${auditLogDetailView.entity_type}: ${auditLogDetailView.entity_display_name}${auditLogDetailView.entity_id ? ` (#${auditLogDetailView.entity_id})` : ''}`) : `${auditLogDetailView.entity_type || ''}${auditLogDetailView.entity_id ? ` #${auditLogDetailView.entity_id}` : ''}` || '—'}
{(() => { const d = typeof auditLogDetailView.details === 'string' ? (() => { try { return JSON.parse(auditLogDetailView.details); } catch { return null; } })() : auditLogDetailView.details; const RawJsonCollapsible = () => (
Technical details (raw data)
{typeof auditLogDetailView.details === 'string' ? auditLogDetailView.details : JSON.stringify(d, null, 2)}
); if (!d || typeof d !== 'object') { return (

What happened

{auditLogDetailView.details && typeof auditLogDetailView.details === 'string' ? auditLogDetailView.details : '—'}

{auditLogDetailView.details && }
); } if (d.changes && typeof d.changes === 'object') { const normalizeForCompare = (val) => { if (val === undefined || val === null) return '—'; if (Array.isArray(val)) return [...val].sort().join(','); return String(val).trim(); }; const entries = Object.entries(d.changes).filter(([field, v]) => { const isObj = v && typeof v === 'object' && !Array.isArray(v) && ('old' in v || 'new' in v); if (!isObj) return true; return normalizeForCompare(v.old) !== normalizeForCompare(v.new); }); return (

What changed

{entries.length === 0 ? (

No field values changed (e.g. only reorder or no-op save).

) : (
    {entries.map(([field, v]) => { const isObj = v && typeof v === 'object' && !Array.isArray(v) && ('old' in v || 'new' in v); const oldVal = isObj ? (v.old === undefined || v.old === null ? '—' : v.old === 'All properties' ? 'All properties' : Array.isArray(v.old) ? v.old.join(', ') : String(v.old)) : null; const newVal = isObj ? (v.new === undefined || v.new === null ? '—' : v.new === 'All properties' ? 'All properties' : Array.isArray(v.new) ? v.new.join(', ') : String(v.new)) : String(v); return (
  • {field.replace(/_/g, ' ')}: {isObj ? ( {oldVal} → {newVal} ) : ( {newVal} )}
  • ); })}
)}
); } if (d.updated_values && typeof d.updated_values === 'object') { return (

What changed

The following values were updated:

    {Object.entries(d.updated_values).map(([k, v]) => (
  • {k.replace(/_/g, ' ')}:{Array.isArray(v) ? v.join(', ') : String(v ?? '—')}
  • ))}
); } // Human-readable summary for all other shapes (note, title, name, action, etc.) const summary = getAuditSummary(d, auditLogDetailView); return (

What happened

{summary}

); })()}
)}
)}
); }; if (typeof window !== 'undefined') window.AdministrationView = AdministrationView;