Add user profile popover and logout icon; refactor dashboard navigation
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit

This commit is contained in:
Tom Butcher 2026-03-07 19:22:21 +00:00
parent fd968bb2b5
commit 775393dfd1
7 changed files with 322 additions and 49 deletions

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.905936,0,0,0.905936,8.091803,1)">
<path d="M0,64.406C0,66.625 1.812,68.438 4.031,68.438C6.25,68.438 8.062,66.625 8.062,64.406L8.062,9.844C8.062,8.75 8.75,8.062 9.797,8.062L42.984,8.062C44.031,8.062 44.719,8.75 44.719,9.844L44.719,64.406C44.719,66.625 46.531,68.438 48.766,68.438C50.984,68.438 52.781,66.625 52.781,64.406L52.781,8.828C52.781,3.391 49.391,0 43.875,0L8.922,0C3.406,0 0,3.391 0,8.828L0,64.406Z" style="fill-rule:nonzero;"/>
<path d="M11.891,65.75C11.891,66.547 12.5,66.938 13.359,66.578L22.859,62.547C23.672,62.203 24.031,61.828 24.031,61.062L24.031,17.438C24.031,16.672 23.672,16.281 22.875,15.969L13.359,11.906C12.5,11.562 11.891,11.953 11.891,12.766L11.891,65.75Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -5,18 +5,14 @@ import {
Flex, Flex,
Tag, Tag,
Space, Space,
Dropdown,
Button, Button,
Tooltip, Tooltip,
Badge, Badge,
Divider, Divider,
Typography Typography,
Popover
} from 'antd' } from 'antd'
import { import { LoadingOutlined } from '@ant-design/icons'
LogoutOutlined,
MailOutlined,
LoadingOutlined
} from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
import { SpotlightContext } from '../context/SpotlightContext' import { SpotlightContext } from '../context/SpotlightContext'
import { ApiServerContext } from '../context/ApiServerContext' import { ApiServerContext } from '../context/ApiServerContext'
@ -25,6 +21,7 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { Header } from 'antd/es/layout/layout' import { Header } from 'antd/es/layout/layout'
import { useMediaQuery } from 'react-responsive' import { useMediaQuery } from 'react-responsive'
import KeyboardShortcut from './KeyboardShortcut' import KeyboardShortcut from './KeyboardShortcut'
import UserProfilePopover from './UserProfilePopover'
import FarmControlLogo from '../../Logos/FarmControlLogo' import FarmControlLogo from '../../Logos/FarmControlLogo'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall' import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
@ -45,7 +42,7 @@ import DashboardWindowButtons from './DashboardWindowButtons'
const { Text } = Typography const { Text } = Typography
const DashboardNavigation = () => { const DashboardNavigation = () => {
const { logout, userProfile } = useContext(AuthContext) const { userProfile } = useContext(AuthContext)
const { showSpotlight } = useContext(SpotlightContext) const { showSpotlight } = useContext(SpotlightContext)
const { connecting, connected } = useContext(ApiServerContext) const { connecting, connected } = useContext(ApiServerContext)
const { toggleNotificationCenter, unreadCount } = const { toggleNotificationCenter, unreadCount } =
@ -93,34 +90,11 @@ const DashboardNavigation = () => {
[] []
) )
const userMenuItems = { const [userPopoverOpen, setUserPopoverOpen] = useState(false)
items: [
{ const userPopoverContent = (
key: 'username', <UserProfilePopover onClose={() => setUserPopoverOpen(false)} />
label: userProfile?.username, )
icon: <PersonIcon />,
disabled: true
},
{
key: 'email',
label: userProfile?.email,
icon: <MailOutlined />,
disabled: true
},
{
key: 'logout',
label: 'Logout',
icon: <LogoutOutlined />
}
],
onClick: (key) => {
if (key === 'profile') {
navigate('/profile')
} else if (key === 'logout') {
logout()
}
}
}
useEffect(() => { useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean) const pathParts = location.pathname.split('/').filter(Boolean)
@ -322,11 +296,18 @@ const DashboardNavigation = () => {
)} )}
{userProfile ? ( {userProfile ? (
<Space> <Space>
<Dropdown menu={userMenuItems} placement='bottomRight'> <Popover
content={userPopoverContent}
placement='bottomRight'
trigger='hover'
open={userPopoverOpen}
onOpenChange={setUserPopoverOpen}
arrow={false}
>
<Tag style={{ marginRight: 0 }} icon={<PersonIcon />}> <Tag style={{ marginRight: 0 }} icon={<PersonIcon />}>
{!isMobile && (userProfile?.name || userProfile.username)} {!isMobile && (userProfile?.name || userProfile.username)}
</Tag> </Tag>
</Dropdown> </Popover>
</Space> </Space>
) : null} ) : null}
</Flex> </Flex>

