diff --git a/assets/icons/logouticon.svg b/assets/icons/logouticon.svg
new file mode 100644
index 0000000..003d2c8
--- /dev/null
+++ b/assets/icons/logouticon.svg
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/components/Dashboard/common/DashboardNavigation.jsx b/src/components/Dashboard/common/DashboardNavigation.jsx
index de2bcf7..aa4021e 100644
--- a/src/components/Dashboard/common/DashboardNavigation.jsx
+++ b/src/components/Dashboard/common/DashboardNavigation.jsx
@@ -5,18 +5,14 @@ import {
Flex,
Tag,
Space,
- Dropdown,
Button,
Tooltip,
Badge,
Divider,
- Typography
+ Typography,
+ Popover
} from 'antd'
-import {
- LogoutOutlined,
- MailOutlined,
- LoadingOutlined
-} from '@ant-design/icons'
+import { LoadingOutlined } from '@ant-design/icons'
import { AuthContext } from '../context/AuthContext'
import { SpotlightContext } from '../context/SpotlightContext'
import { ApiServerContext } from '../context/ApiServerContext'
@@ -25,6 +21,7 @@ import { useNavigate, useLocation } from 'react-router-dom'
import { Header } from 'antd/es/layout/layout'
import { useMediaQuery } from 'react-responsive'
import KeyboardShortcut from './KeyboardShortcut'
+import UserProfilePopover from './UserProfilePopover'
import FarmControlLogo from '../../Logos/FarmControlLogo'
import FarmControlLogoSmall from '../../Logos/FarmControlLogoSmall'
@@ -45,7 +42,7 @@ import DashboardWindowButtons from './DashboardWindowButtons'
const { Text } = Typography
const DashboardNavigation = () => {
- const { logout, userProfile } = useContext(AuthContext)
+ const { userProfile } = useContext(AuthContext)
const { showSpotlight } = useContext(SpotlightContext)
const { connecting, connected } = useContext(ApiServerContext)
const { toggleNotificationCenter, unreadCount } =
@@ -93,34 +90,11 @@ const DashboardNavigation = () => {
[]
)
- const userMenuItems = {
- items: [
- {
- key: 'username',
- label: userProfile?.username,
- icon: ,
- disabled: true
- },
- {
- key: 'email',
- label: userProfile?.email,
- icon: ,
- disabled: true
- },
- {
- key: 'logout',
- label: 'Logout',
- icon:
- }
- ],
- onClick: (key) => {
- if (key === 'profile') {
- navigate('/profile')
- } else if (key === 'logout') {
- logout()
- }
- }
- }
+ const [userPopoverOpen, setUserPopoverOpen] = useState(false)
+
+ const userPopoverContent = (
+ setUserPopoverOpen(false)} />
+ )
useEffect(() => {
const pathParts = location.pathname.split('/').filter(Boolean)
@@ -322,11 +296,18 @@ const DashboardNavigation = () => {
)}
{userProfile ? (
-
+
}>
{!isMobile && (userProfile?.name || userProfile.username)}
-
+
) : null}
diff --git a/src/components/Dashboard/common/UserProfilePopover.jsx b/src/components/Dashboard/common/UserProfilePopover.jsx
new file mode 100644
index 0000000..b7b2f5f
--- /dev/null
+++ b/src/components/Dashboard/common/UserProfilePopover.jsx
@@ -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 ? (
+
+
+
+ ) : null
+
+ return (
+
+
+ {profileImageUrl ? (
+
+ ) : (
+
+ )}
+
+ {fullName && (
+
+ {fullName}
+
+ )}
+ {email && (
+
+
+ @{username}
+
+
+ {email}
+
+
+ )}
+ {!username && !fullName && !email && (
+ Unknown user
+ )}
+
+
+ {iconActions.map((action) => (
+
+
+
+
+ )
+}
+
+UserProfilePopover.propTypes = {
+ onClose: PropTypes.func
+}
+
+export default UserProfilePopover
diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx
index cc82711..3121c86 100644
--- a/src/components/Dashboard/context/ApiServerContext.jsx
+++ b/src/components/Dashboard/context/ApiServerContext.jsx
@@ -28,8 +28,13 @@ const runningSpotlightFetches = new Map()
const ApiServerContext = createContext()
const ApiServerProvider = ({ children }) => {
- const { token, userProfile, authenticated, setUnauthenticated } =
- useContext(AuthContext)
+ const {
+ token,
+ userProfile,
+ setUserProfile,
+ authenticated,
+ setUnauthenticated
+ } = useContext(AuthContext)
const socketRef = useRef(null)
const [connected, setConnected] = useState(false)
const [connecting, setConnecting] = useState(false)
@@ -446,6 +451,25 @@ const ApiServerProvider = ({ children }) => {
[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(
(objectType, callback) => {
logger.debug('Subscribing to type updates:', objectType)
diff --git a/src/components/Dashboard/context/AuthContext.jsx b/src/components/Dashboard/context/AuthContext.jsx
index 77e8e52..2510980 100644
--- a/src/components/Dashboard/context/AuthContext.jsx
+++ b/src/components/Dashboard/context/AuthContext.jsx
@@ -4,7 +4,8 @@ import {
useState,
useCallback,
useEffect,
- useContext
+ useContext,
+ useRef
} from 'react'
import axios from 'axios'
import {
@@ -53,6 +54,8 @@ const AuthProvider = ({ children }) => {
const [token, setToken] = useState(null)
const [expiresAt, setExpiresAt] = useState(null)
const [userProfile, setUserProfile] = useState(null)
+ const [profileImageUrl, setProfileImageUrl] = useState(null)
+ const profileImageUrlRef = useRef(null)
const [showSessionExpiredModal, setShowSessionExpiredModal] = useState(false)
const [showUnauthorizedModal, setShowUnauthorizedModal] = useState(false)
const [showAuthErrorModal, setShowAuthErrorModal] = useState(false)
@@ -238,6 +241,97 @@ const AuthProvider = ({ children }) => {
return cleanupCookieSync
}, [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(
(redirectUri = '/login') => {
setAuthenticated(false)
@@ -627,6 +721,8 @@ const AuthProvider = ({ children }) => {
token,
loading,
userProfile,
+ setUserProfile,
+ profileImageUrl,
logout
}}
>
diff --git a/src/components/Icons/LogoutIcon.jsx b/src/components/Icons/LogoutIcon.jsx
new file mode 100644
index 0000000..c4d33ff
--- /dev/null
+++ b/src/components/Icons/LogoutIcon.jsx
@@ -0,0 +1,6 @@
+import Icon from '@ant-design/icons'
+import CustomIconSvg from '../../../assets/icons/logouticon.svg?react'
+
+const LogoutIcon = (props) =>
+
+export default LogoutIcon
diff --git a/src/database/models/User.js b/src/database/models/User.js
index cd967ed..c5e9dfc 100644
--- a/src/database/models/User.js
+++ b/src/database/models/User.js
@@ -1,7 +1,7 @@
import PersonIcon from '../../components/Icons/PersonIcon'
import InfoCircleIcon from '../../components/Icons/InfoCircleIcon'
-import ReloadIcon from '../../components/Icons/ReloadIcon'
-import AppPasswordIcon from '../../components/Icons/AppPasswordIcon'
+import EditIcon from '../../components/Icons/EditIcon'
+import PlusIcon from '../../components/Icons/PlusIcon'
export const User = {
name: 'user',
@@ -18,17 +18,17 @@ export const User = {
url: (_id) => `/dashboard/management/users/info?userId=${_id}`
},
{
- name: 'reload',
- label: 'Reload',
- icon: ReloadIcon,
- url: (_id) =>
- `/dashboard/management/users/info?userId=${_id}&action=reload`
+ name: 'edit',
+ label: 'Edit',
+ row: true,
+ icon: EditIcon,
+ url: (_id) => `/dashboard/management/users/info?userId=${_id}&action=edit`
},
{
name: 'newAppPassword',
label: 'New App Password',
type: 'button',
- icon: AppPasswordIcon,
+ icon: PlusIcon,
url: (_id) =>
`/dashboard/management/users/info?userId=${_id}&action=newAppPassword`,
disabled: (objectData) => {