diff --git a/electron/src/ElectronApp.ts b/electron/src/ElectronApp.ts index 113790bd7..c10647346 100644 --- a/electron/src/ElectronApp.ts +++ b/electron/src/ElectronApp.ts @@ -128,12 +128,9 @@ export default class ElectronApp { this.openWindow() } - private handleNavigate = (action: 'BACK' | 'FORWARD' | 'STATUS') => { + private handleNavigate = (action: 'BACK' | 'FORWARD' | 'STATUS' | 'CLEAR') => { if (!this.window) return - const canNavigate = { - canGoBack: this.window.webContents.navigationHistory.canGoBack, - canGoForward: this.window.webContents.navigationHistory.canGoForward, - } + switch (action) { case 'BACK': this.window.webContents.navigationHistory.goBack() @@ -141,6 +138,15 @@ export default class ElectronApp { case 'FORWARD': this.window.webContents.navigationHistory.goForward() break + case 'CLEAR': + this.window.webContents.navigationHistory.clear() + break + } + + // Get navigation state AFTER performing the action + const canNavigate = { + canGoBack: this.window.webContents.navigationHistory.canGoBack, + canGoForward: this.window.webContents.navigationHistory.canGoForward, } EventBus.emit(EVENTS.canNavigate, canNavigate) } diff --git a/frontend/src/buttons/RefreshButton/RefreshButton.tsx b/frontend/src/buttons/RefreshButton/RefreshButton.tsx index d69a501c1..db35c28e5 100644 --- a/frontend/src/buttons/RefreshButton/RefreshButton.tsx +++ b/frontend/src/buttons/RefreshButton/RefreshButton.tsx @@ -29,6 +29,9 @@ export const RefreshButton: React.FC = props => { const logsPage = useRouteMatch(['/logs', '/devices/:deviceID/logs']) const devicesPage = useRouteMatch('/devices') const productsPage = useRouteMatch('/products') + const partnerStatsPage = useRouteMatch('/partner-stats') + const adminUsersPage = useRouteMatch('/admin/users') + const adminPartnersPage = useRouteMatch('/admin/partners') const scriptingPage = useRouteMatch(['/script', '/scripts', '/runs']) const scriptPage = useRouteMatch('/script') @@ -85,6 +88,25 @@ export const RefreshButton: React.FC = props => { } else if (productsPage) { title = 'Refresh products' methods.push(dispatch.products.fetch) + + // partner stats pages + } else if (partnerStatsPage) { + title = 'Refresh partner stats' + methods.push(dispatch.partnerStats.fetch) + + // admin users pages + } else if (adminUsersPage) { + title = 'Refresh users' + methods.push(async () => { + window.dispatchEvent(new CustomEvent('refreshAdminData')) + }) + + // admin partners pages + } else if (adminPartnersPage) { + title = 'Refresh partners' + methods.push(async () => { + window.dispatchEvent(new CustomEvent('refreshAdminData')) + }) } const refresh = async () => { diff --git a/frontend/src/components/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx new file mode 100644 index 000000000..b48a9613a --- /dev/null +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { List, ListItemButton, ListItemIcon, ListItemText } from '@mui/material' +import { Icon } from './Icon' +import { State } from '../store' + +export const AdminSidebarNav: React.FC = () => { + const history = useHistory() + const defaultSelection = useSelector((state: State) => state.ui.defaultSelection) + const currentPath = history.location.pathname + + const handleNavClick = (baseRoute: string) => { + const adminSelection = defaultSelection['admin'] + const savedRoute = adminSelection?.[baseRoute] + history.push(savedRoute || baseRoute) + } + + return ( + + handleNavClick('/admin/users')} + > + + + + + + + handleNavClick('/admin/partners')} + > + + + + + + + ) +} + diff --git a/frontend/src/components/AvatarMenu.tsx b/frontend/src/components/AvatarMenu.tsx index dd6f43280..d5d66cf32 100644 --- a/frontend/src/components/AvatarMenu.tsx +++ b/frontend/src/components/AvatarMenu.tsx @@ -38,6 +38,8 @@ export const AvatarMenu: React.FC = () => { const backendAuthenticated = useSelector((state: State) => state.auth.backendAuthenticated) const licenseIndicator = useSelector(selectLicenseIndicator) const activeUser = useSelector(selectActiveUser) + const adminMode = useSelector((state: State) => state.ui.adminMode) + const userAdmin = useSelector((state: State) => state.auth.user?.admin || false) const css = useStyles() const handleOpen = () => { @@ -99,6 +101,33 @@ export const AvatarMenu: React.FC = () => { badge={licenseIndicator} onClick={handleClose} /> + {adminMode ? ( + { + await dispatch.ui.set({ adminMode: false }) + handleClose() + history.push('/devices') + }} + /> + ) : ( + userAdmin && ( + { + await dispatch.ui.set({ adminMode: true }) + handleClose() + history.push('/admin/users') + }} + /> + ) + )} { const manager = permissions.includes('MANAGE') const menu = location.pathname.match(REGEX_FIRST_PATH)?.[0] - const isRootMenu = menu === location.pathname + + // Admin pages have two-level roots: /admin/users and /admin/partners (without IDs) + const adminRootPages = ['/admin/users', '/admin/partners', '/partner-stats'] + const isAdminRootPage = adminRootPages.includes(location.pathname) + const isRootMenu = menu === location.pathname || isAdminRootPage return ( <> diff --git a/frontend/src/components/OrganizationSelect.tsx b/frontend/src/components/OrganizationSelect.tsx index 809c843b6..6da8fc349 100644 --- a/frontend/src/components/OrganizationSelect.tsx +++ b/frontend/src/components/OrganizationSelect.tsx @@ -19,7 +19,7 @@ export const OrganizationSelect: React.FC = () => { const history = useHistory() const location = useLocation() const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) - const { accounts, devices, files, tags, networks, logs, products } = useDispatch() + const { accounts, devices, files, tags, networks, logs, products, partnerStats } = useDispatch() let activeOrg = useSelector(selectOrganization) const defaultSelection = useSelector((state: State) => state.ui.defaultSelection) @@ -58,7 +58,8 @@ export const OrganizationSelect: React.FC = () => { files.fetchIfEmpty() tags.fetchIfEmpty() products.fetchIfEmpty() - if (!mobile && ['/devices', '/networks', '/connections', '/products'].includes(menu)) { + partnerStats.fetchIfEmpty() + if (!mobile && ['/devices', '/networks', '/connections', '/products', '/partner-stats'].includes(menu)) { history.push(defaultSelection[id]?.[menu] || menu) } } diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d24c72c63..14df0fc9c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -6,23 +6,27 @@ import { OrganizationSidebar } from './OrganizationSidebar' import { RemoteManagement } from './RemoteManagement' import { RegisterMenu } from './RegisterMenu' import { SidebarNav } from './SidebarNav' +import { AdminSidebarNav } from './AdminSidebarNav' import { AvatarMenu } from './AvatarMenu' import { spacing } from '../styling' import { Body } from './Body' +import { useSelector } from 'react-redux' +import { State } from '../store' export const Sidebar: React.FC<{ layout: ILayout }> = ({ layout }) => { const addSpace = browser.isMac && browser.isElectron && !layout.showOrgs + const adminMode = useSelector((state: State) => state.ui.adminMode) const css = useStyles({ insets: layout.insets, addSpace }) return ( - +
- + {!adminMode && }
- - + {adminMode ? : } + {!adminMode && }
) diff --git a/frontend/src/components/SidebarNav.tsx b/frontend/src/components/SidebarNav.tsx index d91a47d4a..4d07fbb65 100644 --- a/frontend/src/components/SidebarNav.tsx +++ b/frontend/src/components/SidebarNav.tsx @@ -1,20 +1,18 @@ -import React, { useState } from 'react' +import React from 'react' import browser from '../services/browser' import { makeStyles } from '@mui/styles' import { MOBILE_WIDTH } from '../constants' import { selectLimitsLookup } from '../selectors/organizations' import { selectDefaultSelectedPage } from '../selectors/ui' +import { selectActiveAccountId } from '../selectors/accounts' import { useSelector, useDispatch } from 'react-redux' import { State, Dispatch } from '../store' import { Box, Badge, List, - ListItemButton, Divider, - Typography, Tooltip, - Collapse, Chip, useMediaQuery, } from '@mui/material' @@ -22,13 +20,12 @@ import { ListItemLocation } from './ListItemLocation' import { UpgradeBanner } from './UpgradeBanner' import { ResellerLogo } from './ResellerLogo' import { ListItemLink } from './ListItemLink' -import { ExpandIcon } from './ExpandIcon' import { isRemoteUI } from '../helpers/uiHelper' import { useCounts } from '../hooks/useCounts' import { spacing } from '../styling' +import { getPartnerStatsModel } from '../models/partnerStats' export const SidebarNav: React.FC = () => { - const [more, setMore] = useState() const counts = useCounts() const reseller = useSelector((state: State) => state.user.reseller) const defaultSelectedPage = useSelector(selectDefaultSelectedPage) @@ -41,6 +38,10 @@ export const SidebarNav: React.FC = () => { const css = useStyles({ active: counts.active, insets }) const pathname = path => (rootPaths ? path : defaultSelectedPage[path] || path) + // Check if user has admin access to any partner entities + const partnerStatsModel = useSelector((state: State) => getPartnerStatsModel(state)) + const hasPartnerAdminAccess = partnerStatsModel.initialized && partnerStatsModel.all.length > 0 + if (remoteUI) return ( @@ -112,17 +113,17 @@ export const SidebarNav: React.FC = () => { dense /> + {hasPartnerAdminAccess && ( + dispatch.partnerStats.fetchIfEmpty()} + /> + )} - setMore(!more)} sx={{ marginTop: 2 }}> - - More - - - - - - diff --git a/frontend/src/hooks/useContainerWidth.ts b/frontend/src/hooks/useContainerWidth.ts new file mode 100644 index 000000000..bc0cb3b99 --- /dev/null +++ b/frontend/src/hooks/useContainerWidth.ts @@ -0,0 +1,30 @@ +import { useRef, useState, useEffect } from 'react' + +/** + * Hook to track the width of a container element using ResizeObserver + * @returns containerRef - ref to attach to the container element + * @returns containerWidth - current width of the container + */ +export function useContainerWidth() { + const containerRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(1000) + + useEffect(() => { + const updateWidth = () => { + if (containerRef.current) { + setContainerWidth(containerRef.current.offsetWidth) + } + } + + updateWidth() + + const resizeObserver = new ResizeObserver(updateWidth) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + + return () => resizeObserver.disconnect() + }, []) + + return { containerRef, containerWidth } +} diff --git a/frontend/src/hooks/useResizablePanel.ts b/frontend/src/hooks/useResizablePanel.ts new file mode 100644 index 000000000..b37900711 --- /dev/null +++ b/frontend/src/hooks/useResizablePanel.ts @@ -0,0 +1,63 @@ +import { useRef, useState, useCallback } from 'react' + +interface UseResizablePanelOptions { + /** Minimum width constraint for the panel */ + minWidth?: number + /** Maximum width available (e.g., fullWidth - otherPanelWidths) */ + maxWidthConstraint?: number +} + +/** + * Hook to handle drag-to-resize functionality for a panel + * @param defaultWidth - Initial width of the panel + * @param options - Configuration options + * @returns panelRef - ref to attach to the panel element + * @returns width - current width of the panel + * @returns grab - whether the user is currently dragging + * @returns onDown - mouseDown handler for the resize handle + */ +export function useResizablePanel( + defaultWidth: number, + containerRef?: React.RefObject, + options: UseResizablePanelOptions = {} +) { + const { minWidth = 250, maxWidthConstraint } = options + + const panelRef = useRef(null) + const handleRef = useRef(defaultWidth) + const moveRef = useRef(0) + const [width, setWidth] = useState(defaultWidth) + const [grab, setGrab] = useState(false) + + const onMove = useCallback((event: MouseEvent) => { + const fullWidth = containerRef?.current?.offsetWidth || 1000 + handleRef.current += event.clientX - moveRef.current + moveRef.current = event.clientX + + const maxConstraint = maxWidthConstraint !== undefined + ? maxWidthConstraint + : fullWidth - minWidth + + if (handleRef.current > minWidth && handleRef.current < maxConstraint) { + setWidth(handleRef.current) + } + }, [containerRef, maxWidthConstraint, minWidth]) + + const onUp = useCallback((event: MouseEvent) => { + setGrab(false) + event.preventDefault() + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + }, [onMove]) + + const onDown = (event: React.MouseEvent) => { + setGrab(true) + moveRef.current = event.clientX + handleRef.current = panelRef.current?.offsetWidth || width + event.preventDefault() + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + } + + return { panelRef, width, grab, onDown } +} diff --git a/frontend/src/models/adminPartners.ts b/frontend/src/models/adminPartners.ts new file mode 100644 index 000000000..ec00fde05 --- /dev/null +++ b/frontend/src/models/adminPartners.ts @@ -0,0 +1,135 @@ +import { createModel } from '@rematch/core' +import { graphQLAdminPartners, graphQLAdminPartner } from '../services/graphQLRequest' +import type { RootModel } from '.' + +interface AdminPartnerUser { + id: string + email: string + deviceCount: number + online: number + active: number + activated: number + updated?: Date +} + +interface AdminPartnerChild { + id: string + name: string + deviceCount: number + online: number + active: number + activated: number + admins?: AdminPartnerUser[] + registrants?: AdminPartnerUser[] +} + +interface AdminPartner { + id: string + name: string + parent?: { + id: string + name: string + deviceCount: number + online: number + active: number + activated: number + } + deviceCount: number + online: number + active: number + activated: number + updated?: Date + admins?: AdminPartnerUser[] + registrants?: AdminPartnerUser[] + children?: AdminPartnerChild[] +} + +interface AdminPartnersState { + partners: AdminPartner[] + loading: boolean + searchValue: string + detailCache: { [partnerId: string]: AdminPartner } +} + +const initialState: AdminPartnersState = { + partners: [], + loading: false, + searchValue: '', + detailCache: {} +} + +export const adminPartners = createModel()({ + name: 'adminPartners', + state: initialState, + reducers: { + setPartners: (state, partners: AdminPartner[]) => ({ + ...state, + partners, + loading: false + }), + setLoading: (state, loading: boolean) => ({ + ...state, + loading + }), + setSearchValue: (state, searchValue: string) => ({ + ...state, + searchValue + }), + cachePartnerDetail: (state, payload: { partnerId: string; partner: AdminPartner }) => ({ + ...state, + detailCache: { + ...state.detailCache, + [payload.partnerId]: payload.partner + } + }), + invalidatePartnerDetail: (state, partnerId: string) => { + const newCache = { ...state.detailCache } + delete newCache[partnerId] + return { + ...state, + detailCache: newCache + } + }, + reset: () => initialState + }, + effects: (dispatch) => ({ + async fetch() { + dispatch.adminPartners.setLoading(true) + + const result = await graphQLAdminPartners() + + if (result !== 'ERROR' && result?.data?.data?.admin?.partners) { + const partners = result.data.data.admin.partners + dispatch.adminPartners.setPartners(partners) + + // Cache all partner details + partners.forEach((partner: AdminPartner) => { + dispatch.adminPartners.cachePartnerDetail({ partnerId: partner.id, partner }) + }) + } else { + dispatch.adminPartners.setLoading(false) + } + }, + async fetchIfEmpty(_payload, rootState) { + if (rootState.adminPartners.partners.length === 0) { + await dispatch.adminPartners.fetch() + } + }, + async fetchPartnerDetail(partnerId: string, rootState) { + // Check cache first + const cached = rootState.adminPartners.detailCache[partnerId] + if (cached) { + return cached + } + + // Fetch from API + const result = await graphQLAdminPartner(partnerId) + if (result !== 'ERROR' && result?.data?.data?.admin?.partners?.[0]) { + const partner = result.data.data.admin.partners[0] + dispatch.adminPartners.cachePartnerDetail({ partnerId, partner }) + return partner + } + return null + } + }) +}) diff --git a/frontend/src/models/adminUsers.ts b/frontend/src/models/adminUsers.ts new file mode 100644 index 000000000..5d0f1ac0c --- /dev/null +++ b/frontend/src/models/adminUsers.ts @@ -0,0 +1,144 @@ +import { createModel } from '@rematch/core' +import { graphQLAdminUsers, graphQLAdminUser } from '../services/graphQLRequest' +import type { RootModel } from '.' + +interface AdminUser { + id: string + email: string + created: string + lastLogin?: string + [key: string]: any +} + +interface AdminUsersState { + users: AdminUser[] + total: number + loading: boolean + page: number + pageSize: number + searchValue: string + searchType: 'all' | 'email' | 'userId' + detailCache: { [userId: string]: AdminUser } +} + +const initialState: AdminUsersState = { + users: [], + total: 0, + loading: false, + page: 1, + pageSize: 50, + searchValue: '', + searchType: 'email', + detailCache: {} +} + +export const adminUsers = createModel()({ + name: 'adminUsers', + state: initialState, + reducers: { + setUsers: (state, payload: { users: AdminUser[]; total: number }) => ({ + ...state, + users: payload.users, + total: payload.total, + loading: false + }), + setLoading: (state, loading: boolean) => ({ + ...state, + loading + }), + setPage: (state, page: number) => ({ + ...state, + page + }), + setSearch: (state, payload: { searchValue: string; searchType: 'all' | 'email' | 'userId' }) => ({ + ...state, + searchValue: payload.searchValue, + searchType: payload.searchType, + page: 1 // Reset to first page on new search + }), + cacheUserDetail: (state, payload: { userId: string; user: AdminUser }) => ({ + ...state, + detailCache: { + ...state.detailCache, + [payload.userId]: payload.user + } + }), + invalidateUserDetail: (state, userId: string) => { + const newCache = { ...state.detailCache } + delete newCache[userId] + return { + ...state, + detailCache: newCache + } + }, + reset: () => initialState + }, + effects: (dispatch) => ({ + async fetch(_payload, rootState) { + const state = rootState.adminUsers + dispatch.adminUsers.setLoading(true) + + // Build filters based on search type + 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: (state.page - 1) * state.pageSize, 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.setUsers({ + users, + total: data.total || 0 + }) + + // Cache user details for users in the list + users.forEach((user: AdminUser) => { + dispatch.adminUsers.cacheUserDetail({ userId: user.id, user }) + }) + } else { + dispatch.adminUsers.setLoading(false) + } + }, + async fetchIfEmpty(_payload, rootState) { + if (rootState.adminUsers.users.length === 0) { + await dispatch.adminUsers.fetch() + } + }, + async fetchUserDetail(userId: string, rootState) { + // Check cache first + const cached = rootState.adminUsers.detailCache[userId] + if (cached) { + return cached + } + + // Fetch from API + const result = await graphQLAdminUser(userId) + if (result !== 'ERROR' && result?.data?.data?.admin?.users?.items?.[0]) { + const user = result.data.data.admin.users.items[0] + dispatch.adminUsers.cacheUserDetail({ userId, user }) + return user + } + return null + } + }) +}) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 72fc05351..dcceaf621 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -74,8 +74,8 @@ const authServiceConfig = (): ConfigInterface => ({ signoutCallbackURL: browser.isPortal ? window.origin : browser.isElectron || browser.isMobile - ? SIGNOUT_REDIRECT_URL - : CALLBACK_URL, + ? SIGNOUT_REDIRECT_URL + : CALLBACK_URL, }) export default createModel()({ @@ -265,6 +265,9 @@ export default createModel()({ dispatch.mfa.reset() dispatch.ui.reset() dispatch.products.reset() + dispatch.partnerStats.reset() + dispatch.adminUsers.reset() + dispatch.adminPartners.reset() cloudSync.reset() dispatch.accounts.set({ activeId: undefined }) diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index 607e2561c..315b0c70a 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -1,5 +1,7 @@ import { Models } from '@rematch/core' import accounts from './accounts' +import { adminPartners } from './adminPartners' +import { adminUsers } from './adminUsers' import announcements from './announcements' import applicationTypes from './applicationTypes' import auth from './auth' @@ -19,6 +21,7 @@ import logs from './logs' import mfa from './mfa' import networks from './networks' import organization from './organization' +import partnerStats from './partnerStats' import plans from './plans' import products from './products' import search from './search' @@ -30,6 +33,8 @@ import user from './user' export interface RootModel extends Models { accounts: typeof accounts + adminPartners: typeof adminPartners + adminUsers: typeof adminUsers announcements: typeof announcements applicationTypes: typeof applicationTypes auth: typeof auth @@ -49,6 +54,7 @@ export interface RootModel extends Models { mfa: typeof mfa networks: typeof networks organization: typeof organization + partnerStats: typeof partnerStats plans: typeof plans products: typeof products search: typeof search @@ -61,6 +67,8 @@ export interface RootModel extends Models { export const models: RootModel = { accounts, + adminPartners, + adminUsers, announcements, applicationTypes, auth, @@ -80,6 +88,7 @@ export const models: RootModel = { mfa, networks, organization, + partnerStats, plans, products, search, diff --git a/frontend/src/models/partnerStats.ts b/frontend/src/models/partnerStats.ts new file mode 100644 index 000000000..26a9b9915 --- /dev/null +++ b/frontend/src/models/partnerStats.ts @@ -0,0 +1,174 @@ +import { createModel } from '@rematch/core' +import { RootModel } from '.' +import { graphQLPartnerEntities } from '../services/graphQLRequest' +import { graphQLGetErrors } from '../services/graphQL' +import { selectActiveAccountId } from '../selectors/accounts' +import { State } from '../store' + +export interface IPartnerEntityUser { + id: string + email: string + deviceCount: number + online: number + active: number + activated: number + updated?: Date +} + +export interface IPartnerEntity { + id: string + name: string + parent?: { + id: string + name: string + deviceCount: number + online: number + active: number + activated: number + } | null + deviceCount: number + online: number + active: number + activated: number + updated?: Date + admins?: IPartnerEntityUser[] + registrants?: IPartnerEntityUser[] + children?: IPartnerEntity[] +} + +export type PartnerStatsState = { + initialized: boolean + fetching: boolean + all: IPartnerEntity[] + flattened: IPartnerEntity[] // All partners including children +} + +export const defaultState: PartnerStatsState = { + initialized: false, + fetching: false, + all: [], + flattened: [], +} + +type PartnerStatsAccountState = { + [accountId: string]: PartnerStatsState +} + +const defaultAccountState: PartnerStatsAccountState = { + default: { ...defaultState }, +} + +// Helper to get partner stats model for a specific account +export function getPartnerStatsModel(state: State, accountId?: string): PartnerStatsState { + const activeAccountId = selectActiveAccountId(state) + return state.partnerStats[accountId || activeAccountId] || state.partnerStats.default || defaultState +} + +// Helper to flatten partner entities (including children) +function flattenPartners(entities: IPartnerEntity[]): IPartnerEntity[] { + const allPartners: IPartnerEntity[] = [] + const addedIds = new Set() + + entities.forEach((entity: IPartnerEntity) => { + if (!addedIds.has(entity.id)) { + allPartners.push(entity) + addedIds.add(entity.id) + } + + // Add children if they exist and haven't been added + if (entity.children) { + entity.children.forEach((child: IPartnerEntity) => { + if (!addedIds.has(child.id)) { + // Add parent reference to child + const childWithParent = { + ...child, + parent: { + id: entity.id, + name: entity.name, + deviceCount: entity.deviceCount, + online: entity.online, + active: entity.active, + activated: entity.activated, + } + } + allPartners.push(childWithParent) + addedIds.add(child.id) + } + + // Add grandchildren + if (child.children) { + child.children.forEach((grandchild: IPartnerEntity) => { + if (!addedIds.has(grandchild.id)) { + // Add parent reference to grandchild + const grandchildWithParent = { + ...grandchild, + parent: { + id: child.id, + name: child.name, + deviceCount: child.deviceCount, + online: child.online, + active: child.active, + activated: child.activated, + } + } + allPartners.push(grandchildWithParent) + addedIds.add(grandchild.id) + } + }) + } + }) + } + }) + + return allPartners +} + +export default createModel()({ + state: { ...defaultAccountState }, + effects: dispatch => ({ + async fetch(_: void, state) { + const accountId = selectActiveAccountId(state) + dispatch.partnerStats.set({ fetching: true, accountId }) + const response = await graphQLPartnerEntities(accountId) + if (!graphQLGetErrors(response)) { + const entities = response?.data?.data?.login?.account?.partnerEntities || [] + const flattened = flattenPartners(entities) + dispatch.partnerStats.set({ all: entities, flattened, initialized: true, accountId }) + } + dispatch.partnerStats.set({ fetching: false, accountId }) + }, + + async fetchIfEmpty(_: void, state) { + const accountId = selectActiveAccountId(state) + const partnerStatsModel = getPartnerStatsModel(state, accountId) + // Only fetch if not initialized for this account + if (!partnerStatsModel.initialized) { + await dispatch.partnerStats.fetch() + } + }, + + // Set effect that updates state for a specific account + async set(params: Partial & { accountId?: string }, state) { + const accountId = params.accountId || selectActiveAccountId(state) + const partnerStatsState = { ...getPartnerStatsModel(state, accountId) } + + Object.keys(params).forEach(key => { + if (key !== 'accountId') { + partnerStatsState[key] = params[key] + } + }) + + await dispatch.partnerStats.rootSet({ [accountId]: partnerStatsState }) + }, + }), + reducers: { + reset(state: PartnerStatsAccountState) { + state = { ...defaultAccountState } + return state + }, + rootSet(state: PartnerStatsAccountState, params: PartnerStatsAccountState) { + Object.keys(params).forEach(key => (state[key] = params[key])) + return state + }, + }, +}) diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts index 1db7bcebe..60a6b9a89 100644 --- a/frontend/src/models/products.ts +++ b/frontend/src/models/products.ts @@ -8,6 +8,7 @@ import { graphQLUpdateDeviceProductSettings, graphQLAddDeviceProductService, graphQLRemoveDeviceProductService, + graphQLTransferDeviceProduct, } from '../services/graphQLDeviceProducts' import { graphQLGetErrors } from '../services/graphQL' import { selectActiveAccountId } from '../selectors/accounts' @@ -27,6 +28,7 @@ export interface IDeviceProduct { platform: { id: number; name: string | null } | null status: 'NEW' | 'LOCKED' registrationCode?: string + registrationCommand?: string created: string updated: string services: IProductService[] @@ -87,9 +89,10 @@ export default createModel()({ async fetchSingle(id: string, state) { const accountId = selectActiveAccountId(state) dispatch.products.set({ fetching: true, accountId }) - const response = await graphQLDeviceProduct(id) + const response = await graphQLDeviceProduct(id, accountId) if (!graphQLGetErrors(response)) { - const product = response?.data?.data?.deviceProduct + const items = response?.data?.data?.login?.account?.deviceProducts?.items || [] + const product = items[0] if (product) { const productModel = getProductModel(state, accountId) const exists = productModel.all.some(p => p.id === id) @@ -149,7 +152,7 @@ export default createModel()({ ) const successIds = selected.filter((id, i) => !graphQLGetErrors(results[i])) - + dispatch.products.set({ all: all.filter(p => !successIds.includes(p.id)), selected: [], @@ -237,6 +240,34 @@ export default createModel()({ return false }, + async transferProduct({ productId, email }: { productId: string; email: string }, state) { + const accountId = selectActiveAccountId(state) + const productModel = getProductModel(state, accountId) + const product = productModel.all.find(p => p.id === productId) + + if (!product) return false + + dispatch.ui.set({ transferring: true }) + const response = await graphQLTransferDeviceProduct(productId, email) + + if (!graphQLGetErrors(response)) { + // Remove product from local state + dispatch.products.set({ + all: productModel.all.filter(p => p.id !== productId), + selected: productModel.selected.filter(s => s !== productId), + accountId, + }) + dispatch.ui.set({ + successMessage: `"${product.name}" was successfully transferred to ${email}.`, + }) + dispatch.ui.set({ transferring: false }) + return true + } + + dispatch.ui.set({ transferring: false }) + return false + }, + // Set effect that updates state for a specific account async set(params: Partial & { accountId?: string }, state) { const accountId = params.accountId || selectActiveAccountId(state) diff --git a/frontend/src/models/ui.ts b/frontend/src/models/ui.ts index 4c5c05bee..28f410968 100644 --- a/frontend/src/models/ui.ts +++ b/frontend/src/models/ui.ts @@ -107,6 +107,7 @@ export type UIState = { mobileWelcome: boolean showDesktopNotice: boolean scriptForm?: IFileForm + adminMode: boolean } export const defaultState: UIState = { @@ -211,6 +212,7 @@ export const defaultState: UIState = { mobileWelcome: true, showDesktopNotice: true, scriptForm: undefined, + adminMode: false, } export default createModel()({ @@ -296,11 +298,11 @@ export default createModel()({ all[deviceId] = serviceId dispatch.ui.setPersistent({ defaultService: all }) }, - async setDefaultSelected({ key, value }: { key: string; value?: string }, state) { - const accountId = selectActiveAccountId(state) + async setDefaultSelected({ key, value, accountId }: { key: string; value?: string; accountId?: string }, state) { + const id = accountId || selectActiveAccountId(state) let defaultSelection = structuredClone(state.ui.defaultSelection) - defaultSelection[accountId] = defaultSelection[accountId] || {} - defaultSelection[accountId][key] = value + defaultSelection[id] = defaultSelection[id] || {} + defaultSelection[id][key] = value dispatch.ui.set({ defaultSelection }) }, async setPersistent(params: ILookup, state) { diff --git a/frontend/src/models/user.ts b/frontend/src/models/user.ts index 1298d1577..9b80cbd28 100644 --- a/frontend/src/models/user.ts +++ b/frontend/src/models/user.ts @@ -14,6 +14,7 @@ type IUserState = { reseller: IResellerRef | null language: string attributes: ILookup + admin: boolean } const defaultState: IUserState = { @@ -24,6 +25,7 @@ const defaultState: IUserState = { reseller: null, language: 'en', attributes: {}, + admin: false, } export default createModel()({ diff --git a/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx new file mode 100644 index 000000000..06f6cc2ab --- /dev/null +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx @@ -0,0 +1,570 @@ +import React, { useEffect, useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useDispatch } from 'react-redux' +import { + Typography, List, ListItem, ListItemText, ListItemButton, ListItemIcon, Box, Divider, Button, + Dialog, DialogTitle, DialogContent, DialogActions, TextField, Select, MenuItem, FormControl, InputLabel, + IconButton as MuiIconButton +} from '@mui/material' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { IconButton } from '../../buttons/IconButton' +import { CopyIconButton } from '../../buttons/CopyIconButton' +import { LoadingMessage } from '../../components/LoadingMessage' +import { + graphQLAdminPartners, + graphQLAddPartnerAdmin, + graphQLRemovePartnerAdmin, + graphQLAddPartnerRegistrant, + graphQLRemovePartnerRegistrant, + graphQLAddPartnerChild, + graphQLRemovePartnerChild, + graphQLDeletePartner, + graphQLExportPartnerDevices +} from '../../services/graphQLRequest' +import { windowOpen } from '../../services/browser' +import { spacing } from '../../styling' +import { Dispatch } from '../../store' + +export const AdminPartnerDetailPanel: React.FC = () => { + const { partnerId } = useParams<{ partnerId: string }>() + const history = useHistory() + const dispatch = useDispatch() + const [partner, setPartner] = useState(null) + const [loading, setLoading] = useState(true) + const [addAdminDialogOpen, setAddAdminDialogOpen] = useState(false) + const [newAdminEmail, setNewAdminEmail] = useState('') + const [addingAdmin, setAddingAdmin] = useState(false) + const [removingAdmin, setRemovingAdmin] = useState(null) + const [addChildDialogOpen, setAddChildDialogOpen] = useState(false) + const [availablePartners, setAvailablePartners] = useState([]) + const [selectedChildId, setSelectedChildId] = useState('') + const [addingChild, setAddingChild] = useState(false) + const [removingChild, setRemovingChild] = useState(null) + const [deleting, setDeleting] = useState(false) + const [exporting, setExporting] = useState(false) + const [addRegistrantDialogOpen, setAddRegistrantDialogOpen] = useState(false) + const [newRegistrantEmail, setNewRegistrantEmail] = useState('') + const [addingRegistrant, setAddingRegistrant] = useState(false) + const [removingRegistrant, setRemovingRegistrant] = useState(null) + + useEffect(() => { + if (partnerId) { + fetchPartner() + } + }, [partnerId]) + + const fetchPartner = async (forceRefresh = false) => { + setLoading(true) + if (forceRefresh) { + dispatch.adminPartners.invalidatePartnerDetail(partnerId) + } + const partnerData = await dispatch.adminPartners.fetchPartnerDetail(partnerId) + setPartner(partnerData) + setLoading(false) + } + + const handleBack = () => { + history.push('/admin/partners') + } + + const handleNavigateToPartner = (id: string) => { + history.push(`/admin/partners/${id}`) + } + + const handleNavigateToUser = (userId: string) => { + history.push(`/admin/users/${userId}`) + } + + const handleAddAdmin = async () => { + if (!newAdminEmail) return + + setAddingAdmin(true) + const result = await graphQLAddPartnerAdmin(partnerId, newAdminEmail) + setAddingAdmin(false) + + if (result !== 'ERROR') { + setAddAdminDialogOpen(false) + setNewAdminEmail('') + fetchPartner(true) + } else { + alert('Failed to add admin.') + } + } + + const handleRemoveAdmin = async (userId: string) => { + if (!confirm('Are you sure you want to remove this admin?')) return + + setRemovingAdmin(userId) + const result = await graphQLRemovePartnerAdmin(partnerId, userId) + setRemovingAdmin(null) + + if (result !== 'ERROR') { + fetchPartner(true) + } else { + alert('Failed to remove admin.') + } + } + + const handleAddRegistrant = async () => { + if (!newRegistrantEmail) return + + setAddingRegistrant(true) + const result = await graphQLAddPartnerRegistrant(partnerId, newRegistrantEmail) + setAddingRegistrant(false) + + if (result !== 'ERROR') { + setAddRegistrantDialogOpen(false) + setNewRegistrantEmail('') + fetchPartner(true) + } else { + alert('Failed to add registrant. They may already have access to this entity.') + } + } + + const handleRemoveRegistrant = async (userId: string) => { + if (!confirm('Are you sure you want to remove this registrant?')) return + + setRemovingRegistrant(userId) + const result = await graphQLRemovePartnerRegistrant(partnerId, userId) + setRemovingRegistrant(null) + + if (result !== 'ERROR') { + fetchPartner(true) + } else { + alert('Failed to remove registrant.') + } + } + + const handleOpenAddChildDialog = async () => { + setAddChildDialogOpen(true) + // Fetch all partners to show as options + const result = await graphQLAdminPartners() + if (result !== 'ERROR' && result?.data?.data?.admin?.partners) { + // Filter out current partner, its children, and its parent + const childIds = children.map((c: any) => c.id) + const filtered = result.data.data.admin.partners.filter((p: any) => + p.id !== partnerId && + !childIds.includes(p.id) && + p.id !== partner.parent?.id + ) + setAvailablePartners(filtered) + } + } + + const handleAddChild = async () => { + if (!selectedChildId) return + + setAddingChild(true) + const result = await graphQLAddPartnerChild(partnerId, selectedChildId) + setAddingChild(false) + + if (result !== 'ERROR') { + setAddChildDialogOpen(false) + setSelectedChildId('') + fetchPartner(true) + } else { + alert('Failed to add child partner.') + } + } + + const handleRemoveChild = async (childId: string) => { + if (!confirm('Remove this child partner? It will become a top-level partner.')) return + + setRemovingChild(childId) + const result = await graphQLRemovePartnerChild(childId) + setRemovingChild(null) + + if (result !== 'ERROR') { + fetchPartner(true) + } else { + alert('Failed to remove child partner.') + } + } + + const handleDeletePartner = async () => { + const childCount = children.length + const message = childCount > 0 + ? `Delete this partner? Its ${childCount} child partner(s) will become top-level partners.` + : 'Delete this partner? This action cannot be undone.' + + if (!confirm(message)) return + + setDeleting(true) + const result = await graphQLDeletePartner(partnerId) + setDeleting(false) + + if (result !== 'ERROR') { + history.push('/admin/partners') + } else { + alert('Failed to delete partner.') + } + } + + const handleExportDevices = async () => { + setExporting(true) + const result = await graphQLExportPartnerDevices(partnerId) + setExporting(false) + + if (result !== 'ERROR' && result?.data?.data?.exportPartnerDevices) { + const url = result.data.data.exportPartnerDevices + windowOpen(url) + } else { + alert('Failed to export devices.') + } + } + + if (loading) { + return ( + + + + ) + } + + if (!partner) { + return ( + + + + + Partner not found + + + + ) + } + + const admins = partner.admins || [] + const registrants = partner.registrants || [] + const children = partner.children || [] + + return ( + + + + + fetchPartner(true)} + spin={loading} + size="md" + /> + + + + + + + + + {partner.name} + + + + {partner.id} + + + + + + + } + > + {/* Parent Partner */} + {partner.parent && ( + <> + + Parent Partner + + + handleNavigateToPartner(partner.parent.id)}> + + + + + + + + + )} + + {/* Device Counts */} + + Device Summary + + + + + + + + + + + + + + + + + + + + {/* Children Partners */} + <> + + + Child Partners ({children.length}) + + + + + + + {/* Add Registrant Dialog */} + setAddRegistrantDialogOpen(false)} maxWidth="sm" fullWidth> + Add Registrant to Partner + + setNewRegistrantEmail(e.target.value)} + sx={{ marginTop: 2 }} + /> + + + + + + + + {/* Add Child Dialog */} + setAddChildDialogOpen(false)} maxWidth="sm" fullWidth> + Add Child Partner + + + Select Partner + + + + + + + + + + ) +} + diff --git a/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx new file mode 100644 index 000000000..5a9a038fc --- /dev/null +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx @@ -0,0 +1,236 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { useDispatch, useSelector } from 'react-redux' +import { + Typography, Box, TextField, InputAdornment, Stack, Button, + Dialog, DialogTitle, DialogContent, DialogActions, Select, MenuItem, FormControl, InputLabel +} from '@mui/material' +import { Container } from '../../components/Container' +import { Icon } from '../../components/Icon' +import { IconButton } from '../../buttons/IconButton' +import { LoadingMessage } from '../../components/LoadingMessage' +import { graphQLCreatePartner } from '../../services/graphQLRequest' +import { Gutters } from '../../components/Gutters' +import { GridList } from '../../components/GridList' +import { GridListItem } from '../../components/GridListItem' +import { Attribute } from '../../components/Attributes' +import { State, Dispatch } from '../../store' +import { makeStyles } from '@mui/styles' +import { removeObject } from '../../helpers/utilHelper' + +class AdminPartnerAttribute extends Attribute { + type: Attribute['type'] = 'MASTER' +} + +const adminPartnerAttributes: Attribute[] = [ + new AdminPartnerAttribute({ + id: 'partnerName', + label: 'Name', + defaultWidth: 250, + required: true, + value: ({ partner }: { partner: any }) => partner?.name || partner?.id, + }), + new AdminPartnerAttribute({ + id: 'partnerDevicesTotal', + label: 'Devices', + defaultWidth: 80, + value: ({ partner }: { partner: any }) => partner?.deviceCount || 0, + }), + new AdminPartnerAttribute({ + id: 'partnerActivated', + label: 'Activated', + defaultWidth: 100, + value: ({ partner }: { partner: any }) => partner?.activated || 0, + }), + new AdminPartnerAttribute({ + id: 'partnerActive', + label: 'Active', + defaultWidth: 80, + value: ({ partner }: { partner: any }) => partner?.active || 0, + }), + new AdminPartnerAttribute({ + id: 'partnerOnline', + label: 'Online', + defaultWidth: 80, + value: ({ partner }: { partner: any }) => partner?.online || 0, + }), +] + +export const AdminPartnersListPage: React.FC = () => { + const history = useHistory() + const location = useLocation() + const dispatch = useDispatch() + const css = useStyles() + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const [required, attributes] = removeObject(adminPartnerAttributes, a => a.required === true) + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [newPartnerName, setNewPartnerName] = useState('') + const [newPartnerParentId, setNewPartnerParentId] = useState('') + const [creating, setCreating] = useState(false) + + // Get state from Redux + const partners = useSelector((state: State) => state.adminPartners.partners) + const loading = useSelector((state: State) => state.adminPartners.loading) + const searchValue = useSelector((state: State) => state.adminPartners.searchValue) + + useEffect(() => { + dispatch.adminPartners.fetchIfEmpty() + }, []) + + useEffect(() => { + const handleRefresh = () => { + dispatch.adminPartners.fetch() + } + window.addEventListener('refreshAdminData', handleRefresh) + return () => window.removeEventListener('refreshAdminData', handleRefresh) + }, []) + + const filteredPartners = useMemo(() => { + if (!searchValue.trim()) return partners + const search = searchValue.toLowerCase() + return partners.filter(partner => + partner.name?.toLowerCase().includes(search) || + partner.id?.toLowerCase().includes(search) + ) + }, [partners, searchValue]) + + const handlePartnerClick = (partnerId: string) => { + dispatch.ui.setDefaultSelected({ key: '/admin/partners', value: `/admin/partners/${partnerId}`, accountId: 'admin' }) + history.push(`/admin/partners/${partnerId}`) + } + + const handleCreatePartner = async () => { + if (!newPartnerName.trim()) return + + setCreating(true) + const result = await graphQLCreatePartner(newPartnerName, newPartnerParentId || undefined) + setCreating(false) + + if (result !== 'ERROR' && result?.data?.data?.createPartner) { + setCreateDialogOpen(false) + setNewPartnerName('') + setNewPartnerParentId('') + dispatch.adminPartners.fetch() + // Navigate to new partner + history.push(`/admin/partners/${result.data.data.createPartner.id}`) + } else { + alert('Failed to create partner.') + } + } + + return ( + + + + + + + + ) +} + +const useStyles = makeStyles(() => ({ + truncate: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + flex: 1, + }, +})) + diff --git a/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx new file mode 100644 index 000000000..602b78156 --- /dev/null +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useRef } from 'react' +import { useParams, useHistory, useLocation } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Box } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { AdminPartnersListPage } from './AdminPartnersListPage' +import { AdminPartnerDetailPanel } from './AdminPartnerDetailPanel' +import { State } from '../../store' +import { useContainerWidth } from '../../hooks/useContainerWidth' +import { useResizablePanel } from '../../hooks/useResizablePanel' + +const MIN_WIDTH = 250 +const TWO_PANEL_WIDTH = 700 +const DEFAULT_LEFT_WIDTH = 350 + +export const AdminPartnersPage: React.FC = () => { + const { partnerId } = useParams<{ partnerId?: string }>() + const history = useHistory() + const location = useLocation() + const css = useStyles() + const layout = useSelector((state: State) => state.ui.layout) + const defaultSelection = useSelector((state: State) => state.ui.defaultSelection) + const hasRestoredRef = useRef(false) + + const { containerRef, containerWidth } = useContainerWidth() + const leftPanel = useResizablePanel(DEFAULT_LEFT_WIDTH, containerRef, { + minWidth: MIN_WIDTH, + maxWidthConstraint: containerWidth - MIN_WIDTH, + }) + + const maxPanels = layout.singlePanel ? 1 : (containerWidth >= TWO_PANEL_WIDTH ? 2 : 1) + + // Restore previously selected partner ONLY on initial mount + useEffect(() => { + if (!hasRestoredRef.current) { + const adminSelection = defaultSelection['admin'] + const savedRoute = adminSelection?.['/admin/partners'] + if (location.pathname === '/admin/partners' && savedRoute) { + history.replace(savedRoute) + } + hasRestoredRef.current = true + } + }, []) // Empty dependency array - only run once on mount + + const hasPartnerSelected = !!partnerId + const showLeft = !hasPartnerSelected || maxPanels >= 2 + const showRight = hasPartnerSelected + + return ( + + + {showLeft && ( + <> + + + + + {hasPartnerSelected && ( + + + + + + )} + + )} + + {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: { + flex: 1, + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + minWidth: MIN_WIDTH, + }, + 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/AdminUsersPage/AdminUserAccountPanel.tsx b/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx new file mode 100644 index 000000000..6d4dc8978 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { useDispatch } from 'react-redux' +import { Typography, List, ListItem, ListItemText, Box, Divider } from '@mui/material' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { CopyIconButton } from '../../buttons/CopyIconButton' +import { LoadingMessage } from '../../components/LoadingMessage' +import { Dispatch } from '../../store' + +export const AdminUserAccountPanel: React.FC = () => { + const { userId } = useParams<{ userId: string }>() + const dispatch = useDispatch() + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (userId) { + fetchUser() + } + }, [userId]) + + const fetchUser = async () => { + setLoading(true) + const userData = await dispatch.adminUsers.fetchUserDetail(userId) + setUser(userData) + setLoading(false) + } + + if (loading) { + return ( + + + + ) + } + + if (!user) { + return ( + + + + + User not found + + + + ) + } + + const deviceCount = user.info?.devices?.total || 0 + const deviceOnline = user.info?.devices?.online || 0 + const deviceOffline = deviceCount - deviceOnline + + return ( + + Account Details + + } + > + + + + + {user.id} + + + + } + /> + + + + + + + + + {user.organization?.name && ( + <> + + + + + + )} + + + + + + + + + + + + + Device Summary + + + + + + + + ) +} + diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx new file mode 100644 index 000000000..8e6afa261 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useDispatch } from 'react-redux' +import { Typography, List, ListItemText, Box, Divider } from '@mui/material' +import { Container } from '../../components/Container' +import { ListItemLocation } from '../../components/ListItemLocation' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { IconButton } from '../../buttons/IconButton' +import { LoadingMessage } from '../../components/LoadingMessage' +import { spacing } from '../../styling' +import { Dispatch } from '../../store' + +type Props = { + showRefresh?: boolean +} + +export const AdminUserDetailPage: React.FC = ({ showRefresh = true }) => { + const { userId } = useParams<{ userId: string }>() + const history = useHistory() + const dispatch = useDispatch() + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (userId) { + fetchUser() + } + }, [userId]) + + const fetchUser = async (forceRefresh = false) => { + setLoading(true) + if (forceRefresh) { + dispatch.adminUsers.invalidateUserDetail(userId) + } + const userData = await dispatch.adminUsers.fetchUserDetail(userId) + setUser(userData) + setLoading(false) + } + + const handleBack = () => { + history.push('/admin/users') + } + + if (loading) { + return ( + + + + ) + } + + if (!user) { + return ( + + + + + User not found + + + + ) + } + + const deviceCount = user.info?.devices?.total || 0 + const deviceOnline = user.info?.devices?.online || 0 + + return ( + + + + {showRefresh && ( + fetchUser(true)} + spin={loading} + size="md" + /> + )} + + + } + title={{user.email || user.id}} + /> + + + + } + > + + Devices + + + } + > + + + + + ) +} diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDevicesPanel.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDevicesPanel.tsx new file mode 100644 index 000000000..53ec51f71 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AdminUserDevicesPanel.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Typography, Box } from '@mui/material' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { LoadingMessage } from '../../components/LoadingMessage' +import { GridList } from '../../components/GridList' +import { GridListItem } from '../../components/GridListItem' +import { Attribute } from '../../components/Attributes' +import { TargetPlatform } from '../../components/TargetPlatform' +import { StatusChip } from '../../components/StatusChip' +import { Timestamp } from '../../components/Timestamp' +import { graphQLAdminUserDevices } from '../../services/graphQLRequest' +import { State } from '../../store' +import { makeStyles } from '@mui/styles' +import { removeObject } from '../../helpers/utilHelper' + +class AdminDeviceAttribute extends Attribute { + type: Attribute['type'] = 'DEVICE' +} + +const adminDeviceAttributes: Attribute[] = [ + new AdminDeviceAttribute({ + id: 'adminDeviceName', + label: 'Name', + defaultWidth: 250, + required: true, + value: ({ device }: { device: any }) => device?.name || device?.id, + }), + new AdminDeviceAttribute({ + id: 'adminDeviceStatus', + label: 'Status', + defaultWidth: 100, + value: ({ device }: { device: any }) => ( + + ), + }), + new AdminDeviceAttribute({ + id: 'adminDevicePlatform', + label: 'Platform', + defaultWidth: 150, + value: ({ device }: { device: any }) => TargetPlatform({ id: device?.platform, label: true }), + }), + new AdminDeviceAttribute({ + id: 'adminDeviceServices', + label: 'Services', + defaultWidth: 80, + value: ({ device }: { device: any }) => device?.services?.length || 0, + }), + new AdminDeviceAttribute({ + id: 'adminDeviceLastReported', + label: 'Last Reported', + defaultWidth: 150, + value: ({ device }: { device: any }) => device?.lastReported ? : '-', + }), +] + +export const AdminUserDevicesPanel: React.FC = () => { + const { userId } = useParams<{ userId: string }>() + const css = useStyles() + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const [required, attributes] = removeObject(adminDeviceAttributes, a => a.required === true) + + useEffect(() => { + if (userId) { + fetchDevices() + } + }, [userId]) + + const fetchDevices = async () => { + setLoading(true) + const result = await graphQLAdminUserDevices(userId, { from: 0, size: 100 }) + if (result !== 'ERROR' && result?.data?.data?.login?.account?.devices) { + const data = result.data.data.login.account.devices + setDevices(data.items || []) + setTotal(data.total || 0) + } + setLoading(false) + } + + if (loading) { + return ( + + + + ) + } + + return ( + + User Devices ({total}) + + } + > + {devices.length === 0 ? ( + + + + No devices found + + + ) : ( + + {devices.map(device => ( + } + required={required?.value({ device })} + > + {attributes.map(attribute => ( + +
+ {attribute.value({ device })} +
+
+ ))} +
+ ))} +
+ )} +
+ ) +} + +const useStyles = makeStyles(() => ({ + truncate: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + flex: 1, + }, +})) + diff --git a/frontend/src/pages/AdminUsersPage/AdminUserListItem.tsx b/frontend/src/pages/AdminUsersPage/AdminUserListItem.tsx new file mode 100644 index 000000000..2ca95b050 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AdminUserListItem.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { Box } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { GridListItem } from '../../components/GridListItem' +import { Attribute } from '../../components/Attributes' +import { Icon } from '../../components/Icon' + +interface Props { + user: any + required?: Attribute + attributes: Attribute[] + active?: boolean + onClick: () => void +} + +export const AdminUserListItem: React.FC = ({ + user, + required, + attributes, + active, + onClick, +}) => { + const css = useStyles() + + return ( + } + required={required?.value({ user })} + > + {attributes.map(attribute => ( + +
+ {attribute.value({ user })} +
+
+ ))} +
+ ) +} + +const useStyles = makeStyles(() => ({ + truncate: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + flex: 1, + }, +})) diff --git a/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx new file mode 100644 index 000000000..b1cb581a5 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx @@ -0,0 +1,168 @@ +import React, { useEffect, useState } from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { useDispatch, useSelector } from 'react-redux' +import { Typography, Box, TextField, ToggleButtonGroup, ToggleButton, Stack } from '@mui/material' +import { Container } from '../../components/Container' +import { Icon } from '../../components/Icon' +import { IconButton } from '../../buttons/IconButton' +import { LoadingMessage } from '../../components/LoadingMessage' +import { graphQLAdminUsers } from '../../services/graphQLRequest' +import { Gutters } from '../../components/Gutters' +import { GridList } from '../../components/GridList' +import { AdminUserListItem } from './AdminUserListItem' +import { adminUserAttributes } from './adminUserAttributes' +import { removeObject } from '../../helpers/utilHelper' +import { State, Dispatch } from '../../store' + +type SearchType = 'all' | 'email' | 'userId' + +export const AdminUsersListPage: React.FC = () => { + const history = useHistory() + const location = useLocation() + const dispatch = useDispatch() + const [searchInput, setSearchInput] = useState('') + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const [required, attributes] = removeObject(adminUserAttributes, a => a.required === true) + + // Get state from Redux + const users = useSelector((state: State) => state.adminUsers.users) + const loading = useSelector((state: State) => state.adminUsers.loading) + const total = useSelector((state: State) => state.adminUsers.total) + const page = useSelector((state: State) => state.adminUsers.page) + const pageSize = useSelector((state: State) => state.adminUsers.pageSize) + const searchValue = useSelector((state: State) => state.adminUsers.searchValue) + const searchType = useSelector((state: State) => state.adminUsers.searchType) + + // Initialize search input from Redux state + useEffect(() => { + setSearchInput(searchValue) + }, []) + + // Fetch on mount if empty + useEffect(() => { + dispatch.adminUsers.fetchIfEmpty() + }, []) + + // Fetch when page/search changes (but not on initial mount) + const isInitialMount = React.useRef(true) + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false + return + } + dispatch.adminUsers.fetch() + }, [page, searchValue, searchType]) + + useEffect(() => { + const handleRefresh = () => { + dispatch.adminUsers.fetch() + } + window.addEventListener('refreshAdminData', handleRefresh) + return () => window.removeEventListener('refreshAdminData', handleRefresh) + }, []) + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchInput(event.target.value) + } + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + dispatch.adminUsers.setSearch({ searchValue: searchInput, searchType }) + } + } + + const handleSearchTypeChange = (_: React.MouseEvent, newType: SearchType | null) => { + if (newType !== null) { + dispatch.adminUsers.setSearch({ searchValue, searchType: 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 handleUserClick = (userId: string) => { + const route = `/admin/users/${userId}/account` + dispatch.ui.setDefaultSelected({ key: '/admin/users', value: route, accountId: 'admin' }) + history.push(route) + } + + return ( + + + + + + + + + + + + + + , + }} + /> + + + } + > + {loading ? ( + + ) : users.length === 0 ? ( + + + + No users found + + + ) : ( + + {users.map(user => ( + handleUserClick(user.id)} + /> + ))} + + )} + + ) +} + diff --git a/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx new file mode 100644 index 000000000..f03f361ac --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx @@ -0,0 +1,194 @@ +import React, { useEffect, useRef } from 'react' +import { Switch, Route, useParams, useHistory, useLocation } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Box } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { AdminUsersListPage } from './AdminUsersListPage' +import { AdminUserDetailPage } from './AdminUserDetailPage' +import { AdminUserAccountPanel } from './AdminUserAccountPanel' +import { AdminUserDevicesPanel } from './AdminUserDevicesPanel' +import { State } from '../../store' +import { useContainerWidth } from '../../hooks/useContainerWidth' +import { useResizablePanel } from '../../hooks/useResizablePanel' + +const MIN_WIDTH = 250 +const THREE_PANEL_WIDTH = 961 +const DEFAULT_LEFT_WIDTH = 300 +const DEFAULT_RIGHT_WIDTH = 350 + +export const AdminUsersWithDetailPage: React.FC = () => { + const { userId } = useParams<{ userId?: string }>() + const history = useHistory() + const location = useLocation() + const css = useStyles() + const layout = useSelector((state: State) => state.ui.layout) + const defaultSelection = useSelector((state: State) => state.ui.defaultSelection) + const hasRestoredRef = useRef(false) + + const { containerRef, containerWidth } = useContainerWidth() + const leftPanel = useResizablePanel(DEFAULT_LEFT_WIDTH, containerRef, { + minWidth: MIN_WIDTH, + }) + const rightPanel = useResizablePanel(DEFAULT_RIGHT_WIDTH, containerRef, { + minWidth: MIN_WIDTH, + }) + + const maxPanels = layout.singlePanel ? 1 : (containerWidth >= THREE_PANEL_WIDTH ? 3 : 2) + + // Restore previously selected user ONLY on initial mount + useEffect(() => { + if (!hasRestoredRef.current) { + const adminSelection = defaultSelection['admin'] + const savedRoute = adminSelection?.['/admin/users'] + if (location.pathname === '/admin/users' && savedRoute) { + history.replace(savedRoute) + } + hasRestoredRef.current = true + } + }, []) // Empty dependency array - only run once on mount + + // Redirect to /account tab if navigating directly to user without a sub-route + useEffect(() => { + if (userId && location.pathname === `/admin/users/${userId}`) { + history.replace(`/admin/users/${userId}/account`) + } + }, [userId, location.pathname, history]) + + // Only show detail panels when a user is selected + const hasUserSelected = !!userId + + // When no user selected, show only the list (full width) + // When user selected, show panels based on available space + const showLeft = !hasUserSelected || maxPanels >= 3 + const showMiddle = hasUserSelected && maxPanels >= 2 + const showRight = hasUserSelected && maxPanels >= 1 + + return ( + + + {showLeft && ( + <> + + + + + {hasUserSelected && ( + + + + + + )} + + )} + + {showMiddle && ( + <> + + + + + + + + + + + )} + + {showRight && ( + + + + + + + + + + {!showMiddle && } + + + + )} + + + ) +} + +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, + }, + middlePanel: { + flex: 1, + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + minWidth: MIN_WIDTH, + paddingLeft: 8, + paddingRight: 8, + }, + 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/AdminUsersPage/adminUserAttributes.tsx b/frontend/src/pages/AdminUsersPage/adminUserAttributes.tsx new file mode 100644 index 000000000..482bd7a6f --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/adminUserAttributes.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { Attribute } from '../../components/Attributes' + +class AdminUserAttribute extends Attribute { + type: Attribute['type'] = 'MASTER' +} + +export const adminUserAttributes: Attribute[] = [ + new AdminUserAttribute({ + id: 'userId', + label: 'User ID', + defaultWidth: 320, + value: ({ user }: { user: any }) => user.id, + }), + new AdminUserAttribute({ + id: 'email', + label: 'Email', + defaultWidth: 250, + required: true, + value: ({ user }: { user: any }) => user.email || '-', + }), + new AdminUserAttribute({ + id: 'devices', + label: 'Devices', + defaultWidth: 100, + value: ({ user }: { user: any }) => user.info?.devices?.total || 0, + }), + new AdminUserAttribute({ + id: 'license', + label: 'License', + defaultWidth: 120, + value: ({ user }: { user: any }) => { + // TODO: Get license info from user data + return - + }, + }), + new AdminUserAttribute({ + id: 'created', + label: 'Created', + defaultWidth: 150, + value: ({ user }: { user: any }) => { + if (!user.created) return '-' + return new Date(user.created).toLocaleDateString() + }, + }), +] diff --git a/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx b/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx new file mode 100644 index 000000000..f3b5e53a0 --- /dev/null +++ b/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector, useDispatch } from 'react-redux' +import { + Typography, List, ListItem, ListItemText, ListItemButton, ListItemIcon, Box, Divider, Button +} from '@mui/material' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { IconButton } from '../../buttons/IconButton' +import { CopyIconButton } from '../../buttons/CopyIconButton' +import { LoadingMessage } from '../../components/LoadingMessage' +import { graphQLExportPartnerDevices } from '../../services/graphQLRequest' +import { windowOpen } from '../../services/browser' +import { spacing } from '../../styling' +import { State, Dispatch as AppDispatch } from '../../store' +import { getPartnerStatsModel } from '../../models/partnerStats' + +export const PartnerStatsDetailPanel: React.FC = () => { + const { partnerId } = useParams<{ partnerId: string }>() + const history = useHistory() + const dispatch = useDispatch() + const [exporting, setExporting] = useState(false) + const userId = useSelector((state: State) => state.user.id) + const partnerStatsModel = useSelector((state: State) => getPartnerStatsModel(state)) + const { flattened: partners, all: rootPartners, fetching: loading } = partnerStatsModel + + // Find the partner in the flattened list + const partner = useMemo(() => { + if (!partnerId) return null + return partners.find(p => p.id === partnerId) + }, [partnerId, partners]) + + const handleBack = () => { + history.push('/partner-stats') + } + + const handleNavigateToPartner = (id: string) => { + history.push(`/partner-stats/${id}`) + } + + const handleExportDevices = async () => { + if (!partnerId) return + setExporting(true) + const result = await graphQLExportPartnerDevices(partnerId) + setExporting(false) + + if (result !== 'ERROR' && result?.data?.data?.exportPartnerDevices) { + const url = result.data.data.exportPartnerDevices + windowOpen(url) + } else { + alert('Failed to export devices.') + } + } + + // Don't show anything if no partner is selected + if (!partnerId) { + return null + } + + if (loading && !partner) { + return ( + + + + ) + } + + if (!partner) { + return ( + + + + + Partner not found + + + + ) + } + + const children = partner.children || [] + const admins = partner.admins || [] + const registrants = partner.registrants || [] + + return ( + + + + + + + + + + {partner.name} + + + + {partner.id} + + + + + + + } + > + {/* Parent Partner */} + {partner.parent && ( + <> + + Parent Partner + + + handleNavigateToPartner(partner.parent!.id)}> + + + + + + + + + )} + + {/* Device Counts */} + + Device Summary + + + + + + + + + + + + + + + + + + + + {/* Children Partners */} + {children.length > 0 && ( + <> + + Child Partners ({children.length}) + + + {children.map((child: any, index: number) => ( + + {index > 0 && } + handleNavigateToPartner(child.id)}> + + + + + + + + ))} + + + )} + + {/* Registrants in this Partner */} + <> + + + Registrants ({registrants.length}) + + + {registrants.length > 0 && ( + + {registrants.map((user: any, index: number) => ( + + {index > 0 && } + + + + + + + + ))} + + )} + + + {/* Admins in this Partner */} + <> + + + Admins ({admins.length}) + + + {admins.length > 0 && ( + + {admins.map((user: any, index: number) => ( + + {index > 0 && } + + + + + + + + ))} + + )} + + + + ) +} diff --git a/frontend/src/pages/PartnerStatsPage/PartnerStatsListPage.tsx b/frontend/src/pages/PartnerStatsPage/PartnerStatsListPage.tsx new file mode 100644 index 000000000..e429ea720 --- /dev/null +++ b/frontend/src/pages/PartnerStatsPage/PartnerStatsListPage.tsx @@ -0,0 +1,170 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { Typography, Box, TextField, InputAdornment } from '@mui/material' +import { Container } from '../../components/Container' +import { Icon } from '../../components/Icon' +import { LoadingMessage } from '../../components/LoadingMessage' +import { Gutters } from '../../components/Gutters' +import { GridList } from '../../components/GridList' +import { GridListItem } from '../../components/GridListItem' +import { Attribute } from '../../components/Attributes' +import { State, Dispatch } from '../../store' +import { makeStyles } from '@mui/styles' +import { removeObject } from '../../helpers/utilHelper' +import { useSelector, useDispatch } from 'react-redux' +import { getPartnerStatsModel } from '../../models/partnerStats' +import { selectDefaultSelectedPage } from '../../selectors/ui' + +class PartnerStatsAttribute extends Attribute { + type: Attribute['type'] = 'MASTER' +} + +const partnerStatsAttributes: Attribute[] = [ + new PartnerStatsAttribute({ + id: 'partnerName', + label: 'Name', + defaultWidth: 250, + required: true, + value: ({ partner }: { partner: any }) => partner?.name || partner?.id, + }), + new PartnerStatsAttribute({ + id: 'partnerDevicesTotal', + label: 'Devices', + defaultWidth: 80, + value: ({ partner }: { partner: any }) => partner?.deviceCount || 0, + }), + new PartnerStatsAttribute({ + id: 'partnerActivated', + label: 'Activated', + defaultWidth: 100, + value: ({ partner }: { partner: any }) => partner?.activated || 0, + }), + new PartnerStatsAttribute({ + id: 'partnerActive', + label: 'Active', + defaultWidth: 80, + value: ({ partner }: { partner: any }) => partner?.active || 0, + }), + new PartnerStatsAttribute({ + id: 'partnerOnline', + label: 'Online', + defaultWidth: 80, + value: ({ partner }: { partner: any }) => partner?.online || 0, + }), +] + +export const PartnerStatsListPage: React.FC = () => { + const dispatch = useDispatch() + const history = useHistory() + const location = useLocation() + const css = useStyles() + const [searchValue, setSearchValue] = useState('') + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const userId = useSelector((state: State) => state.user.id) + const partnerStatsModel = useSelector((state: State) => getPartnerStatsModel(state)) + const { flattened: partners, fetching: loading, initialized } = partnerStatsModel + const [required, attributes] = removeObject(partnerStatsAttributes, a => a.required === true) + const defaultSelected = useSelector(selectDefaultSelectedPage) + + useEffect(() => { + dispatch.partnerStats.fetchIfEmpty() + // Restore previous selection for this account, or clear to root if none + const savedPath = defaultSelected['/partner-stats'] + if (savedPath && location.pathname !== savedPath) { + history.push(savedPath) + } else if (!savedPath && location.pathname !== '/partner-stats') { + history.push('/partner-stats') + } + }, [userId]) + + const filteredPartners = useMemo(() => { + if (!searchValue.trim()) return partners + const search = searchValue.toLowerCase() + return partners.filter(partner => + partner.name?.toLowerCase().includes(search) || + partner.id?.toLowerCase().includes(search) + ) + }, [partners, searchValue]) + + const handlePartnerClick = (partnerId: string) => { + const to = `/partner-stats/${partnerId}` + dispatch.ui.setDefaultSelected({ key: '/partner-stats', value: to }) + history.push(to) + } + + return ( + + setSearchValue(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + } + > + {loading && !initialized ? ( + + ) : filteredPartners.length === 0 ? ( + + + + {searchValue ? 'No matching partners' : 'No partners found'} + + + You don't have admin access to any partner entities. + + + ) : ( + + {filteredPartners.map(partner => ( + handlePartnerClick(partner.id)} + selected={location.pathname.includes(`/partner-stats/${partner.id}`)} + disableGutters + icon={} + required={required?.value({ partner })} + > + {attributes.map(attribute => ( + +
+ {attribute.value({ partner })} +
+
+ ))} +
+ ))} +
+ )} +
+ ) +} + +const useStyles = makeStyles(() => ({ + truncate: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + flex: 1, + }, +})) diff --git a/frontend/src/pages/PartnerStatsPage/PartnerStatsPage.tsx b/frontend/src/pages/PartnerStatsPage/PartnerStatsPage.tsx new file mode 100644 index 000000000..7dd4e87dc --- /dev/null +++ b/frontend/src/pages/PartnerStatsPage/PartnerStatsPage.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { State } from '../../store' +import { DynamicPanel } from '../../components/DynamicPanel' +import { PartnerStatsListPage } from './PartnerStatsListPage' +import { PartnerStatsDetailPanel } from './PartnerStatsDetailPanel' + +export const PartnerStatsPage: React.FC = () => { + const layout = useSelector((state: State) => state.ui.layout) + + return ( + } + secondary={} + layout={layout} + root="/partner-stats" + /> + ) +} diff --git a/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx b/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx index d6b89e944..44f80522e 100644 --- a/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx +++ b/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx @@ -40,6 +40,15 @@ export const AddProductServiceDialog: React.FC = ({ const [creating, setCreating] = useState(false) const [error, setError] = useState(null) + const handleTypeChange = (selectedType: string) => { + setType(selectedType) + // Set default port for the selected application type + const appType = applicationTypes.find(t => String(t.id) === selectedType) + if (appType?.port) { + setPort(String(appType.port)) + } + } + const resetForm = () => { setName('') setType('') @@ -115,7 +124,7 @@ export const AddProductServiceDialog: React.FC = ({ Service Type setType(e.target.value)} + onChange={e => handleTypeChange(e.target.value)} label="Service Type" disabled={creating} > - {applicationTypes - .filter(t => t.enabled) - .map(t => ( - - {t.name} - - ))} + {applicationTypes.map(t => ( + + {t.name} + + ))} diff --git a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx index 77b4acccb..1dae63f8d 100644 --- a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx @@ -17,6 +17,7 @@ import { Container } from '../../components/Container' import { Icon } from '../../components/Icon' import { IconButton } from '../../buttons/IconButton' import { CopyIconButton } from '../../buttons/CopyIconButton' +import { CopyCodeBlock } from '../../components/CopyCodeBlock' import { Body } from '../../components/Body' import { Notice } from '../../components/Notice' import { Confirm } from '../../components/Confirm' @@ -43,6 +44,7 @@ export const ProductSettingsPage: React.FC = ({ showBack = true }) => { const [deleting, setDeleting] = useState(false) const isLocked = product?.status === 'LOCKED' + const registrationCommand = product?.registrationCommand const handleLockToggle = async () => { if (!product || isLocked) return @@ -94,6 +96,23 @@ export const ProductSettingsPage: React.FC = ({ showBack = true }) => { } >
+ {isLocked && product.registrationCode && ( +
+ + {registrationCommand ? 'Registration Command' : 'Registration Code'} + + + {registrationCommand + ? 'Use this command to register devices with this product configuration:' + : 'Use this registration code to register devices with this product configuration:'} + + +
+ )} +
Product Details @@ -158,7 +177,7 @@ export const ProductSettingsPage: React.FC = ({ showBack = true }) => { Product Settings - + = ({ showBack = true }) => {
+
+ + Transfer Ownership + + + + + + + +
+
Danger Zone diff --git a/frontend/src/pages/ProductsPage/ProductTransferPage.tsx b/frontend/src/pages/ProductsPage/ProductTransferPage.tsx new file mode 100644 index 000000000..23dc653ed --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductTransferPage.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Typography, Button, Box } from '@mui/material' +import { ContactSelector } from '../../components/ContactSelector' +import { Container } from '../../components/Container' +import { Gutters } from '../../components/Gutters' +import { Confirm } from '../../components/Confirm' +import { Notice } from '../../components/Notice' +import { Icon } from '../../components/Icon' +import { IconButton } from '../../buttons/IconButton' +import { Body } from '../../components/Body' +import { spacing } from '../../styling' +import { dispatch, State } from '../../store' +import { getProductModel } from '../../selectors/products' + +type Props = { + showBack?: boolean +} + +export const ProductTransferPage: React.FC = ({ showBack = true }) => { + const { productId } = useParams<{ productId: string }>() + const history = useHistory() + const { contacts = [], transferring } = useSelector((state: State) => ({ + contacts: state.contacts.all, + transferring: state.ui.transferring, + })) + const { all: products } = useSelector(getProductModel) + const product = products.find(p => p.id === productId) + + const [open, setOpen] = useState(false) + const [selected, setSelected] = useState() + + const handleBack = () => { + history.push(`/products/${productId}`) + } + + const handleChange = (emails: string[]) => { + setSelected(undefined) + if (emails.length > 0) { + setSelected(emails[0]) + } + } + + const onCancel = () => history.push(`/products/${productId}`) + + const onTransfer = async () => { + if (productId && selected) { + const success = await dispatch.products.transferProduct({ productId, email: selected }) + if (success) { + history.push('/products') + } + } + } + + if (!product) { + return ( + + + + + Product not found + + + + ) + } + + return ( + + {showBack && ( + + + + )} + Transfer Product + + + + + } + > + + + You are transferring "{product.name}" to a new owner. + + + Product transfer typically takes a few seconds to complete. The new owner will gain full access to the product and all its services. + + + + + + + { + setOpen(false) + onTransfer() + }} + onDeny={() => setOpen(false)} + color="error" + title="Are you sure?" + action="Transfer" + > + + You will lose all access and control of this product upon transfer. + + + You are about to transfer ownership of {product.name} and all of its services to + {selected}. + + + + ) +} diff --git a/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx index 02747ea77..53455e505 100644 --- a/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react' +import React from 'react' import { Switch, Route, useParams, Redirect } from 'react-router-dom' import { useSelector } from 'react-redux' import { Box } from '@mui/material' @@ -8,8 +8,11 @@ import { ProductPage } from './ProductPage' import { ProductSettingsPage } from './ProductSettingsPage' import { ProductServiceDetailPage } from './ProductServiceDetailPage' import { ProductServiceAddPage } from './ProductServiceAddPage' +import { ProductTransferPage } from './ProductTransferPage' import { getProductModel } from '../../selectors/products' import { State } from '../../store' +import { useContainerWidth } from '../../hooks/useContainerWidth' +import { useResizablePanel } from '../../hooks/useResizablePanel' const MIN_WIDTH = 250 const THREE_PANEL_WIDTH = 961 // Width threshold for showing 3 panels @@ -19,26 +22,17 @@ const DEFAULT_RIGHT_WIDTH = 350 export const ProductsWithDetailPage: React.FC = () => { const { productId } = useParams<{ productId: string }>() const css = useStyles() - const containerRef = useRef(null) - + // Get layout from Redux for singlePanel breakpoint (750px) const layout = useSelector((state: State) => state.ui.layout) - - // Container width for 3-panel vs 2-panel transition - const [containerWidth, setContainerWidth] = useState(1000) - - // Left divider state - const leftPrimaryRef = useRef(null) - const leftHandleRef = useRef(DEFAULT_LEFT_WIDTH) - const leftMoveRef = useRef(0) - const [leftWidth, setLeftWidth] = useState(DEFAULT_LEFT_WIDTH) - const [leftGrab, setLeftGrab] = useState(false) - // Right divider state - const rightHandleRef = useRef(DEFAULT_RIGHT_WIDTH) - const rightMoveRef = useRef(0) - const [rightWidth, setRightWidth] = useState(DEFAULT_RIGHT_WIDTH) - const [rightGrab, setRightGrab] = useState(false) + const { containerRef, containerWidth } = useContainerWidth() + const leftPanel = useResizablePanel(DEFAULT_LEFT_WIDTH, containerRef, { + minWidth: MIN_WIDTH, + }) + const rightPanel = useResizablePanel(DEFAULT_RIGHT_WIDTH, containerRef, { + minWidth: MIN_WIDTH, + }) const { all: products } = useSelector(getProductModel) const product = products.find(p => p.id === productId) @@ -49,76 +43,6 @@ export const ProductsWithDetailPage: React.FC = () => { // - !singlePanel + narrow container: 2 panels const maxPanels = layout.singlePanel ? 1 : (containerWidth >= THREE_PANEL_WIDTH ? 3 : 2) - // Track container width for 3-panel threshold - useEffect(() => { - const updateWidth = () => { - if (containerRef.current) { - setContainerWidth(containerRef.current.offsetWidth) - } - } - - updateWidth() - - const resizeObserver = new ResizeObserver(updateWidth) - if (containerRef.current) { - resizeObserver.observe(containerRef.current) - } - - return () => resizeObserver.disconnect() - }, []) - - // Left divider handlers - const onLeftMove = useCallback((event: MouseEvent) => { - const fullWidth = containerRef.current?.offsetWidth || 1000 - leftHandleRef.current += event.clientX - leftMoveRef.current - leftMoveRef.current = event.clientX - if (leftHandleRef.current > MIN_WIDTH && leftHandleRef.current < fullWidth - MIN_WIDTH - rightWidth) { - setLeftWidth(leftHandleRef.current) - } - }, [rightWidth]) - - const onLeftUp = useCallback((event: MouseEvent) => { - setLeftGrab(false) - event.preventDefault() - window.removeEventListener('mousemove', onLeftMove) - window.removeEventListener('mouseup', onLeftUp) - }, [onLeftMove]) - - const onLeftDown = (event: React.MouseEvent) => { - setLeftGrab(true) - leftMoveRef.current = event.clientX - leftHandleRef.current = leftPrimaryRef.current?.offsetWidth || leftWidth - event.preventDefault() - window.addEventListener('mousemove', onLeftMove) - window.addEventListener('mouseup', onLeftUp) - } - - // Right divider handlers - const onRightMove = useCallback((event: MouseEvent) => { - const fullWidth = containerRef.current?.offsetWidth || 1000 - rightHandleRef.current -= event.clientX - rightMoveRef.current - rightMoveRef.current = event.clientX - if (rightHandleRef.current > MIN_WIDTH && rightHandleRef.current < fullWidth - MIN_WIDTH - leftWidth) { - setRightWidth(rightHandleRef.current) - } - }, [leftWidth]) - - const onRightUp = useCallback((event: MouseEvent) => { - setRightGrab(false) - event.preventDefault() - window.removeEventListener('mousemove', onRightMove) - window.removeEventListener('mouseup', onRightUp) - }, [onRightMove]) - - const onRightDown = (event: React.MouseEvent) => { - setRightGrab(true) - rightMoveRef.current = event.clientX - rightHandleRef.current = rightWidth - event.preventDefault() - window.addEventListener('mousemove', onRightMove) - window.addEventListener('mouseup', onRightUp) - } - // Determine which panels to show based on available space // Priority: right panel > middle panel > left panel const showLeft = maxPanels >= 3 @@ -131,49 +55,52 @@ export const ProductsWithDetailPage: React.FC = () => { {/* Left Panel - Products List */} {showLeft && ( <> - - + {/* Left Divider */} - - + + )} - + {/* Middle Panel - Product Details */} {showMiddle && ( <> - + {/* Right Divider */} - - + + )} - + {/* Right Panel - Settings/Service Details */} {showRight && ( - + + + diff --git a/frontend/src/pages/ProductsPage/index.ts b/frontend/src/pages/ProductsPage/index.ts index b35057bac..21557cbdf 100644 --- a/frontend/src/pages/ProductsPage/index.ts +++ b/frontend/src/pages/ProductsPage/index.ts @@ -5,4 +5,5 @@ export { ProductPage } from './ProductPage' export { ProductServiceDetailPage } from './ProductServiceDetailPage' export { ProductServiceAddPage } from './ProductServiceAddPage' export { ProductSettingsPage } from './ProductSettingsPage' +export { ProductTransferPage } from './ProductTransferPage' export { ProductsWithDetailPage } from './ProductsWithDetailPage' diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index a234a16ee..fe2dfe18b 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -57,6 +57,9 @@ import { SecurityPage } from '../pages/SecurityPage' import { FeedbackPage } from '../pages/FeedbackPage' import { AccessKeyPage } from '../pages/AccessKeyPage' import { NotificationsPage } from '../pages/NotificationsPage' +import { AdminUsersWithDetailPage } from '../pages/AdminUsersPage/AdminUsersWithDetailPage' +import { AdminPartnersPage } from '../pages/AdminPartnersPage/AdminPartnersPage' +import { PartnerStatsPage } from '../pages/PartnerStatsPage/PartnerStatsPage' import browser, { getOs } from '../services/browser' import analytics from '../services/analytics' @@ -71,6 +74,23 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { const thisId = useSelector((state: State) => state.backend.thisId) const registered = useSelector((state: State) => !!state.backend.thisId) const os = useSelector((state: State) => state.backend.environment.os) || getOs() + const userAdmin = useSelector((state: State) => state.auth.user?.admin || false) + const adminMode = useSelector((state: State) => state.ui.adminMode) + + // Auto-set admin mode when navigating to admin routes + useEffect(() => { + const isAdminRoute = location.pathname.startsWith('/admin') + if (isAdminRoute && userAdmin && !adminMode) { + ui.set({ adminMode: true }) + // Clear navigation history when entering admin mode + emit('navigate', 'CLEAR') + } else if (!isAdminRoute && adminMode) { + // Exit admin mode when leaving admin routes + ui.set({ adminMode: false }) + // Clear navigation history when returning to app + emit('navigate', 'CLEAR') + } + }, [location.pathname, userAdmin, adminMode, ui]) useEffect(() => { const initialRoute = window.localStorage.getItem('initialRoute') @@ -383,6 +403,23 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { root="/account" /> + {/* Admin Routes */} + + + + + + + + + + + + + + + + {/* Not found */} diff --git a/frontend/src/services/CloudSync.ts b/frontend/src/services/CloudSync.ts index 57709c8c1..0a9e5ed46 100644 --- a/frontend/src/services/CloudSync.ts +++ b/frontend/src/services/CloudSync.ts @@ -65,6 +65,7 @@ class CloudSync { dispatch.connections.fetch, dispatch.files.fetch, dispatch.products.fetch, + dispatch.partnerStats.fetch, ]) } } diff --git a/frontend/src/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts index 5e0831ce6..cfe34d570 100644 --- a/frontend/src/services/graphQLDeviceProducts.ts +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -29,6 +29,7 @@ export async function graphQLDeviceProducts(options?: { platform { id name } status registrationCode + registrationCommand created updated services { @@ -50,27 +51,34 @@ export async function graphQLDeviceProducts(options?: { ) } -export async function graphQLDeviceProduct(id: string) { +export async function graphQLDeviceProduct(id: string, accountId?: string) { return await graphQLBasicRequest( - ` query DeviceProduct($id: ID!) { - deviceProduct(id: $id) { - id - name - platform { id name } - status - registrationCode - created - updated - services { - id - name - type { id name } - port - enabled + ` query DeviceProduct($id: String!, $accountId: String) { + login { + account(id: $accountId) { + deviceProducts(id: [$id]) { + items { + id + name + platform { id name } + status + registrationCode + registrationCommand + created + updated + services { + id + name + type { id name } + port + enabled + } + } + } } } }`, - { id } + { id, accountId } ) } @@ -165,3 +173,11 @@ export async function graphQLRemoveDeviceProductService(id: string) { ) } +export async function graphQLTransferDeviceProduct(productId: string, email: string) { + return await graphQLBasicRequest( + ` mutation TransferDeviceProduct($productId: ID!, $email: String!) { + transferDeviceProduct(productId: $productId, email: $email) + }`, + { productId, email } + ) +} diff --git a/frontend/src/services/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index 579c04609..2dcd83d79 100644 --- a/frontend/src/services/graphQLRequest.ts +++ b/frontend/src/services/graphQLRequest.ts @@ -8,6 +8,7 @@ export async function graphQLLogin() { email authhash yoicsId + admin } }` ) @@ -118,6 +119,13 @@ export async function graphQLUser(accountId: string) { notificationUrl } attributes + info { + devices { + total + online + offline + } + } } } }`, @@ -314,8 +322,8 @@ export async function graphQLFetchOrganizations(ids: string[]) { ` query Organizations { login { ${ids - .map( - (id, index) => ` + .map( + (id, index) => ` _${index}: account(id: "${id}") { organization { id @@ -384,13 +392,79 @@ export async function graphQLFetchOrganizations(ids: string[]) { ${LIMITS_QUERY} } }` - ) - .join('\n')} + ) + .join('\n')} } }` ) } +export async function graphQLAdminUsers( + options: { from?: number; size?: number }, + filters?: { search?: string; email?: string; accountId?: string }, + sort?: string +) { + return await graphQLBasicRequest( + ` query AdminUsers($from: Int, $size: Int, $search: String, $email: String, $accountId: String, $sort: String) { + admin { + users(from: $from, size: $size, search: $search, email: $email, accountId: $accountId, sort: $sort) { + items { + id + email + created + info { + devices { + total + } + } + } + total + hasMore + } + } + }`, + { + from: options.from, + size: options.size, + search: filters?.search, + email: filters?.email, + accountId: filters?.accountId, + sort, + } + ) +} + +export async function graphQLAdminUser(accountId: string) { + return await graphQLBasicRequest( + ` query AdminUser($accountId: String) { + admin { + users(from: 0, size: 1, accountId: $accountId) { + items { + id + email + created + lastLogin + info { + devices { + total + online + offline + } + } + organization { + name + } + } + total + hasMore + } + } + }`, + { accountId } + ) +} + + export async function graphQLFetchGuests(accountId: string) { return await graphQLBasicRequest( ` query Guests($accountId: String) { @@ -420,8 +494,8 @@ export async function graphQLFetchSessions(ids: string[]) { ` query Sessions { login { ${ids - .map( - (id, index) => ` + .map( + (id, index) => ` _${index}: account(id: "${id}") { sessions { id @@ -456,8 +530,8 @@ export async function graphQLFetchSessions(ids: string[]) { } } }` - ) - .join('\n')} + ) + .join('\n')} } }` ) @@ -479,3 +553,353 @@ export async function graphQLGetResellerReportUrl(accountId: string) { { accountId } ) } + +export async function graphQLAdminUserDevices( + accountId: string, + options: { from?: number; size?: number } = {} +) { + return await graphQLBasicRequest( + ` query AdminUserDevices($accountId: String!, $from: Int, $size: Int) { + login { + account(id: $accountId) { + devices(from: $from, size: $size) { + total + items { + id + name + state + platform + license + lastReported + created + endpoint { + externalAddress + } + owner { + id + email + } + services { + id + name + state + } + } + } + } + } + }`, + { accountId, from: options.from || 0, size: options.size || 100 } + ) +} + +export async function graphQLAdminPartners() { + return await graphQLBasicRequest( + ` query AdminPartners { + admin { + partners { + id + name + parent { + id + name + deviceCount + online + active + activated + } + deviceCount + online + active + activated + updated + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { + id + email + deviceCount + online + active + activated + updated + } + children { + id + name + deviceCount + online + active + activated + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { + id + email + deviceCount + online + active + activated + updated + } + } + } + } + }` + ) +} + +export async function graphQLAdminPartner(id: string) { + return await graphQLBasicRequest( + ` query AdminPartner($id: String!) { + admin { + partners(id: $id) { + id + name + parent { + id + name + deviceCount + online + active + activated + } + deviceCount + online + active + activated + updated + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { + id + email + deviceCount + online + active + activated + updated + } + children { + id + name + deviceCount + online + active + activated + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { + id + email + deviceCount + online + active + activated + updated + } + } + } + } + }`, + { id } + ) +} + +export async function graphQLCreatePartner(name: string, parentId?: string) { + return await graphQLBasicRequest( + `mutation CreatePartner($name: String!, $parentId: String) { + createPartner(name: $name, parentId: $parentId) { + id + name + deviceCount + online + active + activated + updated + } + }`, + { name, parentId } + ) +} + +export async function graphQLDeletePartner(id: string) { + return await graphQLBasicRequest( + `mutation DeletePartner($id: String!) { + deletePartner(id: $id) + }`, + { id } + ) +} + +export async function graphQLAddPartnerChild(parentId: string, childId: string) { + return await graphQLBasicRequest( + `mutation AddPartnerChild($parentId: String!, $childId: String!) { + addPartnerChild(parentId: $parentId, childId: $childId) + }`, + { parentId, childId } + ) +} + +export async function graphQLRemovePartnerChild(childId: string) { + return await graphQLBasicRequest( + `mutation RemovePartnerChild($childId: String!) { + removePartnerChild(childId: $childId) + }`, + { childId } + ) +} + +export async function graphQLAddPartnerAdmin(entityId: string, email: string) { + return await graphQLBasicRequest( + `mutation AddPartnerAdmin($entityId: String!, $email: String!) { + addPartnerAdmin(entityId: $entityId, email: $email) + }`, + { entityId, email } + ) +} + +export async function graphQLRemovePartnerAdmin(entityId: string, userId: string) { + return await graphQLBasicRequest( + `mutation RemovePartnerAdmin($entityId: String!, $userId: String!) { + removePartnerAdmin(entityId: $entityId, userId: $userId) + }`, + { entityId, userId } + ) +} + +export async function graphQLAddPartnerRegistrant(entityId: string, email: string) { + return await graphQLBasicRequest( + `mutation AddPartnerRegistrant($entityId: String!, $email: String!) { + addPartnerRegistrant(entityId: $entityId, email: $email) + }`, + { entityId, email } + ) +} + +export async function graphQLRemovePartnerRegistrant(entityId: string, userId: string) { + return await graphQLBasicRequest( + `mutation RemovePartnerRegistrant($entityId: String!, $userId: String!) { + removePartnerRegistrant(entityId: $entityId, userId: $userId) + }`, + { entityId, userId } + ) +} + +export async function graphQLExportPartnerDevices(entityId: string) { + return await graphQLBasicRequest( + `query ExportPartnerDevices($entityId: String!) { + exportPartnerDevices(entityId: $entityId) + }`, + { entityId } + ) +} + +export async function graphQLPartnerEntities(accountId?: string) { + return await graphQLBasicRequest( + `query PartnerEntities($accountId: String) { + login { + account(id: $accountId) { + partnerEntities { + id + name + parent { + id + name + deviceCount + online + active + activated + } + deviceCount + online + active + activated + updated + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { + id + email + deviceCount + online + active + activated + updated + } + children { + id + name + deviceCount + online + active + activated + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { + id + email + deviceCount + online + active + activated + updated + } + children { + id + name + deviceCount + online + active + activated + } + } + } + } + } + }`, + { accountId } + ) +}