// ================================================================================== // OTO HUB - Main App (OOP: components/views/modals loaded from separate files) // ================================================================================== const { useState, useEffect, useRef } = React; // Use components and utilities from loaded modules (window.*) const ErrorBoundary = window.ErrorBoundary; const TagsInput = window.TagsInput; const validateKBForm = window.validateKBForm; const VersionHistoryPanel = window.VersionHistoryPanel; const AttachmentManager = window.AttachmentManager; const ContactPrefIcons = window.ContactPrefIcons; const PropertySelector = window.PropertySelector; const MarkdownText = window.MarkdownText; const HistoryVersionsButton = window.HistoryVersionsButton; const RoleModal = window.RoleModal; const ArticleModal = window.ArticleModal; const PropertiesManagementModal = window.PropertiesManagementModal; const AIChatView = window.AIChatView; const ITSupportView = window.ITSupportView; const AccessManagementView = window.AccessManagementView; const ContactInformationView = window.ContactInformationView; const VendorsView = window.VendorsView; const VenuesView = window.VenuesView; const AdministrationView = window.AdministrationView; const formatDate = window.formatDate; const getRelativeTime = window.getRelativeTime; const formatPreferredContactMethods = window.formatPreferredContactMethods; const getStatusColor = window.getStatusColor; const sortPropertiesOTOThenAZ = window.sortPropertiesOTOThenAZ; const normalizeKbMetadata = window.normalizeKbMetadata; const getKbAudience = window.getKbAudience; const getKbAttachments = window.getKbAttachments; window.KBComponents = { TagsInput, validateKBForm, VersionHistoryPanel, AttachmentManager }; // Sidebar theme: one place to change blend, hover, and shadow. Exposed for PropertySelector (sidebar variant). const OTOSidebarTheme = { // Sidebar container – blends with gradient; no white tint sidebarRoot: 'flex flex-col w-64 flex-shrink-0 border-r border-black/10 overflow-hidden', sidebarHeader: 'px-5 pt-6 pb-4 border-b border-black/10', sidebarNavLabel: 'text-blue-200/60 text-[11px] font-semibold mb-2.5 px-2 uppercase tracking-widest', sidebarNavItem: 'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-all text-blue-100/95 hover:bg-black/15 hover:text-white', sidebarNavItemActive: 'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium transition-all bg-black/20 text-white shadow-md', sidebarLogoutFooter: 'p-3 border-t border-black/10 bg-black/5', sidebarLogoutButton: 'w-full text-left px-3 py-2.5 rounded-xl text-sm font-medium text-blue-100/90 hover:bg-black/15 hover:text-white transition-all flex items-center gap-2.5', // Property card when used in sidebar (darker hover, medium shadow, blends) propertyCard: 'bg-black/10 border border-black/20 rounded-xl p-4', propertyLabel: 'text-white/70 text-[10px] font-semibold uppercase tracking-wider', propertyBorder: 'border-black/20', propertySelect: 'w-full bg-black/15 border border-black/25 text-white rounded-lg px-3 py-2.5 pr-8 text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-black/20 cursor-pointer appearance-none transition-all hover:border-black/40', propertyButtonHover: 'hover:bg-black/15', propertyDashedBorder: 'border-2 border-dashed border-black/20 rounded-lg', }; if (typeof window !== 'undefined') window.OTOSidebarTheme = OTOSidebarTheme; // Main App Component const App = () => { const [isAuthenticated, setIsAuthenticated] = useState(false); const [activeView, setActiveView] = useState('chat'); const [showCreateTicket, setShowCreateTicket] = useState(false); const [ticketDescription, setTicketDescription] = useState(''); const [ticketDraft, setTicketDraft] = useState(null); // Store full AI draft const [createTicketOrigin, setCreateTicketOrigin] = useState('it'); const [showPropertiesModal, setShowPropertiesModal] = useState(false); const [currentUser, setCurrentUser] = useState({ FirstName: "Hotel", LastName: "Rep", Username: "hotelrep", Email: "user@company.com", Phone: "(555) 123-4567", Role: "Front Desk", properties: [] }); const [selectedProperty, setSelectedProperty] = useState(null); const [pendingKbDraft, setPendingKbDraft] = useState(null); // KB article draft from AI for user to submit const canManageProperties = window.canManageProperties ? window.canManageProperties(currentUser) : !!currentUser.can_manage_properties; const canAccessPropertyManagement = window.canAccessPropertyManagement ? window.canAccessPropertyManagement(currentUser) : (!!currentUser?.can_manage_properties || !!currentUser?.can_manage_venues); // Admin nav: show if user has Manage Settings, any KB permission, or View Audit Logs. const isAdmin = window.canAccessAdmin ? window.canAccessAdmin(currentUser) : ( (currentUser?.can_manage_settings === true) || (currentUser?.can_manage_knowledge_base === true) || (currentUser?.can_approve_knowledge_base === true) || (currentUser?.can_view_knowledge_base === true) || (currentUser?.can_submit_knowledge_base === true) || (currentUser?.can_edit_knowledge_base === true) || (currentUser?.can_view_audit_logs === true) ); const adminNavLabel = window.getAdminNavLabel ? window.getAdminNavLabel(currentUser) : ( (currentUser?.can_manage_settings === true) ? 'Administration' : ( (currentUser?.can_view_audit_logs === true && (currentUser?.can_manage_knowledge_base === true || currentUser?.can_view_knowledge_base === true)) ? 'Knowledge Base & Audit' : (currentUser?.can_manage_knowledge_base === true || currentUser?.can_view_knowledge_base === true) ? 'Knowledge Base' : (currentUser?.can_view_audit_logs === true) ? 'Audit' : 'Administration' ) ); const canAccessAI = window.canAccessAI ? window.canAccessAI(currentUser) : !!( currentUser?.can_access_general_ai || currentUser?.can_access_it_support || currentUser?.can_access_capex_support ); // Listen for login events from other tabs to prevent duplicate portal tabs useEffect(() => { const handleStorageChange = (e) => { // If another tab just logged in, close this tab if we're on the login page if (e.key === 'oto_portal_login' && e.newValue && !isAuthenticated) { try { const loginData = JSON.parse(e.newValue); // Check if this was a recent login (within last 2 seconds) if (Date.now() - loginData.timestamp < 2000) { // Another tab just logged in - close this one window.close(); } } catch (err) { // Ignore parse errors } } }; window.addEventListener('storage', handleStorageChange); return () => window.removeEventListener('storage', handleStorageChange); }, [isAuthenticated]); // Set selected property when user properties are available // Prefer OTO if available, otherwise use first property useEffect(() => { if (currentUser.properties && currentUser.properties.length > 0 && !selectedProperty) { // Sort properties: OTO first, then alphabetically const sortedProps = [...currentUser.properties].sort((a, b) => { if (a.property_code === 'OTO') return -1; if (b.property_code === 'OTO') return 1; return a.property_code.localeCompare(b.property_code); }); setSelectedProperty(sortedProps[0]); // Now this will be OTO if user has it } }, [currentUser.properties]); // AI Chat state is stored at the App level so it persists when switching views // Default greeting is marked as system/display-only so it's not sent to backend; text is permission-based (see getAIAssistantGreeting). const [chatMessages, setChatMessages] = useState([ { role: 'assistant', content: (window.getAIAssistantGreeting && window.getAIAssistantGreeting(null)) || "Hi! I'm your OTO Assistant. I can help with property information, directory lookups, knowledge base articles, and general questions about OTO-HUB.\n\nHow can I help you today?", isSystemMessage: true, } ]); const [chatAttachments, setChatAttachments] = useState([]); const [chatAllAttachments, setChatAllAttachments] = useState([]); const [ticketAttachments, setTicketAttachments] = useState([]); const handleCreateTicketFromChat = (draft, attachments = []) => { // draft contains: {subject, description, priority, category} setTicketDraft(draft); // Store the full draft setTicketDescription(draft.description || ''); setTicketAttachments(attachments); setCreateTicketOrigin('chat'); // Remember we came from AI Assistant setActiveView('it'); setShowCreateTicket(true); }; const handleBackToChat = (isSubmitted = false) => { setActiveView('chat'); setShowCreateTicket(false); setTicketDescription(''); setTicketAttachments([]); setCreateTicketOrigin('it'); if (isSubmitted) { // Clear chat for new conversation after successful submission setChatMessages([ { role: 'assistant', content: (window.getAIAssistantGreeting && window.getAIAssistantGreeting(currentUser)) || "Hi! I'm your OTO Assistant. I can help with property information, directory lookups, knowledge base articles, and general questions about OTO-HUB.\n\nHow can I help you today?", isSystemMessage: true, } ]); setChatAttachments([]); setChatAllAttachments([]); setTicketDraft(null); } // If cancelled (isSubmitted = false), keep context - don't clear anything }; const handleShowCreateForm = (show, description = '') => { if (show) { setCreateTicketOrigin('it'); // Opened from IT Support } setShowCreateTicket(show); setTicketDescription(description); }; const handleLoginSuccess = async (user) => { const username = user?.username || 'hotelrep'; let phoneNumber = ''; let preferredMethods = ['email']; try { const response = await fetch(`/api/contact/${username}`, { headers: window.getApiHeaders ? window.getApiHeaders(username) : { 'X-OTO-User': username } }); if (response.ok) { const data = await response.json(); preferredMethods = Array.isArray(data.preferred_contact_methods) && data.preferred_contact_methods.length ? data.preferred_contact_methods : ['email']; if (data.phone) { const cleaned = data.phone.replace(/\D/g, ''); if (cleaned.length === 10) { phoneNumber = `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`; } else { phoneNumber = data.phone; } } } } catch (err) { console.error('Failed to fetch contact info at login:', err); } const permissions = (window.applyPermissionsFromUser && window.applyPermissionsFromUser(user)) || (user && Object.fromEntries(Object.keys(user).filter(k => k.startsWith('can_')).map(k => [k, !!user[k]]))) || {}; setCurrentUser(prev => ({ ...prev, Email: user?.email || '', FirstName: user?.first_name || 'Hotel', LastName: user?.last_name || 'Rep', Username: username, Role: user?.role_display_name || user?.role_name || 'Front Desk', Phone: phoneNumber, PreferredContactMethods: preferredMethods, properties: user?.properties || [], ...permissions, })); setActiveView('chat'); setIsAuthenticated(true); try { localStorage.setItem('oto_username', username); } catch (e) { /* ignore */ } // Clear chat on new login setChatMessages([ { role: 'assistant', content: (window.getAIAssistantGreeting && window.getAIAssistantGreeting(user)) || "Hi! I'm your OTO Assistant. I can help with property information, directory lookups, knowledge base articles, and general questions about OTO-HUB.\n\nHow can I help you today?", isSystemMessage: true, } ]); setChatAttachments([]); setChatAllAttachments([]); }; // Refresh current user from API (role, permissions, properties). Used when role/staff updated elsewhere. const refreshCurrentUser = React.useCallback(async () => { const username = currentUser?.Username || (typeof localStorage !== 'undefined' && localStorage.getItem('oto_username')); if (!username) return; try { const meRes = await fetch('/api/auth/me', { headers: window.getApiHeaders ? window.getApiHeaders(username) : { 'X-OTO-User': username } }); if (!meRes.ok) return; const meData = await meRes.json(); const user = meData.user; if (!user) return; let phoneNumber = (currentUser && currentUser.Phone) || ''; let preferredMethods = (currentUser && currentUser.PreferredContactMethods) || ['email']; try { const contactRes = await fetch(`/api/contact/${username}`, { headers: window.getApiHeaders ? window.getApiHeaders(username) : { 'X-OTO-User': username } }); if (contactRes.ok) { const contact = await contactRes.json(); preferredMethods = Array.isArray(contact.preferred_contact_methods) && contact.preferred_contact_methods.length ? contact.preferred_contact_methods : ['email']; if (contact.phone) { const cleaned = String(contact.phone).replace(/\D/g, ''); phoneNumber = cleaned.length === 10 ? `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}` : contact.phone; } } } catch (_) { /* keep existing phone/prefs */ } const permissions = (window.applyPermissionsFromUser && window.applyPermissionsFromUser(user)) || (user && Object.fromEntries(Object.keys(user).filter(k => k.startsWith('can_')).map(k => [k, !!user[k]]))) || {}; setCurrentUser(prev => ({ ...prev, Email: user.email || prev.Email, FirstName: user.first_name || prev.FirstName, LastName: user.last_name || prev.LastName, Username: user.username || username, Role: user.role_display_name || user.role_name || prev.Role, Phone: phoneNumber, PreferredContactMethods: preferredMethods, properties: user.properties || prev.properties || [], ...permissions, })); } catch (e) { console.error('Refresh current user failed', e); } }, [currentUser?.Username, currentUser?.Phone, currentUser?.PreferredContactMethods]); // Live updates: when role/staff/permissions change elsewhere, refresh app state useEffect(() => { if (!window.otoEvents || !isAuthenticated) return; const unsub = window.otoEvents.on('current-user-updated', refreshCurrentUser); return () => { if (typeof unsub === 'function') unsub(); }; }, [isAuthenticated, refreshCurrentUser]); // When user returns to this tab (visibility), refresh current user so permission changes from another tab appear. const wasHiddenRef = React.useRef(false); useEffect(() => { if (!isAuthenticated) return; const onVisibilityChange = () => { if (document.visibilityState === 'hidden') wasHiddenRef.current = true; if (document.visibilityState === 'visible' && wasHiddenRef.current) { wasHiddenRef.current = false; refreshCurrentUser(); } }; document.addEventListener('visibilitychange', onVisibilityChange); return () => document.removeEventListener('visibilitychange', onVisibilityChange); }, [isAuthenticated, refreshCurrentUser]); // Periodic refresh of permissions so role changes (e.g. by an admin) are reflected without leaving the tab // Short interval so client UIs react quickly when their role/permissions change. const PERMISSION_REFRESH_INTERVAL_MS = 15 * 1000; useEffect(() => { if (!isAuthenticated) return; const interval = setInterval(refreshCurrentUser, PERMISSION_REFRESH_INTERVAL_MS); return () => clearInterval(interval); }, [isAuthenticated, refreshCurrentUser]); // When permissions change (e.g. after refresh or role update), force user out of any view they no longer have access to useEffect(() => { if (!isAuthenticated || !currentUser?.Username) return; if (typeof window.isViewAllowed !== 'function' || typeof window.getFirstAllowedView !== 'function') return; if (!window.isViewAllowed(activeView, currentUser)) { const first = window.getFirstAllowedView(currentUser); setActiveView(first); setShowPropertiesModal(false); if (window.otoNotify?.toast) { window.otoNotify.toast('Your permissions have changed. You were moved to a different section.', 'info', { duration: 4000 }); } } }, [isAuthenticated, currentUser, activeView]); const OTONotifyPortal = window.OTONotifyPortal; if (!isAuthenticated) { return ( <> {OTONotifyPortal && } ); } return ( <>

