From 58e4f749c97fdd544fce1ed93604cd4261b5aead Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 30 Mar 2026 19:37:33 -0700 Subject: [PATCH 1/4] feature: add support for viewing, adding and removing system admins --- frontend/src/components/AdminSidebarNav.tsx | 11 + frontend/src/components/Header/Header.tsx | 2 +- .../AdminAdminsPage/AdminAdminDetailPanel.tsx | 234 +++++++++++++++ .../AdminAdminsPage/AdminAdminListItem.tsx | 44 +++ .../AdminAdminsPage/AdminAdminsListPage.tsx | 186 ++++++++++++ .../pages/AdminAdminsPage/AdminAdminsPage.tsx | 122 ++++++++ .../AdminAdminsPage/adminAdminAttributes.tsx | 41 +++ frontend/src/pages/AdminConfirmPage.tsx | 187 ++++++++++++ .../pages/AdminUsersPage/AddAdminDialog.tsx | 281 ++++++++++++++++++ .../AdminUsersPage/AdminUserAccountPanel.tsx | 73 ++++- .../AdminUsersPage/AdminUserListItem.tsx | 2 +- .../AdminUsersPage/adminUserAttributes.tsx | 1 + frontend/src/routers/Router.tsx | 12 + frontend/src/services/graphQLMutation.ts | 27 ++ frontend/src/services/graphQLRequest.ts | 9 +- 15 files changed, 1224 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/AdminAdminsPage/AdminAdminDetailPanel.tsx create mode 100644 frontend/src/pages/AdminAdminsPage/AdminAdminListItem.tsx create mode 100644 frontend/src/pages/AdminAdminsPage/AdminAdminsListPage.tsx create mode 100644 frontend/src/pages/AdminAdminsPage/AdminAdminsPage.tsx create mode 100644 frontend/src/pages/AdminAdminsPage/adminAdminAttributes.tsx create mode 100644 frontend/src/pages/AdminConfirmPage.tsx create mode 100644 frontend/src/pages/AdminUsersPage/AddAdminDialog.tsx diff --git a/frontend/src/components/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx index de8604544..ef507f00a 100644 --- a/frontend/src/components/AdminSidebarNav.tsx +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -55,6 +55,17 @@ export const AdminSidebarNav: React.FC = () => { + handleNavClick('/admin/admins')} + > + + + + + + = ({ 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 diff --git a/frontend/src/pages/AdminAdminsPage/AdminAdminDetailPanel.tsx b/frontend/src/pages/AdminAdminsPage/AdminAdminDetailPanel.tsx new file mode 100644 index 000000000..357f1a4f0 --- /dev/null +++ b/frontend/src/pages/AdminAdminsPage/AdminAdminDetailPanel.tsx @@ -0,0 +1,234 @@ +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 = ({ showBackArrow, onAdminRemoved }) => { + const { adminId } = useParams<{ adminId: string }>() + const history = useHistory() + const dispatch = useDispatch() + const [admin, setAdmin] = useState(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) + if (result !== 'ERROR' && result?.data?.data?.admin?.users?.items?.[0]) { + setAdmin(result.data.data.admin.users.items[0]) + } 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) + 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 ( + + + + ) + } + + if (!admin) { + return ( + + + + + Admin not found + + + + ) + } + + const deviceCount = admin.info?.devices?.total || 0 + const deviceOnline = admin.info?.devices?.online || 0 + const deviceOffline = deviceCount - deviceOnline + + return ( + + {showBackArrow && ( + + history.push('/admin/admins')} + size="md" + color="grayDarker" + /> + + )} + + Admin Details + + + } + > + + + + + {admin.id} + + + + } + /> + + + + + + + + + + + + + System Admin + + + } + /> + + + + {admin.organization?.name && ( + <> + + + + + + )} + + + + + + + + + + + + + Device Summary + + + + + + + + + Actions + + + + + + + + setRemoveConfirmOpen(false)} + title="Remove Admin Privileges" + action={removing ? 'Removing...' : 'Remove Admin'} + disabled={removing} + color="warning" + > + + Are you sure you want to remove admin privileges from {admin.email || admin.id}? + + + This user will no longer have access to the admin panel. + + + + ) +} diff --git a/frontend/src/pages/AdminAdminsPage/AdminAdminListItem.tsx b/frontend/src/pages/AdminAdminsPage/AdminAdminListItem.tsx new file mode 100644 index 000000000..00994702e --- /dev/null +++ b/frontend/src/pages/AdminAdminsPage/AdminAdminListItem.tsx @@ -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 = ({ admin, required, attributes, active, onClick }) => { + const css = useStyles() + + return ( + } + required={required?.value({ admin })} + > + {attributes.map(attribute => ( + +
{attribute.value({ admin })}
+
+ ))} +
+ ) +} + +const useStyles = makeStyles(() => ({ + truncate: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + flex: 1, + }, +})) diff --git a/frontend/src/pages/AdminAdminsPage/AdminAdminsListPage.tsx b/frontend/src/pages/AdminAdminsPage/AdminAdminsListPage.tsx new file mode 100644 index 000000000..3e3994edc --- /dev/null +++ b/frontend/src/pages/AdminAdminsPage/AdminAdminsListPage.tsx @@ -0,0 +1,186 @@ +import { Box, Stack, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material' +import React, { useEffect, useState, useCallback, useRef } from 'react' +import { useSelector } from 'react-redux' +import { useHistory, useLocation } from 'react-router-dom' +import { Container } from '../../components/Container' +import { GridList } from '../../components/GridList' +import { Gutters } from '../../components/Gutters' +import { Icon } from '../../components/Icon' +import { IconButton } from '../../buttons/IconButton' +import { LoadingMessage } from '../../components/LoadingMessage' +import { removeObject } from '../../helpers/utilHelper' +import { State } from '../../store' +import { AdminAdminListItem } from './AdminAdminListItem' +import { AddAdminDialog } from '../AdminUsersPage/AddAdminDialog' +import { adminAdminAttributes, AdminAdminRow } from './adminAdminAttributes' +import { graphQLAdminUsers } from '../../services/graphQLRequest' + +type SearchType = 'all' | 'email' | 'userId' + +export const AdminAdminsListPage: React.FC = () => { + const history = useHistory() + const location = useLocation() + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const [required, attributes] = removeObject(adminAdminAttributes, a => a.required === true) + + const [admins, setAdmins] = useState([]) + const [loading, setLoading] = useState(true) + const [searchInput, setSearchInput] = useState('') + const [searchType, setSearchType] = useState('email') + const [addDialogOpen, setAddDialogOpen] = useState(false) + + const searchInputRef = useRef(searchInput) + const searchTypeRef = useRef(searchType) + searchInputRef.current = searchInput + searchTypeRef.current = searchType + + const fetchAdmins = useCallback(async () => { + setLoading(true) + try { + const filters: { admin: boolean; email?: string; accountId?: string; search?: string } = { admin: true } + const trimmed = searchInputRef.current.trim() + if (trimmed) { + switch (searchTypeRef.current) { + case 'email': + filters.email = trimmed + break + case 'userId': + filters.accountId = trimmed + break + case 'all': + filters.search = trimmed + break + } + } + const result = await graphQLAdminUsers({ from: 0, size: 100 }, filters, 'email') + if (result !== 'ERROR' && result?.data?.data?.admin?.users) { + setAdmins(result.data.data.admin.users.items || []) + } + } catch (error) { + console.error('Failed to fetch admins:', error) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchAdmins() + }, [fetchAdmins]) + + useEffect(() => { + const handler = () => fetchAdmins() + window.addEventListener('refreshAdminData', handler) + return () => window.removeEventListener('refreshAdminData', handler) + }, [fetchAdmins]) + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + fetchAdmins() + } + } + + const handleSearchTypeChange = (_: React.MouseEvent, newType: SearchType | null) => { + if (newType !== null) { + setSearchType(newType) + } + } + + const getPlaceholder = () => { + switch (searchType) { + case 'email': + return 'Search by email address...' + case 'userId': + return 'Search by user ID (UUID)...' + case 'all': + default: + return 'Search by email, name, or user ID...' + } + } + + const handleAdminClick = (adminId: string) => { + history.push(`/admin/admins/${adminId}`) + } + + return ( + + + + + + + + + + + + + + setSearchInput(e.target.value)} + onKeyDown={handleSearchKeyDown} + size="small" + InputProps={{ + startAdornment: , + }} + /> + setAddDialogOpen(true)} + size="md" + color="primary" + /> + + + } + > + {loading ? ( + + ) : admins.length === 0 ? ( + + + + No admins found + + + ) : ( + + {admins.map(admin => ( + handleAdminClick(admin.id)} + /> + ))} + + )} + + setAddDialogOpen(false)} + onSuccess={() => fetchAdmins()} + /> + + ) +} diff --git a/frontend/src/pages/AdminAdminsPage/AdminAdminsPage.tsx b/frontend/src/pages/AdminAdminsPage/AdminAdminsPage.tsx new file mode 100644 index 000000000..5f1f52910 --- /dev/null +++ b/frontend/src/pages/AdminAdminsPage/AdminAdminsPage.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import { useParams } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Box } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { AdminAdminsListPage } from './AdminAdminsListPage' +import { AdminAdminDetailPanel } from './AdminAdminDetailPanel' +import { State } from '../../store' +import { useContainerWidth } from '../../hooks/useContainerWidth' +import { useResizablePanel } from '../../hooks/useResizablePanel' + +const MIN_WIDTH = 250 +const DEFAULT_LEFT_WIDTH = 400 + +export const AdminAdminsPage: React.FC = () => { + const { adminId } = useParams<{ adminId?: string }>() + const css = useStyles() + const layout = useSelector((state: State) => state.ui.layout) + + const { containerRef, containerWidth } = useContainerWidth() + const leftPanel = useResizablePanel(DEFAULT_LEFT_WIDTH, containerRef, { + minWidth: MIN_WIDTH, + }) + + const maxPanels = layout.singlePanel ? 1 : 2 + const hasAdminSelected = !!adminId + + const showLeft = !hasAdminSelected || maxPanels >= 2 + const showRight = hasAdminSelected + + return ( + + + {showLeft && ( + <> + + + + + {hasAdminSelected && ( + + + + + + )} + + )} + + {showRight && ( + + + + )} + + + ) +} + +const useStyles = makeStyles(({ palette }) => ({ + wrapper: { + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + }, + container: { + display: 'flex', + flexDirection: 'row', + flex: 1, + overflow: 'hidden', + }, + panel: { + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + }, + rightPanel: { + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + flex: 1, + }, + anchor: { + position: 'relative', + height: '100%', + }, + handle: { + zIndex: 8, + position: 'absolute', + height: '100%', + marginLeft: -5, + padding: '0 3px', + cursor: 'col-resize', + '& > div': { + width: 1, + marginLeft: 1, + marginRight: 1, + height: '100%', + backgroundColor: palette.grayLighter.main, + transition: 'background-color 100ms 200ms, width 100ms 200ms, margin 100ms 200ms', + }, + '&:hover > div, & .active': { + width: 3, + marginLeft: 0, + marginRight: 0, + backgroundColor: palette.primary.main, + }, + }, +})) diff --git a/frontend/src/pages/AdminAdminsPage/adminAdminAttributes.tsx b/frontend/src/pages/AdminAdminsPage/adminAdminAttributes.tsx new file mode 100644 index 000000000..52f8ffeed --- /dev/null +++ b/frontend/src/pages/AdminAdminsPage/adminAdminAttributes.tsx @@ -0,0 +1,41 @@ +import { Attribute } from '../../components/Attributes' + +export type AdminAdminAttributeOptions = { + admin?: AdminAdminRow +} + +export type AdminAdminRow = { + id: string + email?: string + created?: string + admin?: boolean +} + +export class AdminAdminAttribute extends Attribute { + type: Attribute['type'] = 'MASTER' +} + +export const adminAdminAttributes: AdminAdminAttribute[] = [ + new AdminAdminAttribute({ + id: 'email', + label: 'Email', + defaultWidth: 300, + required: true, + value: ({ admin }: AdminAdminAttributeOptions) => admin?.email || '-', + }), + new AdminAdminAttribute({ + id: 'userId', + label: 'User ID', + defaultWidth: 320, + value: ({ admin }: AdminAdminAttributeOptions) => admin?.id, + }), + new AdminAdminAttribute({ + id: 'created', + label: 'Created', + defaultWidth: 150, + value: ({ admin }: AdminAdminAttributeOptions) => { + if (!admin?.created) return '-' + return new Date(admin.created).toLocaleDateString() + }, + }), +] diff --git a/frontend/src/pages/AdminConfirmPage.tsx b/frontend/src/pages/AdminConfirmPage.tsx new file mode 100644 index 000000000..b8c41f18c --- /dev/null +++ b/frontend/src/pages/AdminConfirmPage.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from 'react' +import { useLocation, useHistory } from 'react-router-dom' +import { useDispatch } from 'react-redux' +import { Typography, Box, TextField, Button, CircularProgress } from '@mui/material' +import { Container } from '../components/Container' +import { Icon } from '../components/Icon' +import { Notice } from '../components/Notice' +import { Dispatch } from '../store' +import { graphQLConfirmAdminPromotion } from '../services/graphQLMutation' + +export const AdminConfirmPage: React.FC = () => { + const location = useLocation() + const history = useHistory() + const dispatch = useDispatch() + const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'manual'>('loading') + const [manualCode, setManualCode] = useState('') + const [submitting, setSubmitting] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + // Extract token from URL query params + const params = new URLSearchParams(location.search) + const token = params.get('token') + + useEffect(() => { + if (token) { + confirmWithToken(token) + } else { + setStatus('manual') + } + }, [token]) + + const confirmWithToken = async (token: string) => { + setStatus('loading') + try { + const result = await graphQLConfirmAdminPromotion(token) + if (result !== 'ERROR') { + setStatus('success') + window.dispatchEvent(new Event('refreshAdminData')) + } else { + setErrorMessage('The confirmation link is invalid or has expired.') + setStatus('error') + } + } catch (error) { + setErrorMessage('The confirmation link is invalid or has expired.') + setStatus('error') + } + } + + const handleManualSubmit = async () => { + if (!manualCode.trim() || manualCode.trim().length !== 6) { + setErrorMessage('Please enter a valid 6-digit code.') + return + } + setSubmitting(true) + setErrorMessage('') + try { + const result = await graphQLConfirmAdminPromotion(undefined, manualCode.trim()) + if (result !== 'ERROR') { + setStatus('success') + window.dispatchEvent(new Event('refreshAdminData')) + } else { + setErrorMessage('Invalid or expired code. Please try again.') + } + } catch (error) { + setErrorMessage('Invalid or expired code. Please try again.') + } finally { + setSubmitting(false) + } + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleManualSubmit() + } + } + + return ( + + Admin Promotion Confirmation + + } + > + + {status === 'loading' && ( + + + + Confirming admin promotion... + + + )} + + {status === 'success' && ( + + + + Admin Promotion Confirmed + + + The user has been successfully promoted to admin. + + + + )} + + {status === 'error' && ( + + + + Confirmation Failed + + + {errorMessage} + + + You can try entering the 6-digit code from your email manually: + + setManualCode(e.target.value.replace(/[^A-Za-z0-9]/g, '').toUpperCase().slice(0, 6))} + onKeyDown={handleKeyDown} + inputProps={{ maxLength: 6, style: { textAlign: 'center', fontSize: '1.5rem', letterSpacing: '0.5rem' } }} + sx={{ marginTop: 1, marginBottom: 2 }} + /> + + + )} + + {status === 'manual' && ( + + + + Enter Confirmation Code + + + Enter the 6-digit code from the confirmation email sent to your inbox. + + {errorMessage && ( + + {errorMessage} + + )} + setManualCode(e.target.value.replace(/[^A-Za-z0-9]/g, '').toUpperCase().slice(0, 6))} + onKeyDown={handleKeyDown} + inputProps={{ maxLength: 6, style: { textAlign: 'center', fontSize: '1.5rem', letterSpacing: '0.5rem' } }} + sx={{ marginTop: 2, marginBottom: 2 }} + /> + + + )} + + + ) +} diff --git a/frontend/src/pages/AdminUsersPage/AddAdminDialog.tsx b/frontend/src/pages/AdminUsersPage/AddAdminDialog.tsx new file mode 100644 index 000000000..6a790ef85 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AddAdminDialog.tsx @@ -0,0 +1,281 @@ +import React, { useState, useEffect } from 'react' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Typography, + List, + ListItem, + ListItemText, + CircularProgress, + Box, +} from '@mui/material' +import { Notice } from '../../components/Notice' +import { Icon } from '../../components/Icon' +import { graphQLAdminUsers } from '../../services/graphQLRequest' +import { graphQLRequestAdminPromotion, graphQLConfirmAdminPromotion } from '../../services/graphQLMutation' + +type Props = { + open: boolean + onClose: () => void + onSuccess: () => void +} + +type Step = 'search' | 'confirm' | 'done' + +export const AddAdminDialog: React.FC = ({ open, onClose, onSuccess }) => { + const [step, setStep] = useState('search') + const [email, setEmail] = useState('') + const [searching, setSearching] = useState(false) + const [promoting, setPromoting] = useState(false) + const [confirming, setConfirming] = useState(false) + const [error, setError] = useState() + const [confirmCode, setConfirmCode] = useState('') + const [foundUser, setFoundUser] = useState<{ id: string; email: string; admin: boolean } | null>(null) + + useEffect(() => { + if (open) { + setStep('search') + setEmail('') + setError(undefined) + setFoundUser(null) + setSearching(false) + setPromoting(false) + setConfirming(false) + setConfirmCode('') + } + }, [open]) + + const handleSearch = async () => { + if (!email.trim()) { + setError('Please enter an email address') + return + } + + setError(undefined) + setFoundUser(null) + setSearching(true) + + try { + const result = await graphQLAdminUsers({ from: 0, size: 1 }, { email: email.trim() }) + const users = result?.data?.data?.admin?.users?.items + if (users && users.length > 0) { + setFoundUser(users[0]) + } else { + setError('No user found with that email address') + } + } catch (err) { + setError('Failed to search for user') + } finally { + setSearching(false) + } + } + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + handleSearch() + } + } + + const handlePromote = async () => { + if (!foundUser) return + + setError(undefined) + setPromoting(true) + + try { + const result = await graphQLRequestAdminPromotion(foundUser.id) + if (result !== 'ERROR') { + setStep('confirm') + } else { + setError('Failed to request admin promotion.') + } + } catch (err) { + setError('Failed to request admin promotion.') + } finally { + setPromoting(false) + } + } + + const handleConfirmCode = async () => { + if (!confirmCode.trim()) { + setError('Please enter the 6-digit code') + return + } + + setError(undefined) + setConfirming(true) + + try { + const result = await graphQLConfirmAdminPromotion(undefined, confirmCode.trim()) + if (result !== 'ERROR') { + setStep('done') + onSuccess() + } else { + setError('Invalid or expired code. Please try again.') + } + } catch (err) { + setError('Failed to confirm admin promotion.') + } finally { + setConfirming(false) + } + } + + const handleCodeKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + handleConfirmCode() + } + } + + const renderSearch = () => ( + <> + + Search for a user by email to promote them to admin. + + + { + setEmail(e.target.value) + setError(undefined) + setFoundUser(null) + }} + onKeyDown={handleSearchKeyDown} + error={!!error && !foundUser} + helperText={!foundUser ? error : undefined} + disabled={searching || promoting} + /> + + + + {foundUser && ( + + + + + + + + + {foundUser.admin ? ( + + This user is already an admin. + + ) : ( + <> + + A confirmation email will be sent to your admin email address. You must click the link or enter the + code to complete the promotion. + + {error && ( + + {error} + + )} + + )} + + )} + + ) + + const renderConfirm = () => ( + <> + + Confirmation email sent! Check your inbox for the code. + + + Promoting {foundUser?.email} to admin. + Enter the 6-digit code from the email, or click the link in the email to confirm. + + + { + const val = e.target.value.replace(/[^A-Za-z0-9]/g, '').toUpperCase().slice(0, 6) + setConfirmCode(val) + setError(undefined) + }} + onKeyDown={handleCodeKeyDown} + disabled={confirming} + inputProps={{ + maxLength: 6, + style: { textAlign: 'center', fontSize: '1.5rem', letterSpacing: '0.5rem' }, + }} + sx={{ width: 250 }} + /> + + {error && ( + + {error} + + )} + + ) + + const renderDone = () => ( + + + + Admin Added + + + {foundUser?.email} is now an admin. + + + ) + + return ( + + + {step === 'search' && 'Add Admin'} + {step === 'confirm' && 'Confirm Admin Promotion'} + {step === 'done' && 'Admin Added'} + + + {step === 'search' && renderSearch()} + {step === 'confirm' && renderConfirm()} + {step === 'done' && renderDone()} + + + {step === 'search' && ( + <> + + {foundUser && !foundUser.admin && ( + + )} + + )} + {step === 'confirm' && ( + <> + + + + )} + {step === 'done' && } + + + ) +} diff --git a/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx b/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx index 20f52820b..4471c91b5 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx @@ -11,7 +11,7 @@ import { LoadingMessage } from '../../components/LoadingMessage' import { Confirm } from '../../components/Confirm' import { Notice } from '../../components/Notice' import { Dispatch } from '../../store' -import { graphQLAdminUpdateEmail, graphQLAdminDeleteUser } from '../../services/graphQLMutation' +import { graphQLAdminUpdateEmail, graphQLAdminDeleteUser, graphQLRemoveAdmin } from '../../services/graphQLMutation' import { ChangeEmailDialog } from './ChangeEmailDialog' export const AdminUserAccountPanel: React.FC = () => { @@ -22,6 +22,8 @@ export const AdminUserAccountPanel: React.FC = () => { const [loading, setLoading] = useState(true) const [changeEmailOpen, setChangeEmailOpen] = useState(false) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [removeAdminConfirmOpen, setRemoveAdminConfirmOpen] = useState(false) + const [removingAdmin, setRemovingAdmin] = useState(false) const [forceDelete, setForceDelete] = useState(false) const [deleting, setDeleting] = useState(false) @@ -89,6 +91,27 @@ export const AdminUserAccountPanel: React.FC = () => { } } + const handleRemoveAdmin = async () => { + setRemovingAdmin(true) + try { + const result = await graphQLRemoveAdmin(userId) + if (result !== 'ERROR') { + dispatch.ui.set({ successMessage: 'Admin privileges removed' }) + setRemoveAdminConfirmOpen(false) + dispatch.adminUsers.invalidateUserDetail(userId) + await fetchUser() + await dispatch.adminUsers.fetch(undefined) + } 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 { + setRemovingAdmin(false) + } + } + if (loading) { return ( @@ -154,6 +177,25 @@ export const AdminUserAccountPanel: React.FC = () => { + + + + + System Admin + + + ) : ( + 'Standard User' + ) + } + /> + + + {user.organization?.name && ( <> @@ -198,16 +240,24 @@ export const AdminUserAccountPanel: React.FC = () => { Admin Actions - + + {user.admin && ( + + )} + + )} )} From 73e9dcca1c1a88b0038de347356abd0bafee208d Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Mon, 30 Mar 2026 19:51:33 -0700 Subject: [PATCH 4/4] feat: view more users when longer then initial max --- frontend/src/models/adminUsers.ts | 63 ++++++++++++++++++- .../AdminUsersPage/AdminUsersListPage.tsx | 16 ++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/frontend/src/models/adminUsers.ts b/frontend/src/models/adminUsers.ts index e97d59945..c31398d14 100644 --- a/frontend/src/models/adminUsers.ts +++ b/frontend/src/models/adminUsers.ts @@ -13,6 +13,7 @@ interface AdminUser { interface AdminUsersState { users: AdminUser[] total: number + hasMore: boolean loading: boolean page: number pageSize: number @@ -24,6 +25,7 @@ interface AdminUsersState { const initialState: AdminUsersState = { users: [], total: 0, + hasMore: false, loading: false, page: 1, pageSize: 50, @@ -36,10 +38,18 @@ export const adminUsers = createModel()({ 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) => ({ @@ -108,10 +118,57 @@ export const adminUsers = createModel()({ 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 }) }) diff --git a/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx index a0ef90073..4f4767c90 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx @@ -1,4 +1,4 @@ -import { Box,Stack,TextField,ToggleButton,ToggleButtonGroup,Typography } from '@mui/material' +import { Box,Button,Stack,TextField,ToggleButton,ToggleButtonGroup,Typography } from '@mui/material' import React,{ useEffect,useState } from 'react' import { useDispatch,useSelector } from 'react-redux' import { useHistory,useLocation } from 'react-router-dom' @@ -25,6 +25,7 @@ export const AdminUsersListPage: React.FC = () => { // Get state from Redux const users = useSelector((state: State) => state.adminUsers.users) const loading = useSelector((state: State) => state.adminUsers.loading) + const hasMore = useSelector((state: State) => state.adminUsers.hasMore) const page = useSelector((state: State) => state.adminUsers.page) const searchValue = useSelector((state: State) => state.adminUsers.searchValue) const searchType = useSelector((state: State) => state.adminUsers.searchType) @@ -122,7 +123,7 @@ export const AdminUsersListPage: React.FC = () => { } > - {loading ? ( + {loading && users.length === 0 ? ( ) : users.length === 0 ? ( @@ -148,6 +149,17 @@ export const AdminUsersListPage: React.FC = () => { onClick={() => handleUserClick(user.id)} /> ))} + {hasMore && ( + + + + )} )}