View File

@ -0,0 +1,158 @@
import PropTypes from 'prop-types'
import { createElement } from 'react'
import { Flex, Typography, Button, Space, Dropdown, Divider } from 'antd'
import { UserOutlined } from '@ant-design/icons'
import { useContext } from 'react'
import { useNavigate } from 'react-router-dom'
import LogoutIcon from '../../Icons/LogoutIcon'
import { User } from '../../../database/models/User'
import { AuthContext } from '../context/AuthContext'
const { Text } = Typography
const ICON_ACTION_NAMES = ['info', 'edit']
const UserProfilePopover = ({ onClose }) => {
const { userProfile, profileImageUrl, logout } = useContext(AuthContext)
const navigate = useNavigate()
const modelActions = User.actions || []
const iconActions = modelActions.filter((a) => ICON_ACTION_NAMES.includes(a.name))
const dropdownActions = modelActions.filter(
(a) => !ICON_ACTION_NAMES.includes(a.name)
)
const objectData = { ...userProfile, _user: userProfile }
const runAction = (action) => {
if (action.name === 'logout') {
logout()
} else if (action.url && userProfile?._id) {
const url = action.url(userProfile._id)
navigate(url)
}
onClose?.()
}
const isActionDisabled = (action) => {
if (action.disabled && typeof action.disabled === 'function') {
return action.disabled(objectData)
}
return false
}
const username = userProfile?.username
const fullName = [userProfile?.firstName, userProfile?.lastName]
.filter(Boolean)
.join(' ')
const email = userProfile?.email
const dropdownItems = [
...dropdownActions.map((action) => ({
key: action.name,
label: action.label,
icon: action.icon ? createElement(action.icon) : undefined,
disabled: isActionDisabled(action),
onClick: () => !isActionDisabled(action) && runAction(action)
})),
{ type: 'divider' },
{
key: 'logout',
label: 'Logout',
icon: createElement(LogoutIcon),
onClick: () => runAction({ name: 'logout' })
}
]
const actionButton =
dropdownItems.length > 1 ? (
<Dropdown
menu={{ items: dropdownItems }}
trigger={['hover']}
placement='bottomLeft'
>
<Button type='text' size='small' aria-label='Actions'>
Actions
</Button>
</Dropdown>
) : null
return (
<Flex
align='center'
gap='middle'
style={{ minWidth: 240, padding: '0 1px' }}
>
<Flex align='center' gap='12px' style={{ flex: 1, minWidth: 0 }}>
{profileImageUrl ? (
<img
src={profileImageUrl}
alt=''
style={{
width: 76,
height: 76,
borderRadius: '5px',
objectFit: 'cover'
}}
/>
) : (
<UserOutlined
style={{ fontSize: 48, color: 'var(--color-text-secondary)' }}
/>
)}
<Flex vertical style={{ minWidth: 0 }}>
{fullName && (
<Text
strong
ellipsis
style={{ fontSize: 18, lineHeight: 1, marginTop: 1 }}
>
{fullName}
</Text>
)}
{email && (
<Space>
<Text
type='secondary'
strong
style={{ fontSize: 12, lineHeight: 1 }}
>
@{username}
</Text>
<Text
type='secondary'
ellipsis
style={{ fontSize: 12, lineHeight: 1 }}
>
{email}
</Text>
</Space>
)}
{!username && !fullName && !email && (
<Text type='secondary'>Unknown user</Text>
)}
<Divider style={{ margin: '5px 0' }} />
<Flex gap={'1px'}>
{iconActions.map((action) => (
<Button
key={action.name}
type='text'
size='small'
icon={action.icon ? createElement(action.icon) : undefined}
onClick={() => runAction(action)}
aria-label={action.label}
/>
))}
{actionButton}
</Flex>
</Flex>
</Flex>
</Flex>
)
}
UserProfilePopover.propTypes = {
onClose: PropTypes.func
}
export default UserProfilePopover