OTO HUB

Signed in as

{currentUser.FirstName} {currentUser.LastName}
{typeof PropertySelector === 'function' ? ( setShowPropertiesModal(true)} canManageProperties={canAccessPropertyManagement} variant="sidebar" /> ) : (
Loading...
)}

Navigation

{activeView === 'chat' && !canAccessAI && (

You do not have access to the AI Assistant. Contact your administrator for General Information AI or IT Support permissions.

)} {activeView === 'chat' && canAccessAI && ( typeof AIChatView === 'function' ? ( setTicketDraft(null)} messages={chatMessages} setMessages={setChatMessages} attachments={chatAttachments} setAttachments={setChatAttachments} allAttachments={chatAllAttachments} setAllAttachments={setChatAllAttachments} selectedProperty={selectedProperty} currentUser={currentUser} onOpenKbDraft={(draft) => { setPendingKbDraft(draft); setActiveView('admin'); }} /> ) : (
Loading AI Assistant...
) )} {activeView === 'it' && typeof ITSupportView === 'function' && } {activeView === 'access' && typeof AccessManagementView === 'function' && } {activeView === 'contact' && typeof ContactInformationView === 'function' && } {activeView === 'admin' && typeof AdministrationView === 'function' && setPendingKbDraft(null)} />}
{/* Properties Management Modal */} {typeof PropertiesManagementModal === 'function' && ( setShowPropertiesModal(false)} onDataChange={() => {}} currentUser={currentUser} selectedPropertyContext={selectedProperty} onPropertyChange={(property) => setSelectedProperty(property)} /> )} {typeof OTONotifyPortal === 'function' && }
); }; // Login Page Component const LoginPage = ({ onLoginSuccess }) => { const [step, setStep] = useState('email'); // 'email' or 'code' const [email, setEmail] = useState(''); const [code, setCode] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const [successMessage, setSuccessMessage] = useState(''); const [resendCooldown, setResendCooldown] = useState(0); // Seconds remaining until can resend const [lastResendTime, setLastResendTime] = useState(null); // Countdown timer for resend cooldown useEffect(() => { if (resendCooldown > 0) { const timer = setTimeout(() => { setResendCooldown(resendCooldown - 1); }, 1000); return () => clearTimeout(timer); } }, [resendCooldown]); // Auto-dismiss messages after 7 seconds useEffect(() => { if (error) { const timer = setTimeout(() => setError(''), 7000); return () => clearTimeout(timer); } }, [error]); useEffect(() => { if (successMessage) { const timer = setTimeout(() => setSuccessMessage(''), 7000); return () => clearTimeout(timer); } }, [successMessage]); // Check if user came from magic link (URL parameters) useEffect(() => { const params = new URLSearchParams(window.location.search); const userParam = params.get('user'); const errorParam = params.get('error'); if (errorParam && !userParam) { const map = { account_disabled: 'Your account is disabled. Please contact IT support.', account_locked: 'Your account is locked. Please contact IT support.', invalid_token: 'That login link is invalid. Please request a new code.', token_used: 'That login link has already been used. Please request a new code.', token_expired: 'That login link has expired. Please request a new code.', invalid_or_expired: 'That login link is invalid or has expired. Please request a new code.', user_not_found: 'User not found. Please request a new code.', server_error: 'An error occurred. Please try again or request a new code.' }; setError(map[errorParam] || 'Unable to sign in. Please request a new code.'); } if (userParam) { (async () => { try { const userData = JSON.parse(userParam); // Try to communicate with existing portal tabs // Set a flag in localStorage that we're logging in try { localStorage.setItem('oto_portal_login', JSON.stringify({ timestamp: Date.now(), userData: userData })); } catch (e) { console.log('LocalStorage not available'); } // User clicked magic link - log them in await onLoginSuccess( userData.email, userData.first_name, userData.last_name, userData.username, userData.properties || [], userData.role || '' ); // Clear URL parameters after successful login for clean URL window.history.replaceState({}, document.title, "/"); // Clear the login flag after a short delay setTimeout(() => { try { localStorage.removeItem('oto_portal_login'); } catch (e) { // Ignore } }, 1000); } catch (e) { console.error('Failed to parse user data from URL', e); setError('Failed to process login. Please request a new code.'); } })(); } }, []); const handleEmailSubmit = async (e) => { e.preventDefault(); setError(''); setSuccessMessage(''); if (!email) { setError('Please enter your email address'); return; } // Basic email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { setError('Please enter a valid email address'); return; } setIsLoading(true); setError(''); const showCodeStep = () => { setIsLoading(false); setStep('code'); setResendCooldown(300); setLastResendTime(Date.now()); }; // Call backend API (15s timeout so SMTP slowness doesn't hang the UI) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); try { const response = await fetch('/api/auth/request-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.toLowerCase().trim() }), signal: controller.signal, }); clearTimeout(timeoutId); let data; try { data = await response.json(); } catch (_) { // Server returned non-JSON (e.g. error page); still show code step so user can enter code from logs showCodeStep(); setSuccessMessage('Request sent. If you didn’t get the email, enter the code from server logs below.'); return; } if (!response.ok) { throw new Error(data.detail || 'Failed to send verification'); } showCodeStep(); setSuccessMessage('📧 Sending verification email...'); setTimeout(() => setSuccessMessage(`✉️ Verification code sent to ${email}`), 2000); } catch (err) { clearTimeout(timeoutId); if (err.name === 'AbortError') { // Timeout - request may have succeeded; show code step so user can enter code from logs showCodeStep(); setSuccessMessage('Request may have succeeded. Enter the code from your email or server logs below.'); } else { setIsLoading(false); setError(err.message || 'Failed to send verification. Please try again.'); } } }; const handleCodeSubmit = async (e) => { e.preventDefault(); setError(''); setSuccessMessage(''); if (!code) { setError('Please enter the verification code'); return; } if (code.length !== 6) { setError('Verification code must be 6 digits'); return; } setIsLoading(true); // Call backend API for code verification try { const response = await fetch('/api/auth/verify-code', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email: email.toLowerCase().trim(), code: code.trim() }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.detail || 'Invalid verification code'); } setIsLoading(false); setSuccessMessage('Login successful!'); // Wait a moment then log in setTimeout(async () => { await onLoginSuccess(data.user); }, 800); } catch (err) { setIsLoading(false); setError(err.message || 'Invalid verification code. Please try again.'); } }; const handleResendCode = async () => { // Check if still in cooldown if (resendCooldown > 0) { setError(`Please wait ${Math.floor(resendCooldown / 60)}:${(resendCooldown % 60).toString().padStart(2, '0')} before requesting another code`); return; } setError(''); setSuccessMessage(''); setIsLoading(true); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); try { const response = await fetch('/api/auth/request-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.toLowerCase().trim() }), signal: controller.signal, }); clearTimeout(timeoutId); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(data.detail || 'Failed to resend code'); } setIsLoading(false); setSuccessMessage('📧 Sending new verification code...'); setTimeout(() => setSuccessMessage('✉️ New verification code has been sent to your email'), 2000); setResendCooldown(300); setLastResendTime(Date.now()); } catch (err) { clearTimeout(timeoutId); setIsLoading(false); setError(err.name === 'AbortError' ? 'Request timed out. You can still enter the code from server logs.' : (err.message || 'Failed to resend code. Please try again.')); } }; const handleBackToEmail = () => { setStep('email'); setCode(''); setError(''); setSuccessMessage(''); }; return (
{/* Header */}

OTO HUB

{/* Login Card */}
{step === 'email' ? ( <>

Sign In

setEmail(e.target.value)} placeholder="Enter your work email" className="w-full border-2 border-slate-200 rounded-xl px-5 py-4 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-primary focus:ring-4 focus:ring-primary/10 transition-all text-base" disabled={isLoading} autoFocus />
{error && (
{error}
)} {successMessage && (
{successMessage}
)}
) : ( <>

Enter Code

Sent to {email}

If you didn’t receive the email, the code may appear in server logs (e.g. Render logs).

setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="000000" className="w-full border-2 border-slate-200 rounded-xl px-5 py-5 text-center text-3xl font-mono tracking-[0.5em] text-slate-800 focus:outline-none focus:border-primary focus:ring-4 focus:ring-primary/10 transition-all" disabled={isLoading} maxLength="6" autoComplete="off" autoFocus />
{error && (
{error}
)} {successMessage && (
{successMessage}
)}
)}
{/* Footer */}

OTO Development - OTO HUB

Can't login? Alternatively, you can contact
OTO IT Support at ITSupport@otodevelopment.com

); }; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( );