const AccessManagementView = ({ currentUser, selectedProperty, isActiveView }) => { const { useState, useEffect, useRef, useMemo } = React; const [users, setUsers] = useState([]); const [propertyOptions, setPropertyOptions] = useState([]); const [roleOptions, setRoleOptions] = useState([]); const [roleObjectsMap, setRoleObjectsMap] = useState({}); // NEW: Map of display_name -> full role object const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [selectedUser, setSelectedUser] = useState(null); const [showUserForm, setShowUserForm] = useState(false); const [searchText, setSearchText] = useState(''); const [statusFilter, setStatusFilter] = useState('Active'); const [includeOTOContacts, setIncludeOTOContacts] = useState(false); // NEW: Toggle for OTO contacts (default OFF) const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [phone, setPhone] = useState(''); const [role, setRole] = useState('Front Desk'); const [roleCanViewAllProperties, setRoleCanViewAllProperties] = useState(false); const [properties, setProperties] = useState([]); const [propertySearch, setPropertySearch] = useState(''); const [preferredContactMethods, setPreferredContactMethods] = useState(['email']); const [tempPassword, setTempPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const [editingUsername, setEditingUsername] = useState(null); const currentUsername = (currentUser?.Username || localStorage.getItem('oto_username') || '').trim(); const selectedPropertyCode = (selectedProperty?.property_code || '').trim(); // View disabled users: requires explicit can_view_disabled_users permission const canViewDisabledUsers = !!currentUser?.can_view_disabled_users; const myPropertyCodes = new Set((currentUser?.properties || []).map(p => p.property_code).filter(Boolean)); const canManageProperty = (code) => myPropertyCodes.has(code); const normalizeRole = (r) => String(r || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); // Role hierarchy map: role display_name -> sort_order (from database) const [roleSortOrderMap, setRoleSortOrderMap] = useState({}); const getRoleLevel = (roleName) => { // Try to get sort_order from the map first (from database) if (roleName && roleSortOrderMap[roleName] !== undefined) { return roleSortOrderMap[roleName] || 0; } // Fallback to 0 if role not found return 0; }; const currentUserRoleLevel = getRoleLevel(currentUser?.Role || ''); // Permissions: require can_manage_users (not just can_view_directory) const canManageUsers = !!(currentUser?.can_manage_users); const canCreateUsers = canManageUsers; const canSeeUsersWithoutPropertyAccess = canManageUsers; // derived from Manage Users (no separate UI permission) const canEditUsers = canManageUsers; const canResetPasswords = canManageUsers; const canToggleStatus = canManageUsers; // Can manage users: require can_manage_users permission AND target role must be strictly lower rank. // Users can only modify users of a lower role and cannot assign roles higher than their own. const canManageRole = (targetRole) => { if (!canManageUsers) { return false; } const targetLevel = getRoleLevel(targetRole); return currentUserRoleLevel > targetLevel; }; const isCorporateRole = ['corporateit', 'corporate', 'administrator', 'administrators'].includes(normalizeRole(role)); const isSystemRole = normalizeRole(role) === 'system'; const formatPhoneForDisplay = (digits) => { if (!digits) return ''; const cleaned = String(digits).replace(/\D/g, ''); if (cleaned.length !== 10) return String(digits); return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6, 10)}`; }; const normalizeStatus = (row) => { if (row.normalized_status) return row.normalized_status; if (row.status) return row.status; if (row.is_active === false) return 'Disabled'; return 'Active'; }; const mapStaffToUser = (staffRow, idx) => ({ UserID: staffRow.id ?? staffRow.user_id ?? (idx + 1), FirstName: staffRow.first_name ?? '', LastName: staffRow.last_name ?? '', Username: staffRow.username ?? '', Email: staffRow.email ?? '', Phone: formatPhoneForDisplay(staffRow.phone ?? ''), // Use role_display_name or role_name from backend so we can reliably look up role permissions. Role: staffRow.role_display_name ?? staffRow.role_name ?? staffRow.role ?? staffRow.department ?? 'Front Desk', Status: normalizeStatus(staffRow), Created: staffRow.created_at ?? staffRow.created ?? staffRow.created_on ?? '', LastLogin: staffRow.last_login ?? staffRow.last_login_at ?? '', PropertyCodes: staffRow.property_codes || [], PreferredContactMethods: staffRow.preferred_contact_methods ?? staffRow.preferredContactMethods ?? ['email'], // From backend: role's can_be_global_directory_contact (for OTO contacts toggle) can_be_global_directory_contact: !!staffRow.can_be_global_directory_contact, }); const baseRoleOptions = (roleOptions && roleOptions.length) ? roleOptions : ['Administrator', 'Corporate IT', 'Corporate', 'Property Management', 'Front Desk', 'Food & Beverage', 'Sales', 'Engineering']; const roleOptionsWithCurrent = baseRoleOptions.includes(role) ? baseRoleOptions : [role, ...baseRoleOptions]; const loadData = async () => { try { setIsLoading(true); setLoadError(''); const staffParams = new URLSearchParams(); // When filtering by \"Unassigned\", ignore selectedPropertyCode so we can see global no-property users. if (selectedPropertyCode && statusFilter !== 'Unassigned') { staffParams.set('property_code', selectedPropertyCode); } if (currentUsername) staffParams.set('exclude_username', currentUsername); // Request disabled/all users when user has permission and selected that filter if (canViewDisabledUsers && statusFilter === 'Disabled') { staffParams.set('status', 'Disabled'); } else if (canViewDisabledUsers && statusFilter === 'All Users') { // no status = backend returns both Active and Disabled } else { staffParams.set('status', 'Active'); } const staffUrl = staffParams.toString() ? `/api/staff?${staffParams.toString()}` : '/api/staff'; const [staffRes, propsRes] = await Promise.all([ fetch(staffUrl, { headers: window.getApiHeaders ? window.getApiHeaders(currentUsername) : undefined }), fetch('/api/properties', { headers: window.getApiHeaders ? window.getApiHeaders(currentUsername) : undefined }) ]); // Roles/statuses (best-effort; endpoint may be absent in older backends) try { const rolesRes = await fetch('/api/roles', { headers: window.getApiHeaders ? window.getApiHeaders(currentUsername) : undefined }); if (rolesRes.ok) { const rolesData = await rolesRes.json(); if (Array.isArray(rolesData.roles)) { // Build sort_order map: display_name -> sort_order const sortOrderMap = {}; rolesData.roles.forEach(r => { const displayName = r.display_name || r.name; sortOrderMap[displayName] = r.sort_order !== undefined ? r.sort_order : 0; }); setRoleSortOrderMap(sortOrderMap); // Check if current user is admin (using sort_order >= 90 as threshold, or fallback to role name) const currentUserRoleName = currentUser?.Role || ''; const currentUserSortOrder = sortOrderMap[currentUserRoleName] || 0; const isCurrentUserAdmin = currentUserSortOrder >= 90 || ['administrator', 'administrators', 'system'].includes(normalizeRole(currentUserRoleName)); const filteredRoles = rolesData.roles.filter(r => { if (r.name === 'system') return false; if (r.name === 'administrator') return isCurrentUserAdmin; return true; }); // Create array of display names for dropdown const roleNames = filteredRoles.map(r => r.display_name || r.name); setRoleOptions(roleNames); // Create map of display_name -> full role object for permission lookups const rolesMap = {}; filteredRoles.forEach(r => { const displayName = r.display_name || r.name; rolesMap[displayName] = r; }); setRoleObjectsMap(rolesMap); } } } catch (e) { setRoleOptions([]); setRoleObjectsMap({}); setRoleSortOrderMap({}); } const staffData = await staffRes.json(); const propsData = await propsRes.json(); const staffRows = (staffData.users || []).map(mapStaffToUser); // OTO troubleshooting: log every user's OTO-relevant fields (so we can see can_be_global_directory_contact + PropertyCodes) try { const otoRelevant = staffRows.map(u => ({ Username: u.Username, Role: u.Role, PropertyCodes: u.PropertyCodes, can_be_global_directory_contact: u.can_be_global_directory_contact, })); console.log('[OTO DEBUG] Directory loadData – staff with OTO fields', { totalStaff: staffRows.length, requestedPropertyCode: selectedPropertyCode, users: otoRelevant, }); } catch (e) { console.warn('[OTO DEBUG] loadData log failed', e); } setUsers(staffRows); // Selection is synced to first *filtered* user in an effect below (so it matches the visible list) // propsData is already an array, not wrapped in {properties: [...]} const propsArray = Array.isArray(propsData) ? propsData : (propsData.properties || []); const props = propsArray .map(p => ({ code: p.property_code || p.code, name: p.display_name || p.name || p.property_name || p.full_name || p.code, address: p.address, city: p.city, state: p.state, zip: p.zip, his: p.his })) .filter(p => !!p.code) // Sort alphabetically by property name for easier scanning .sort((a, b) => a.name.localeCompare(b.name)); setPropertyOptions(props); } catch (e) { console.error('Failed to load staff/properties', e); setLoadError('Failed to load staff users. Please refresh the page.'); setUsers([]); setPropertyOptions([]); setSelectedUser(null); } finally { setIsLoading(false); } }; useEffect(() => { loadData(); }, [selectedPropertyCode, currentUsername, statusFilter]); // Refetch when this view becomes active (e.g. user clicked Directory) so data is always fresh const loadDataRef = useRef(loadData); loadDataRef.current = loadData; const hasModalOpen = showUserForm || showPassword; const hasModalOpenRef = useRef(hasModalOpen); hasModalOpenRef.current = hasModalOpen; useEffect(() => { if (!isActiveView || hasModalOpenRef.current) return; if (typeof loadDataRef.current === 'function') loadDataRef.current(); }, [isActiveView]); // Poll while view is visible so updates from other tabs/sessions appear without a click (skip when a popup is open) useEffect(() => { if (!isActiveView) return; const interval = setInterval(() => { if (hasModalOpenRef.current) return; if (typeof loadDataRef.current === 'function') loadDataRef.current(); }, 45000); return () => clearInterval(interval); }, [isActiveView]); // Live update: when roles change in Administration, refetch roles and staff (skip when a popup is open) useEffect(() => { if (!window.otoEvents) return; const unsub = window.otoEvents.on('roles-updated', () => { if (hasModalOpenRef.current) return; if (typeof loadDataRef.current === 'function') loadDataRef.current(); }); return () => { if (typeof unsub === 'function') unsub(); }; }, []); useEffect(() => { if (roleOptions.length > 0) { // Only set role if current role is not in the options const roleIsValid = roleOptions.some(opt => typeof opt === 'string' && typeof role === 'string' && opt.toLowerCase() === role.toLowerCase() ); if (!roleIsValid) { setRole(roleOptions[0]); } } }, [roleOptions]); // Update roleCanViewAllProperties whenever role changes useEffect(() => { if (role && roleObjectsMap[role]) { setRoleCanViewAllProperties(!!roleObjectsMap[role].can_view_all_properties); } else { setRoleCanViewAllProperties(false); } }, [role, roleObjectsMap]); // Roles with can_view_all_properties automatically have access to ALL properties. useEffect(() => { if (roleCanViewAllProperties && propertyOptions.length) { const all = propertyOptions.map(p => p.code).filter(Boolean); setProperties(all); } }, [roleCanViewAllProperties, propertyOptions.length]); // Role changes should only clear property selections when the user changes the role dropdown. const handleRoleChange = (e) => { const newRole = e.target.value; const currentNorm = normalizeRole(role); const newNorm = normalizeRole(newRole); // If role didn't actually change, do nothing if (newNorm === currentNorm) return; setRole(newRole); // Look up the role object from our map to get its permissions const selectedRoleObj = roleObjectsMap[newRole]; if (selectedRoleObj) { setRoleCanViewAllProperties(!!selectedRoleObj.can_view_all_properties); } else { // Fallback for roles not in map (shouldn't happen, but be safe) setRoleCanViewAllProperties(false); } const newRoleObj = roleObjectsMap[newRole]; const newCanViewAll = !!(newRoleObj && newRoleObj.can_view_all_properties); // Switching into a role without can_view_all_properties should clear selected properties // (roles with can_view_all_properties will auto-get all properties via the effect above) if (!newCanViewAll) { setProperties([]); } }; const toggleProperty = (propertyCode) => { if (roleCanViewAllProperties) return; if (!canManageProperty(propertyCode)) return; setProperties(prev => prev.includes(propertyCode) ? prev.filter(code => code !== propertyCode) : [...prev, propertyCode] ); }; // When user cannot view disabled users, only "Active" is allowed; keep filter in sync useEffect(() => { if (!canViewDisabledUsers && statusFilter !== 'Active') setStatusFilter('Active'); }, [canViewDisabledUsers, statusFilter]); // Keep selection in sync with the visible (filtered) list: when property/filters change, select first visible user if current selection is not in the list useEffect(() => { if (showUserForm) return; if (filteredUsers.length === 0) { setSelectedUser(null); return; } const selectedInList = selectedUser && filteredUsers.some(u => (u.Username || u.UserID) === (selectedUser.Username || selectedUser.UserID)); if (!selectedInList) { setSelectedUser(filteredUsers[0]); } }, [filteredUsers, selectedPropertyCode, showUserForm]); const formatPhoneNumber = (value) => { const digits = value.replace(/\D/g, ''); if (digits.length === 0) return ''; if (digits.length <= 3) return `(${digits}`; if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; }; const generateTempPassword = () => { const date = new Date(); const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const year = String(date.getFullYear()).slice(-2); const random = Math.floor(Math.random() * 90) + 10; return `${dayName}${month}${day}${year}!${random}`; }; useEffect(() => { if (showUserForm && !tempPassword) { setTempPassword(generateTempPassword()); } }, [showUserForm]); const handlePhoneChange = (e) => { const formatted = formatPhoneNumber(e.target.value); setPhone(formatted); }; const resetUserForm = () => { setFirstName(''); setLastName(''); setUsername(''); setEmail(''); setPhone(''); setRole((roleOptions && roleOptions.length) ? roleOptions[0] : 'Front Desk'); setProperties([]); setPropertySearch(''); setPreferredContactMethods(['email']); setTempPassword(''); setShowPassword(false); setEditingUsername(null); }; const handleCreateUser = async () => { if (!firstName || !lastName || !username || !email) { window.otoNotify?.toast('Please fill in all required fields', 'error'); return; } if (!roleCanViewAllProperties && properties.length === 0) { window.otoNotify?.toast('Please select at least one property', 'error'); return; } if (preferredContactMethods.length === 0) { window.otoNotify?.toast('Please select at least one contact preference', 'error'); return; } const phoneDigits = phone ? phone.replace(/\D/g, '') : ''; const nextPropertyCodes = roleCanViewAllProperties ? propertyOptions.map(p => p.code).filter(Boolean) : properties; const payload = { username: editingUsername || username, first_name: firstName, last_name: lastName, email, phone: phoneDigits, role, status: editingUsername ? undefined : 'Active', property_codes: nextPropertyCodes, preferred_contact_methods: preferredContactMethods }; // When editing, skip API call if nothing changed if (editingUsername && selectedUser) { const currentPhone = (selectedUser.Phone || '').replace(/\D/g, ''); const currentProps = [...(selectedUser.PropertyCodes || [])].sort(); const nextProps = [...nextPropertyCodes].sort(); const same = (selectedUser.FirstName || '') === (firstName || '') && (selectedUser.LastName || '') === (lastName || '') && (selectedUser.Email || '') === (email || '') && currentPhone === phoneDigits && (selectedUser.Role || '') === (role || '') && currentProps.length === nextProps.length && currentProps.every((c, i) => c === nextProps[i]) && (selectedUser.PreferredContactMethods || []).length === preferredContactMethods.length && [...(selectedUser.PreferredContactMethods || [])].sort().join(',') === [...preferredContactMethods].sort().join(','); if (same) { setShowUserForm(false); resetUserForm(); window.otoNotify?.toast('No changes to save', 'info'); return; } } try { const url = editingUsername ? `/api/staff/${encodeURIComponent(editingUsername)}` : '/api/staff'; const method = editingUsername ? 'PUT' : 'POST'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json', ...(window.getApiHeaders ? window.getApiHeaders(currentUsername) : {}) }, body: JSON.stringify(payload) }); const data = await res.json(); if (!res.ok) { throw new Error(data.detail || 'Failed to save user'); } const savedUser = mapStaffToUser(data.user, 0); // Refresh list for accuracy (status/created/last_login etc) await loadData(); setSelectedUser(savedUser); window.otoEvents?.emit('staff-updated'); if ((savedUser?.Username || editingUsername || username) === currentUsername) { window.otoEvents?.emit('current-user-updated'); } if (!editingUsername) { // Only show temp password for domain-attached roles (not Corporate IT/Corporate/Administrator) const createdRoleToken = normalizeRole(role); const isDomainRole = !['corporateit', 'corporate', 'administrator', 'administrators'].includes(createdRoleToken); if (isDomainRole) { setShowPassword(true); } else { // For Corporate IT/Corporate/Admin, just close form and show the created user setShowUserForm(false); resetUserForm(); } } else { setShowUserForm(false); resetUserForm(); } } catch (e) { window.otoNotify?.toast(e.message || 'Failed to save user', 'error'); } }; const handleResetPassword = async () => { if (!selectedUser) return; try { const res = await fetch(`/api/staff/${encodeURIComponent(selectedUser.Username)}/send-login`, { method: 'POST', headers: window.getApiHeaders ? window.getApiHeaders(currentUsername) : undefined }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Failed to send login link'); window.otoNotify?.toast('Login link and verification code sent to the user\'s email.', 'success'); } catch (e) { window.otoNotify?.toast(e.message || 'Failed to send login link', 'error'); } }; const handleToggleStatus = async () => { if (!selectedUser) return; try { const res = await fetch(`/api/staff/${encodeURIComponent(selectedUser.Username)}/toggle`, { method: 'POST', headers: window.getApiHeaders ? window.getApiHeaders(currentUsername) : undefined }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Failed to toggle status'); const updated = mapStaffToUser(data.user, 0); setUsers(prev => prev.map(u => (u.Username === updated.Username ? updated : u))); setSelectedUser(updated); window.otoEvents?.emit('staff-updated'); if (selectedUser?.Username === currentUsername) { window.otoEvents?.emit('current-user-updated'); } } catch (e) { window.otoNotify?.toast(e.message || 'Failed to toggle status', 'error'); } }; const handleEditUser = () => { if (!selectedUser) return; setEditingUsername(selectedUser.Username); setFirstName(selectedUser.FirstName || ''); setLastName(selectedUser.LastName || ''); setUsername(selectedUser.Username || ''); setEmail(selectedUser.Email || ''); setPhone(selectedUser.Phone || ''); setRole( selectedUser.Role || ((roleOptions && roleOptions.length) ? roleOptions[0] : 'Front Desk') ); setProperties(selectedUser.PropertyCodes || []); setPropertySearch(''); setPreferredContactMethods(selectedUser.PreferredContactMethods || ['email']); setTempPassword(''); setShowPassword(false); setShowUserForm(true); }; const closePasswordDisplay = () => { setShowPassword(false); setShowUserForm(false); resetUserForm(); }; // Helper: is this user a global directory contact based at OTO? // Prefer backend flag (can_be_global_directory_contact) when present; fallback to roleObjectsMap by Role name. const isGlobalDirectoryContact = (user) => { const hasFlag = user && ( user.can_be_global_directory_contact === true || !!(roleObjectsMap[user.Role || ''] && roleObjectsMap[user.Role || ''].can_be_global_directory_contact) ); const codes = Array.isArray(user.PropertyCodes) ? user.PropertyCodes : []; const hasOTO = codes.includes('OTO'); return !!hasFlag && hasOTO; }; const filteredUsers = useMemo(() => users.filter(user => { const uLower = (user.Username || '').toLowerCase(); if (uLower === 'system') return false; if (currentUsername && uLower === currentUsername.toLowerCase()) return false; // Status filter (special case: "Unassigned" for no property access) const userStatus = user.Status; const hasAnyProperty = Array.isArray(user.PropertyCodes) && user.PropertyCodes.length > 0; let matchesStatus = true; if (statusFilter === 'Unassigned') { // Only Active users with NO property assignments matchesStatus = userStatus === 'Active' && !hasAnyProperty; } else if (statusFilter === 'All Users') { matchesStatus = true; } else { matchesStatus = userStatus === statusFilter; } // Search filter const matchesSearch = searchText === '' || user.FirstName.toLowerCase().includes(searchText.toLowerCase()) || user.LastName.toLowerCase().includes(searchText.toLowerCase()) || user.Email.toLowerCase().includes(searchText.toLowerCase()) || user.Username.toLowerCase().includes(searchText.toLowerCase()); // Property filter - Use selected property from sidebar (ignored when filtering by "Unassigned") let matchesProperty = true; if (selectedPropertyCode && statusFilter !== 'Unassigned') { const userPropertyCodes = Array.isArray(user.PropertyCodes) ? user.PropertyCodes : []; const isOtoContact = isGlobalDirectoryContact(user); const roleLower = normalizeRole(user.Role || ''); const isCorporateUser = ['administrator', 'corporateit', 'corporate'].includes(roleLower); if (selectedPropertyCode === 'OTO') { // Viewing OTO "property": // - Show any user explicitly assigned to OTO. // - Also show global OTO directory contacts (Corporate IT/Admin with the flag). matchesProperty = userPropertyCodes.includes('OTO') || isOtoContact; } else if (isCorporateUser) { // Viewing a hotel property: // - Corporate users should NEVER appear as "hotel staff". // - They only appear when they are global OTO contacts AND the toggle is ON. matchesProperty = includeOTOContacts && isOtoContact; } else { // Hotel roles: // - Always include users assigned to this hotel. // - Global OTO contacts do NOT affect hotel roles; they remain in the hotel list only if assigned. matchesProperty = userPropertyCodes.includes(selectedPropertyCode); } } return matchesStatus && matchesSearch && matchesProperty; }), [users, statusFilter, searchText, includeOTOContacts, selectedPropertyCode, currentUsername, roleObjectsMap]); // Users with no property access (PropertyCodes empty). Surface to admins/user managers so they can assign or disable. const usersWithNoPropertyAccess = useMemo( () => users.filter(u => // Only care about Active users for this alert/report u.Status === 'Active' && (!Array.isArray(u.PropertyCodes) || u.PropertyCodes.length === 0) ), [users] ); const getStatusBadgeColor = (status) => { switch (status) { case 'Active': return 'bg-primary/10 text-primary border border-primary/20'; case 'Disabled': return 'bg-primary/10 text-primary/80 border border-primary/20'; case 'Locked': return 'bg-red-100 text-red-800 border border-red-200'; default: return 'bg-primary/10 text-primary border border-primary/20'; } }; // Contact preference icons (Email / Cell / Text) // - "card" variant: fixed-width vertical stack aligned on the dot (great for list cards) // - "center" variant: centered vertical stack (great for selected/detail view) const ContactPrefIcons = ({ methods, variant = 'card' }) => { const prefs = Array.isArray(methods) ? methods : []; const isOn = (key) => prefs.includes(key); const containerClass = variant === 'center' ? 'flex flex-col items-center gap-2' : 'flex flex-col items-start gap-1.5 w-24'; const Item = ({ enabled, label, svg }) => (
Select at least one contact method
)}Select at least one property
)}First-Time Login Password (user must change on first login):
This password will only be shown once. Please save it securely.
@{selectedUser.Username}