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/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/AdminAdminsPage/AdminAdminDetailPanel.tsx b/frontend/src/pages/AdminAdminsPage/AdminAdminDetailPanel.tsx new file mode 100644 index 000000000..686027bfc --- /dev/null +++ b/frontend/src/pages/AdminAdminsPage/AdminAdminDetailPanel.tsx @@ -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 = ({ 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) + 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 ( + + + + ) + } + + 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..82fa647b3 --- /dev/null +++ b/frontend/src/pages/AdminAdminsPage/AdminAdminsListPage.tsx @@ -0,0 +1,223 @@ +import { Box, Button, 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' + +const PAGE_SIZE = 50 + +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 [hasMore, setHasMore] = useState(false) + 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 buildFilters = () => { + 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 + } + } + return filters + } + + const fetchAdmins = useCallback(async () => { + setLoading(true) + try { + const result = await graphQLAdminUsers({ from: 0, size: PAGE_SIZE }, buildFilters(), 'email') + if (result !== 'ERROR' && result?.data?.data?.admin?.users) { + const data = result.data.data.admin.users + setAdmins(data.items || []) + setHasMore(!!data.hasMore) + } + } catch (error) { + console.error('Failed to fetch admins:', error) + } finally { + setLoading(false) + } + }, []) + + const fetchMore = useCallback(async () => { + if (loading || !hasMore) return + setLoading(true) + try { + const result = await graphQLAdminUsers({ from: admins.length, size: PAGE_SIZE }, buildFilters(), 'email') + if (result !== 'ERROR' && result?.data?.data?.admin?.users) { + const data = result.data.data.admin.users + setAdmins(prev => [...prev, ...(data.items || [])]) + setHasMore(!!data.hasMore) + } + } catch (error) { + console.error('Failed to fetch more admins:', error) + } finally { + setLoading(false) + } + }, [loading, hasMore, admins.length]) + + 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 ? ( + + ) : admins.length === 0 ? ( + + + + No admins found + + + ) : ( + + {admins.map(admin => ( + handleAdminClick(admin.id)} + /> + ))} + {hasMore && ( + + + + )} + + )} + + 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..6540f3490 --- /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 } = 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..f3ce8ac8d --- /dev/null +++ b/frontend/src/pages/AdminConfirmPage.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react' +import { useLocation, useHistory } from 'react-router-dom' +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 { graphQLConfirmAdminPromotion } from '../services/graphQLMutation' + +export const AdminConfirmPage: React.FC = () => { + const location = useLocation() + const history = useHistory() + 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..af94685b3 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AddAdminDialog.tsx @@ -0,0 +1,282 @@ +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() }) + if (result === 'ERROR') throw new Error('Search failed') + 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 && ( + + )} + + )} )} diff --git a/frontend/src/pages/AdminUsersPage/adminUserAttributes.tsx b/frontend/src/pages/AdminUsersPage/adminUserAttributes.tsx index a98a58d02..69ebe9a7d 100644 --- a/frontend/src/pages/AdminUsersPage/adminUserAttributes.tsx +++ b/frontend/src/pages/AdminUsersPage/adminUserAttributes.tsx @@ -9,6 +9,7 @@ export type AdminUserRow = { id: string email?: string created?: string + admin?: boolean info?: { devices?: { total?: number diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index 8f6f44b96..433186df8 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -55,6 +55,8 @@ import { FeedbackPage } from '../pages/FeedbackPage' import { AccessKeyPage } from '../pages/AccessKeyPage' import { NotificationsPage } from '../pages/NotificationsPage' import { AdminUsersWithDetailPage } from '../pages/AdminUsersPage/AdminUsersWithDetailPage' +import { AdminConfirmPage } from '../pages/AdminConfirmPage' +import { AdminAdminsPage } from '../pages/AdminAdminsPage/AdminAdminsPage' import { AdminPartnersPage } from '../pages/AdminPartnersPage/AdminPartnersPage' import { PartnerStatsPage } from '../pages/PartnerStatsPage/PartnerStatsPage' import browser, { getOs } from '../services/browser' @@ -388,6 +390,16 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { + + + + + + + + + + diff --git a/frontend/src/services/graphQLMutation.ts b/frontend/src/services/graphQLMutation.ts index 5148bcd62..7bd63ddc9 100644 --- a/frontend/src/services/graphQLMutation.ts +++ b/frontend/src/services/graphQLMutation.ts @@ -646,6 +646,33 @@ export async function graphQLRentANode(data: string[]) { ) } +export async function graphQLRequestAdminPromotion(userId: string) { + return await graphQLBasicRequest( + ` mutation RequestAdminPromotion($userId: String!) { + requestAdminPromotion(userId: $userId) + }`, + { userId } + ) +} + +export async function graphQLConfirmAdminPromotion(token?: string, code?: string) { + return await graphQLBasicRequest( + ` mutation ConfirmAdminPromotion($token: String, $code: String) { + confirmAdminPromotion(token: $token, code: $code) + }`, + { token, code } + ) +} + +export async function graphQLRemoveAdmin(userId: string) { + return await graphQLBasicRequest( + ` mutation RemoveAdmin($userId: String!) { + removeAdmin(userId: $userId) + }`, + { userId } + ) +} + export async function graphQLAdminUpdateEmail(from: string, to: string) { return await graphQLBasicRequest( ` mutation UpdateEmail($from: String!, $to: String!) { diff --git a/frontend/src/services/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index 8a3c40e83..6d599aa26 100644 --- a/frontend/src/services/graphQLRequest.ts +++ b/frontend/src/services/graphQLRequest.ts @@ -410,17 +410,18 @@ export async function graphQLFetchOrganizations(ids: string[]) { export async function graphQLAdminUsers( options: { from?: number; size?: number }, - filters?: { search?: string; email?: string; accountId?: string }, + filters?: { search?: string; email?: string; accountId?: string; admin?: boolean }, sort?: string ) { return await graphQLBasicRequest( - ` query AdminUsers($from: Int, $size: Int, $search: String, $email: String, $accountId: String, $sort: String) { + ` query AdminUsers($from: Int, $size: Int, $search: String, $email: String, $accountId: String, $sort: String, $admin: Boolean) { admin { - users(from: $from, size: $size, search: $search, email: $email, accountId: $accountId, sort: $sort) { + users(from: $from, size: $size, search: $search, email: $email, accountId: $accountId, sort: $sort, admin: $admin) { items { id email created + admin info { devices { total @@ -438,6 +439,7 @@ export async function graphQLAdminUsers( search: filters?.search, email: filters?.email, accountId: filters?.accountId, + admin: filters?.admin, sort, } ) @@ -453,6 +455,7 @@ export async function graphQLAdminUser(accountId: string) { email created lastLogin + admin info { devices { total