View File

@ -28,8 +28,13 @@ const runningSpotlightFetches = new Map()
const ApiServerContext = createContext() const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => { const ApiServerProvider = ({ children }) => {
const { token, userProfile, authenticated, setUnauthenticated } = const {
useContext(AuthContext) token,
userProfile,
setUserProfile,
authenticated,
setUnauthenticated
} = useContext(AuthContext)
const socketRef = useRef(null) const socketRef = useRef(null)
const [connected, setConnected] = useState(false) const [connected, setConnected] = useState(false)
const [connecting, setConnecting] = useState(false) const [connecting, setConnecting] = useState(false)
@ -446,6 +451,25 @@ const ApiServerProvider = ({ children }) => {
[offObjectUpdatesEvent] [offObjectUpdatesEvent]
) )
// Subscribe to user profile updates when WebSocket is connected and userProfile._id exists
useEffect(() => {
if (connected && userProfile?._id) {
const unsubscribe = subscribeToObjectUpdates(
userProfile._id,
'user',
(updatedUser) => {
logger.debug('Notifying user profile update:', updatedUser)
setUserProfile((prev) =>
prev && updatedUser ? { ...prev, ...updatedUser } : prev
)
}
)
return () => {
if (unsubscribe) unsubscribe()
}
}
}, [connected, userProfile?._id, subscribeToObjectUpdates, setUserProfile])
const subscribeToObjectTypeUpdates = useCallback( const subscribeToObjectTypeUpdates = useCallback(
(objectType, callback) => { (objectType, callback) => {
logger.debug('Subscribing to type updates:', objectType) logger.debug('Subscribing to type updates:', objectType)

View File

@ -4,7 +4,8 @@ import {
useState, useState,
useCallback, useCallback,
useEffect, useEffect,
useContext useContext,
useRef
} from 'react' } from 'react'
import axios from 'axios' import axios from 'axios'
import { import {
@ -53,6 +54,8 @@ const AuthProvider = ({ children }) => {
const [token, setToken] = useState(null) const [token, setToken] = useState(null)
const [expiresAt, setExpiresAt] = useState(null) const [expiresAt, setExpiresAt] = useState(null)
const [userProfile, setUserProfile] = useState(null) const [userProfile, setUserProfile] = useState(null)
const [profileImageUrl, setProfileImageUrl] = useState(null)
const profileImageUrlRef = useRef(null)
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false) const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false)
const [showUnauthorizedModal, setShowUnauthorizedModal] = useState(false) const [showUnauthorizedModal, setShowUnauthorizedModal] = useState(false)
const [showAuthErrorModal, setShowAuthErrorModal] = useState(false) const [showAuthErrorModal, setShowAuthErrorModal] = useState(false)
@ -238,6 +241,97 @@ const AuthProvider = ({ children }) => {
return cleanupCookieSync return cleanupCookieSync
}, [token, expiresAt, userProfile, isElectron]) }, [token, expiresAt, userProfile, isElectron])
// Persist userProfile changes to cookies/electron storage so updates (e.g. from
// WebSocket or profile edits) are saved for session restoration
useEffect(() => {
if (!authenticated || !token || !expiresAt) return
persistSession({
token,
expiresAt,
user: userProfile
})
}, [authenticated, token, expiresAt, userProfile, persistSession])
// Fetch and cache profile image when userProfile.profileImage changes
useEffect(() => {
const profileImage = userProfile?.profileImage
const profileImageId =
profileImage?._id ??
(typeof profileImage === 'string' ? profileImage : null)
console.log('Fetching profile image:', profileImageId)
if (!token) {
if (profileImageUrlRef.current) {
URL.revokeObjectURL(profileImageUrlRef.current)
profileImageUrlRef.current = null
}
setProfileImageUrl(null)
return
}
if (!profileImageId) {
if (profileImageUrlRef.current) {
URL.revokeObjectURL(profileImageUrlRef.current)
profileImageUrlRef.current = null
}
setProfileImageUrl(null)
return
}
let cancelled = false
const file =
typeof profileImage === 'object' && profileImage !== null
? profileImage
: { _id: profileImageId, name: '', extension: '' }
const fetchProfileImage = async () => {
try {
const response = await axios.get(
`${config.backendUrl}/files/${file._id}/content`,
{
headers: {
Accept: '*/*',
Authorization: `Bearer ${token}`
},
responseType: 'blob'
}
)
const blob = new Blob([response.data], {
type: response.headers['content-type']
})
const fileURL = window.URL.createObjectURL(blob)
if (!cancelled) {
if (profileImageUrlRef.current) {
URL.revokeObjectURL(profileImageUrlRef.current)
}
profileImageUrlRef.current = fileURL
setProfileImageUrl(fileURL)
} else {
URL.revokeObjectURL(fileURL)
}
} catch (err) {
logger.debug('Failed to fetch profile image:', err)
if (!cancelled) {
setProfileImageUrl(null)
}
}
}
fetchProfileImage()
return () => {
cancelled = true
if (profileImageUrlRef.current) {
URL.revokeObjectURL(profileImageUrlRef.current)
profileImageUrlRef.current = null
}
setProfileImageUrl(null)
}
}, [userProfile?.profileImage?._id ?? userProfile?.profileImage, token])
useEffect(() => {
console.log('userProfile', userProfile)
}, [userProfile])
const logout = useCallback( const logout = useCallback(
(redirectUri = '/login') => { (redirectUri = '/login') => {
setAuthenticated(false) setAuthenticated(false)
@ -627,6 +721,8 @@ const AuthProvider = ({ children }) => {
token, token,
loading, loading,
userProfile, userProfile,
setUserProfile,
profileImageUrl,
logout logout
}} }}
> >

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/logouticon.svg?react'
const LogoutIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default LogoutIcon

View File

@ -1,7 +1,7 @@
import PersonIcon from '../../components/Icons/PersonIcon' import PersonIcon from '../../components/Icons/PersonIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
import ReloadIcon from '../../components/Icons/ReloadIcon' import EditIcon from '../../components/Icons/EditIcon'
import AppPasswordIcon from '../../components/Icons/AppPasswordIcon' import PlusIcon from '../../components/Icons/PlusIcon'
export const User = { export const User = {
name: 'user', name: 'user',
@ -18,17 +18,17 @@ export const User = {
url: (_id) => `/dashboard/management/users/info?userId=${_id}` url: (_id) => `/dashboard/management/users/info?userId=${_id}`
}, },
{ {
name: 'reload', name: 'edit',
label: 'Reload', label: 'Edit',
icon: ReloadIcon, row: true,
url: (_id) => icon: EditIcon,
`/dashboard/management/users/info?userId=${_id}&action=reload` url: (_id) => `/dashboard/management/users/info?userId=${_id}&action=edit`
}, },
{ {
name: 'newAppPassword', name: 'newAppPassword',
label: 'New App Password', label: 'New App Password',
type: 'button', type: 'button',
icon: AppPasswordIcon, icon: PlusIcon,
url: (_id) => url: (_id) =>
`/dashboard/management/users/info?userId=${_id}&action=newAppPassword`, `/dashboard/management/users/info?userId=${_id}&action=newAppPassword`,
disabled: (objectData) => { disabled: (objectData) => {