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
63 changes: 60 additions & 3 deletions frontend/src/models/adminUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface AdminUser {
interface AdminUsersState {
users: AdminUser[]
total: number
hasMore: boolean
loading: boolean
page: number
pageSize: number
Expand All @@ -24,6 +25,7 @@ interface AdminUsersState {
const initialState: AdminUsersState = {
users: [],
total: 0,
hasMore: false,
loading: false,
page: 1,
pageSize: 50,
Expand All @@ -36,10 +38,18 @@ export const adminUsers = createModel<RootModel>()({
name: 'adminUsers',
state: initialState,
reducers: {
setUsers: (state, payload: { users: AdminUser[]; total: number }) => ({
setUsers: (state, payload: { users: AdminUser[]; total: number; hasMore: boolean }) => ({
...state,
users: payload.users,
total: payload.total,
hasMore: payload.hasMore,
loading: false
}),
appendUsers: (state, payload: { users: AdminUser[]; total: number; hasMore: boolean }) => ({
...state,
users: [...state.users, ...payload.users],
total: payload.total,
hasMore: payload.hasMore,
loading: false
}),
setLoading: (state, loading: boolean) => ({
Expand Down Expand Up @@ -108,10 +118,57 @@ export const adminUsers = createModel<RootModel>()({
const users = data.items || []
dispatch.adminUsers.setUsers({
users,
total: data.total || 0
total: data.total || 0,
hasMore: !!data.hasMore
})

users.forEach((user: AdminUser) => {
dispatch.adminUsers.cacheUserDetail({ userId: user.id, user })
})
} else {
dispatch.adminUsers.setLoading(false)
}
},
async fetchMore(_: void, rootState) {
const state = rootState.adminUsers
if (!state.hasMore || state.loading) return

dispatch.adminUsers.setLoading(true)

const from = state.users.length
const filters: { search?: string; email?: string; accountId?: string } = {}
const trimmedValue = state.searchValue.trim()

if (trimmedValue) {
switch (state.searchType) {
case 'email':
filters.email = trimmedValue
break
case 'userId':
filters.accountId = trimmedValue
break
case 'all':
default:
filters.search = trimmedValue
break
}
}

const result = await graphQLAdminUsers(
{ from, size: state.pageSize },
Object.keys(filters).length > 0 ? filters : undefined,
'email'
)

if (result !== 'ERROR' && result?.data?.data?.admin?.users) {
const data = result.data.data.admin.users
const users = data.items || []
dispatch.adminUsers.appendUsers({
users,
total: data.total || 0,
hasMore: !!data.hasMore
})

// Cache user details for users in the list
users.forEach((user: AdminUser) => {
dispatch.adminUsers.cacheUserDetail({ userId: user.id, user })
})
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'))
} 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>
)
}
Loading
Loading