Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/src/components/AdminSidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ export const AdminSidebarNav: React.FC = () => {
<ListItemText primary="Users" />
</ListItemButton>

<ListItemButton
dense
selected={currentPath.includes('/admin/admins')}
onClick={() => handleNavClick('/admin/admins')}
>
<ListItemIcon>
<Icon name="shield" size="md" />
</ListItemIcon>
<ListItemText primary="Admins" />
</ListItemButton>

<ListItemButton
dense
selected={currentPath.includes('/admin/partners')}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const Header: React.FC<Props> = ({ panels = 1 }) => {
const menu = location.pathname.match(REGEX_FIRST_PATH)?.[0]

// Admin pages have two-level roots: /admin/users and /admin/partners (without IDs)
const adminRootPages = ['/admin/users', '/admin/partners', '/partner-stats']
const adminRootPages = ['/admin/users', '/admin/admins', '/admin/partners', '/partner-stats']
const isAdminRootPage = adminRootPages.includes(location.pathname)
const isRootMenu = menu === location.pathname || isAdminRootPage

Expand Down
237 changes: 237 additions & 0 deletions frontend/src/pages/AdminAdminsPage/AdminAdminDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import React, { useEffect, useState } from 'react'
import { useParams, useHistory } from 'react-router-dom'
import { useDispatch } from 'react-redux'
import { Typography, List, ListItem, ListItemText, Box, Divider, Button } from '@mui/material'
import { Container } from '../../components/Container'
import { Title } from '../../components/Title'
import { Icon } from '../../components/Icon'
import { IconButton } from '../../buttons/IconButton'
import { Body } from '../../components/Body'
import { CopyIconButton } from '../../buttons/CopyIconButton'
import { LoadingMessage } from '../../components/LoadingMessage'
import { Confirm } from '../../components/Confirm'
import { Dispatch } from '../../store'
import { spacing } from '../../styling'
import { graphQLAdminUser } from '../../services/graphQLRequest'
import { graphQLRemoveAdmin } from '../../services/graphQLMutation'

interface Props {
showBackArrow?: boolean
onAdminRemoved?: () => void
}

export const AdminAdminDetailPanel: React.FC<Props> = ({ showBackArrow, onAdminRemoved }) => {
const { adminId } = useParams<{ adminId: string }>()
const history = useHistory()
const dispatch = useDispatch<Dispatch>()
const [admin, setAdmin] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false)
const [removing, setRemoving] = useState(false)

useEffect(() => {
if (adminId) {
fetchAdmin()
}
}, [adminId])

const fetchAdmin = async () => {
setLoading(true)
try {
const result = await graphQLAdminUser(adminId)
const user = result !== 'ERROR' ? result?.data?.data?.admin?.users?.items?.[0] : null
if (user?.admin) {
setAdmin(user)
} else {
setAdmin(null)
}
} catch (error) {
console.error('Failed to fetch admin:', error)
setAdmin(null)
} finally {
setLoading(false)
}
}

const handleRemoveAdmin = async () => {
setRemoving(true)
try {
const result = await graphQLRemoveAdmin(adminId)
if (result !== 'ERROR') {
dispatch.ui.set({ successMessage: `Admin privileges removed from ${admin?.email}` })
setRemoveConfirmOpen(false)
dispatch.adminUsers.invalidateUserDetail(adminId)
dispatch.adminUsers.fetch(undefined)
history.push('/admin/admins')
onAdminRemoved?.()
window.dispatchEvent(new Event('refreshAdminData'))
Comment on lines +65 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate adminUsers cache after demoting an admin

After successful demotion this flow only redirects and emits refreshAdminData, but it never invalidates/refetches adminUsers state. Because adminUsers.fetchUserDetail returns cached entries first and the users page only fetches when empty, previously cached data can continue to show the demoted account as admin until a manual refresh, creating inconsistent admin status across pages.

Useful? React with 👍 / 👎.

} else {
dispatch.ui.set({ errorMessage: 'Failed to remove admin privileges' })
}
} catch (error) {
console.error('Error removing admin:', error)
dispatch.ui.set({ errorMessage: 'Failed to remove admin privileges' })
} finally {
setRemoving(false)
}
}

if (loading) {
return (
<Container gutterBottom>
<LoadingMessage message="Loading admin details..." />
</Container>
)
}

if (!admin) {
return (
<Container gutterBottom>
<Body center>
<Icon name="exclamation-triangle" size="xxl" color="warning" />
<Typography variant="h2" gutterBottom sx={{ marginTop: 2 }}>
Admin not found
</Typography>
</Body>
</Container>
)
}

const deviceCount = admin.info?.devices?.total || 0
const deviceOnline = admin.info?.devices?.online || 0
const deviceOffline = deviceCount - deviceOnline

return (
<Container
gutterBottom
bodyProps={{ verticalOverflow: true }}
header={
<Box>
{showBackArrow && (
<Box sx={{ height: 45, display: 'flex', alignItems: 'center', paddingX: `${spacing.md}px`, marginTop: `${spacing.sm}px` }}>
<IconButton
icon="chevron-left"
title="Back to Admins"
onClick={() => history.push('/admin/admins')}
size="md"
color="grayDarker"
/>
</Box>
)}
<Typography variant="h2" sx={{ padding: 2 }}>
<Title>Admin Details</Title>
</Typography>
</Box>
}
>
<List disablePadding>
<ListItem>
<ListItemText
primary="User ID"
secondary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="body2"
component="span"
sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}
>
{admin.id}
</Typography>
<CopyIconButton value={admin.id} size="sm" />
</Box>
}
/>
</ListItem>
<Divider component="li" />

<ListItem>
<ListItemText primary="Email" secondary={admin.email || 'N/A'} />
</ListItem>
<Divider component="li" />

<ListItem>
<ListItemText
primary="Admin Status"
secondary={
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
<Icon name="shield" size="sm" color="primary" />
<Typography variant="body2" component="span" color="primary">
System Admin
</Typography>
</Box>
}
/>
</ListItem>
<Divider component="li" />

{admin.organization?.name && (
<>
<ListItem>
<ListItemText primary="Organization" secondary={admin.organization.name} />
</ListItem>
<Divider component="li" />
</>
)}

<ListItem>
<ListItemText
primary="Created"
secondary={admin.created ? new Date(admin.created).toLocaleString() : 'N/A'}
/>
</ListItem>
<Divider component="li" />

<ListItem>
<ListItemText
primary="Last Login"
secondary={admin.lastLogin ? new Date(admin.lastLogin).toLocaleString() : 'N/A'}
/>
</ListItem>
</List>

<Typography variant="subtitle1" sx={{ marginTop: 3, paddingX: 2 }}>
<Title>Device Summary</Title>
</Typography>
<List disablePadding>
<ListItem>
<ListItemText
primary="User Devices"
secondary={`${deviceCount} total \u2022 ${deviceOnline} online \u2022 ${deviceOffline} offline`}
/>
</ListItem>
</List>

<Typography variant="subtitle1" sx={{ marginTop: 3, paddingX: 2 }}>
<Title>Actions</Title>
</Typography>
<List disablePadding>
<ListItem>
<Button
variant="outlined"
color="warning"
onClick={() => setRemoveConfirmOpen(true)}
>
Remove Admin
</Button>
</ListItem>
</List>

<Confirm
open={removeConfirmOpen}
onConfirm={handleRemoveAdmin}
onDeny={() => setRemoveConfirmOpen(false)}
title="Remove Admin Privileges"
action={removing ? 'Removing...' : 'Remove Admin'}
disabled={removing}
color="warning"
>
<Typography variant="body2" gutterBottom>
Are you sure you want to remove admin privileges from <strong>{admin.email || admin.id}</strong>?
</Typography>
<Typography variant="body2" color="textSecondary">
This user will no longer have access to the admin panel.
</Typography>
</Confirm>
</Container>
)
}
44 changes: 44 additions & 0 deletions frontend/src/pages/AdminAdminsPage/AdminAdminListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Box } from '@mui/material'
import { makeStyles } from '@mui/styles'
import React from 'react'
import { GridListItem } from '../../components/GridListItem'
import { Icon } from '../../components/Icon'
import { AdminAdminAttribute, AdminAdminRow } from './adminAdminAttributes'

interface Props {
admin: AdminAdminRow
required?: AdminAdminAttribute
attributes: AdminAdminAttribute[]
active?: boolean
onClick: () => void
}

export const AdminAdminListItem: React.FC<Props> = ({ admin, required, attributes, active, onClick }) => {
const css = useStyles()

return (
<GridListItem
onClick={onClick}
selected={active}
disableGutters
icon={<Icon name="shield" size="md" color="primary" />}
required={required?.value({ admin })}
>
{attributes.map(attribute => (
<Box key={attribute.id} className="attribute">
<div className={css.truncate}>{attribute.value({ admin })}</div>
</Box>
))}
</GridListItem>
)
}

const useStyles = makeStyles(() => ({
truncate: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
flex: 1,
},
}))
Loading
Loading