From cac1cbdbf4f6a2bde8b97c4ceb5ba64a5be07bae Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 10 Dec 2025 23:29:09 -0800 Subject: [PATCH 01/22] feat: add device product page --- frontend/src/components/SidebarNav.tsx | 2 +- .../ProductsPage/AddProductServiceDialog.tsx | 196 +++++++++++ .../src/pages/ProductsPage/ProductAddPage.tsx | 158 +++++++++ .../pages/ProductsPage/ProductDetailPage.tsx | 308 ++++++++++++++++++ .../src/pages/ProductsPage/ProductsPage.tsx | 252 ++++++++++++++ frontend/src/pages/ProductsPage/index.ts | 4 + frontend/src/routers/Router.tsx | 17 + .../src/services/graphQLDeviceProducts.ts | 170 ++++++++++ 8 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx create mode 100644 frontend/src/pages/ProductsPage/ProductAddPage.tsx create mode 100644 frontend/src/pages/ProductsPage/ProductDetailPage.tsx create mode 100644 frontend/src/pages/ProductsPage/ProductsPage.tsx create mode 100644 frontend/src/pages/ProductsPage/index.ts create mode 100644 frontend/src/services/graphQLDeviceProducts.ts diff --git a/frontend/src/components/SidebarNav.tsx b/frontend/src/components/SidebarNav.tsx index 99305124a..d91a47d4a 100644 --- a/frontend/src/components/SidebarNav.tsx +++ b/frontend/src/components/SidebarNav.tsx @@ -112,6 +112,7 @@ export const SidebarNav: React.FC = () => { dense /> + setMore(!more)} sx={{ marginTop: 2 }}> @@ -121,7 +122,6 @@ export const SidebarNav: React.FC = () => { - diff --git a/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx b/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx new file mode 100644 index 000000000..ddde14b39 --- /dev/null +++ b/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx @@ -0,0 +1,196 @@ +import React, { useState } from 'react' +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Switch, +} from '@mui/material' +import { Icon } from '../../components/Icon' +import { Notice } from '../../components/Notice' +import { graphQLAddDeviceProductService } from '../../services/graphQLDeviceProducts' +import { graphQLGetErrors } from '../../services/graphQL' + +// Common service types +const SERVICE_TYPES = [ + { value: '1', label: 'TCP (1)' }, + { value: '4', label: 'SSH (4)' }, + { value: '5', label: 'Web/HTTP (5)' }, + { value: '7', label: 'VNC (7)' }, + { value: '8', label: 'HTTPS (8)' }, + { value: '28', label: 'RDP (28)' }, + { value: '33', label: 'Samba (33)' }, + { value: '34', label: 'Bulk Service (34)' }, + { value: '35', label: 'Bulk Identification (35)' }, +] + +interface IProductService { + id: string + name: string + type: string + port: number + enabled: boolean + platformCode: string +} + +interface Props { + open: boolean + productId: string + onClose: () => void + onServiceAdded: (service: IProductService) => void +} + +export const AddProductServiceDialog: React.FC = ({ + open, + productId, + onClose, + onServiceAdded, +}) => { + const [name, setName] = useState('') + const [type, setType] = useState('') + const [port, setPort] = useState('') + const [enabled, setEnabled] = useState(true) + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + const resetForm = () => { + setName('') + setType('') + setPort('') + setEnabled(true) + setError(null) + } + + const handleClose = () => { + resetForm() + onClose() + } + + const handleCreate = async () => { + if (!name.trim()) { + setError('Service name is required') + return + } + if (!type) { + setError('Service type is required') + return + } + const portNum = parseInt(port) + if (isNaN(portNum) || portNum < 0 || portNum > 65535) { + setError('Port must be a number between 0 and 65535') + return + } + + setError(null) + setCreating(true) + + const response = await graphQLAddDeviceProductService(productId, { + name: name.trim(), + type, + port: portNum, + enabled, + }) + + if (graphQLGetErrors(response)) { + setError('Failed to add service') + setCreating(false) + return + } + + const service = response?.data?.data?.addDeviceProductService + if (service) { + onServiceAdded(service) + handleClose() + } else { + setError('Failed to add service') + setCreating(false) + } + } + + return ( + + Add Service + + {error && ( + + {error} + + )} + + setName(e.target.value)} + fullWidth + required + autoFocus + margin="normal" + disabled={creating} + /> + + + Service Type + + + + setPort(e.target.value)} + fullWidth + required + type="number" + margin="normal" + disabled={creating} + inputProps={{ min: 0, max: 65535 }} + /> + + setEnabled(e.target.checked)} + disabled={creating} + /> + } + label="Enabled" + sx={{ marginTop: 1 }} + /> + + + + + + + ) +} + diff --git a/frontend/src/pages/ProductsPage/ProductAddPage.tsx b/frontend/src/pages/ProductsPage/ProductAddPage.tsx new file mode 100644 index 000000000..9d7caa3d6 --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductAddPage.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect } from 'react' +import { useHistory } from 'react-router-dom' +import { + Typography, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + IconButton, +} from '@mui/material' +import { makeStyles } from '@mui/styles' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Notice } from '../../components/Notice' +import { spacing } from '../../styling' +import { graphQLCreateDeviceProduct, graphQLPlatformTypes } from '../../services/graphQLDeviceProducts' +import { graphQLGetErrors } from '../../services/graphQL' + +interface IPlatformType { + id: number + name: string +} + +export const ProductAddPage: React.FC = () => { + const history = useHistory() + const css = useStyles() + const [name, setName] = useState('') + const [platform, setPlatform] = useState('') + const [platforms, setPlatforms] = useState([]) + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchPlatforms = async () => { + const response = await graphQLPlatformTypes() + if (!graphQLGetErrors(response)) { + setPlatforms(response?.data?.data?.platformTypes || []) + } + } + fetchPlatforms() + }, []) + + const handleCreate = async () => { + if (!name.trim()) { + setError('Product name is required') + return + } + if (!platform) { + setError('Platform is required') + return + } + + setError(null) + setCreating(true) + + const response = await graphQLCreateDeviceProduct({ + name: name.trim(), + platform, + }) + + if (graphQLGetErrors(response)) { + setError('Failed to create product') + setCreating(false) + return + } + + const product = response?.data?.data?.createDeviceProduct + if (product) { + history.push(`/products/${product.id}`) + } else { + setError('Failed to create product') + setCreating(false) + } + } + + return ( + + history.push('/products')} sx={{ marginRight: 1 }}> + + + Create Product + + } + > +
+ {error && ( + + {error} + + )} + + setName(e.target.value)} + fullWidth + required + autoFocus + margin="normal" + disabled={creating} + /> + + + Platform + + + +
+ + +
+
+
+ ) +} + +const useStyles = makeStyles(({ palette }) => ({ + form: { + maxWidth: 500, + margin: '0 auto', + padding: spacing.lg, + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + gap: spacing.md, + marginTop: spacing.lg, + }, +})) + diff --git a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx new file mode 100644 index 000000000..d9689adc2 --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx @@ -0,0 +1,308 @@ +import React, { useEffect, useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { + Typography, + Button, + IconButton, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Switch, + Divider, + Chip, +} from '@mui/material' +import { makeStyles } from '@mui/styles' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { Notice } from '../../components/Notice' +import { Confirm } from '../../components/Confirm' +import { LoadingMessage } from '../../components/LoadingMessage' +import { InlineTextFieldSetting } from '../../components/InlineTextFieldSetting' +import { spacing } from '../../styling' +import { + graphQLDeviceProduct, + graphQLUpdateDeviceProductSettings, + graphQLRemoveDeviceProductService, +} from '../../services/graphQLDeviceProducts' +import { graphQLGetErrors } from '../../services/graphQL' +import { AddProductServiceDialog } from './AddProductServiceDialog' + +interface IDeveloperProduct { + id: string + name: string + platform: string + scope: 'PUBLIC' | 'PRIVATE' | 'UNLISTED' + status: 'NEW' | 'LOCKED' + hidden: boolean + created: string + updated: string + services: IProductService[] +} + +interface IProductService { + id: string + name: string + type: { id: number; name: string } | null + port: number + enabled: boolean + platformCode: string +} + +export const ProductDetailPage: React.FC = () => { + const { productId } = useParams<{ productId: string }>() + const history = useHistory() + const css = useStyles() + const [product, setProduct] = useState(null) + const [loading, setLoading] = useState(true) + const [updating, setUpdating] = useState(false) + const [addServiceOpen, setAddServiceOpen] = useState(false) + const [deleteService, setDeleteService] = useState(null) + const [deleting, setDeleting] = useState(false) + + const fetchProduct = async () => { + setLoading(true) + const response = await graphQLDeviceProduct(productId) + if (!graphQLGetErrors(response)) { + setProduct(response?.data?.data?.deviceProduct || null) + } + setLoading(false) + } + + useEffect(() => { + fetchProduct() + }, [productId]) + + const handleLockToggle = async () => { + if (!product) return + setUpdating(true) + const response = await graphQLUpdateDeviceProductSettings(product.id, { + lock: product.status !== 'LOCKED', + }) + if (!graphQLGetErrors(response)) { + setProduct(response?.data?.data?.updateDeviceProductSettings || product) + } + setUpdating(false) + } + + const handleHiddenToggle = async () => { + if (!product) return + setUpdating(true) + const response = await graphQLUpdateDeviceProductSettings(product.id, { + hidden: !product.hidden, + }) + if (!graphQLGetErrors(response)) { + setProduct(response?.data?.data?.updateDeviceProductSettings || product) + } + setUpdating(false) + } + + const handleDeleteService = async () => { + if (!deleteService || !product) return + setDeleting(true) + const response = await graphQLRemoveDeviceProductService(deleteService.id) + if (!graphQLGetErrors(response)) { + setProduct({ + ...product, + services: product.services.filter(s => s.id !== deleteService.id), + }) + } + setDeleting(false) + setDeleteService(null) + } + + const handleServiceAdded = (service: IProductService) => { + if (!product) return + setProduct({ + ...product, + services: [...product.services, service], + }) + } + + const isLocked = product?.status === 'LOCKED' + + if (loading) { + return ( + + + + ) + } + + if (!product) { + return ( + + + + + Product not found + + + + + ) + } + + return ( + + + history.push('/products')} sx={{ marginRight: 1 }}> + + + {product.name} + } + /> + + + } + > +
+
+ + Product Settings + + + + + + + + + + + + + + + + +
+ +
+
+ + Services ({product.services.length}) + + {!isLocked && ( + + )} +
+ {product.services.length === 0 ? ( + + No services defined. Add at least one service before locking the product. + + ) : ( + + {product.services.map((service, index) => ( + + {index > 0 && } + + + {!isLocked && ( + + setDeleteService(service)} + size="small" + > + + + + )} + + + ))} + + )} +
+ +
+ + Product Details + + + + + + + + + + + + + + + +
+
+ + setAddServiceOpen(false)} + onServiceAdded={handleServiceAdded} + /> + + setDeleteService(null)} + title="Remove Service" + action={deleting ? 'Removing...' : 'Remove'} + disabled={deleting} + > + + This action cannot be undone. + + + Are you sure you want to remove the service {deleteService?.name}? + + +
+ ) +} + +const useStyles = makeStyles(({ palette }) => ({ + content: { + padding: spacing.md, + }, + section: { + marginBottom: spacing.lg, + backgroundColor: palette.white.main, + borderRadius: 8, + border: `1px solid ${palette.grayLighter.main}`, + padding: spacing.md, + }, + sectionHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.sm, + }, +})) + diff --git a/frontend/src/pages/ProductsPage/ProductsPage.tsx b/frontend/src/pages/ProductsPage/ProductsPage.tsx new file mode 100644 index 000000000..d09a0f776 --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductsPage.tsx @@ -0,0 +1,252 @@ +import React, { useEffect, useState } from 'react' +import { useHistory } from 'react-router-dom' +import { Typography, Button, IconButton, Chip, FormControlLabel, Switch } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { Confirm } from '../../components/Confirm' +import { Notice } from '../../components/Notice' +import { LoadingMessage } from '../../components/LoadingMessage' +import { spacing } from '../../styling' +import { + graphQLDeviceProducts, + graphQLDeleteDeviceProduct, +} from '../../services/graphQLDeviceProducts' +import { graphQLGetErrors } from '../../services/graphQL' + +interface IDeveloperProduct { + id: string + name: string + platform: string + scope: 'PUBLIC' | 'PRIVATE' | 'UNLISTED' + status: 'NEW' | 'LOCKED' + hidden: boolean + created: string + updated: string + services: IProductService[] +} + +interface IProductService { + id: string + name: string + type: { id: number; name: string } | null + port: number + enabled: boolean + platformCode: string +} + +export const ProductsPage: React.FC = () => { + const history = useHistory() + const css = useStyles() + const [products, setProducts] = useState([]) + const [loading, setLoading] = useState(true) + const [showHidden, setShowHidden] = useState(false) + const [deleteProduct, setDeleteProduct] = useState(null) + const [deleting, setDeleting] = useState(false) + + const fetchProducts = async (includeHidden: boolean) => { + setLoading(true) + const response = await graphQLDeviceProducts({ includeHidden }) + if (!graphQLGetErrors(response)) { + setProducts(response?.data?.data?.deviceProducts?.items || []) + } + setLoading(false) + } + + useEffect(() => { + fetchProducts(showHidden) + }, [showHidden]) + + const handleDelete = async () => { + if (!deleteProduct) return + setDeleting(true) + const response = await graphQLDeleteDeviceProduct(deleteProduct.id) + if (!graphQLGetErrors(response)) { + setProducts(products.filter(p => p.id !== deleteProduct.id)) + } + setDeleting(false) + setDeleteProduct(null) + } + + const getScopeColor = (scope: string): 'primary' | 'default' | 'secondary' => { + switch (scope) { + case 'PUBLIC': + return 'primary' + case 'PRIVATE': + return 'secondary' + default: + return 'default' + } + } + + return ( + + + Products + setShowHidden(e.target.checked)} + /> + } + label="Show hidden" + sx={{ marginLeft: 2, marginRight: 2 }} + /> + + + + } + > + {loading ? ( + + ) : products.length === 0 ? ( + + + + No products yet + + + Products are used for bulk device registration and management. + + + + ) : ( +
+ {products.map(product => ( +
history.push(`/products/${product.id}`)}> +
+ {product.name} +
+ {product.hidden && ( + } + /> + )} + + } + /> +
+
+ + Platform: {product.platform} · Services: {product.services?.length || 0} · Updated: {new Date(product.updated).toLocaleDateString()} + + { + e.stopPropagation() + setDeleteProduct(product) + }} + > + + +
+ ))} +
+ )} + setDeleteProduct(null)} + title="Delete Product" + action={deleting ? 'Deleting...' : 'Delete'} + disabled={deleting} + > + + This action cannot be undone. + + + Are you sure you want to delete the product {deleteProduct?.name}? + {deleteProduct && deleteProduct.services.length > 0 && ( + <> +
+
+ This will also delete {deleteProduct.services.length} associated service + {deleteProduct.services.length > 1 ? 's' : ''}. + + )} +
+
+
+ ) +} + +const useStyles = makeStyles(({ palette }) => ({ + list: { + display: 'flex', + flexDirection: 'column', + gap: spacing.md, + padding: spacing.md, + }, + item: { + position: 'relative', + padding: spacing.md, + backgroundColor: palette.white.main, + border: `1px solid ${palette.grayLighter.main}`, + borderRadius: 8, + cursor: 'pointer', + transition: 'border-color 0.2s, box-shadow 0.2s', + '&:hover': { + borderColor: palette.primary.main, + boxShadow: `0 2px 8px ${palette.shadow.main}`, + }, + }, + itemHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + }, + details: { + marginTop: spacing.xs, + }, + chips: { + display: 'flex', + gap: spacing.xs, + }, + deleteButton: { + position: 'absolute', + top: spacing.sm, + right: spacing.sm, + opacity: 0.5, + '&:hover': { + opacity: 1, + color: palette.error.main, + }, + }, +})) + diff --git a/frontend/src/pages/ProductsPage/index.ts b/frontend/src/pages/ProductsPage/index.ts new file mode 100644 index 000000000..ebf236e63 --- /dev/null +++ b/frontend/src/pages/ProductsPage/index.ts @@ -0,0 +1,4 @@ +export { ProductsPage } from './ProductsPage' +export { ProductDetailPage } from './ProductDetailPage' +export { ProductAddPage } from './ProductAddPage' + diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index b85350d8d..5b62a766f 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -48,6 +48,7 @@ import { SharePage } from '../pages/SharePage' import { TagsPage } from '../pages/TagsPage' import { Panel } from '../components/Panel' import { LogsPage } from '../pages/LogsPage' +import { ProductsPage, ProductDetailPage, ProductAddPage } from '../pages/ProductsPage' import { isRemoteUI } from '../helpers/uiHelper' import { GraphsPage } from '../pages/GraphsPage' import { ProfilePage } from '../pages/ProfilePage' @@ -227,6 +228,22 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { + {/* Products */} + + + + + + + + + + + + + + + {/* Announcements */} diff --git a/frontend/src/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts new file mode 100644 index 000000000..a36435333 --- /dev/null +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -0,0 +1,170 @@ +import { graphQLBasicRequest } from './graphQL' + +export async function graphQLPlatformTypes() { + return await graphQLBasicRequest( + ` query PlatformTypes { + platformTypes { + id + name + } + }` + ) +} + +export async function graphQLDeviceProducts(options?: { + includeHidden?: boolean + size?: number + from?: number + after?: string +}) { + return await graphQLBasicRequest( + ` query DeviceProducts($includeHidden: Boolean, $size: Int, $from: Int, $after: ID) { + deviceProducts(includeHidden: $includeHidden, size: $size, from: $from, after: $after) { + items { + id + name + platform + scope + status + hidden + created + updated + services { + id + name + type { id name } + port + enabled + platformCode + } + } + total + hasMore + last + } + }`, + options || {} + ) +} + +export async function graphQLDeviceProduct(id: string) { + return await graphQLBasicRequest( + ` query DeviceProduct($id: ID!) { + deviceProduct(id: $id) { + id + name + platform + scope + status + hidden + created + updated + services { + id + name + type { id name } + port + enabled + platformCode + } + } + }`, + { id } + ) +} + +export async function graphQLCreateDeviceProduct(input: { + name: string + platform: string +}) { + return await graphQLBasicRequest( + ` mutation CreateDeviceProduct($name: String!, $platform: String!) { + createDeviceProduct(name: $name, platform: $platform) { + id + name + platform + scope + status + hidden + created + updated + services { + id + name + type { id name } + port + enabled + platformCode + } + } + }`, + input + ) +} + +export async function graphQLDeleteDeviceProduct(id: string) { + return await graphQLBasicRequest( + ` mutation DeleteDeviceProduct($id: ID!) { + deleteDeviceProduct(id: $id) + }`, + { id } + ) +} + +export async function graphQLUpdateDeviceProductSettings( + id: string, + input: { lock?: boolean; hidden?: boolean } +) { + return await graphQLBasicRequest( + ` mutation UpdateDeviceProductSettings($id: ID!, $input: DeviceProductSettingsInput!) { + updateDeviceProductSettings(id: $id, input: $input) { + id + name + platform + scope + status + hidden + created + updated + services { + id + name + type { id name } + port + enabled + platformCode + } + } + }`, + { id, input } + ) +} + +export async function graphQLAddDeviceProductService( + productId: string, + input: { name: string; type: string; port: number; enabled: boolean } +) { + return await graphQLBasicRequest( + ` mutation AddDeviceProductService($productId: ID!, $name: String!, $type: String!, $port: Int!, $enabled: Boolean!) { + addDeviceProductService(productId: $productId, name: $name, type: $type, port: $port, enabled: $enabled) { + id + name + type { id name } + port + enabled + platformCode + } + }`, + { productId, ...input } + ) +} + +export async function graphQLRemoveDeviceProductService(id: string) { + return await graphQLBasicRequest( + ` mutation RemoveDeviceProductService($id: ID!) { + removeDeviceProductService(id: $id) + }`, + { id } + ) +} + From 83c1770583cb98efcecac9dd5c488a6445e4a5e1 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 10 Dec 2025 23:56:51 -0800 Subject: [PATCH 02/22] feat: add cloud sync for products --- frontend/src/models/index.ts | 3 + frontend/src/models/products.ts | 147 ++++++++++++++++++ .../ProductsPage/AddProductServiceDialog.tsx | 56 ++----- .../src/pages/ProductsPage/ProductAddPage.tsx | 20 +-- .../pages/ProductsPage/ProductDetailPage.tsx | 85 +++------- .../src/pages/ProductsPage/ProductsPage.tsx | 62 ++------ frontend/src/services/CloudSync.ts | 1 + frontend/src/store.ts | 1 + 8 files changed, 208 insertions(+), 167 deletions(-) create mode 100644 frontend/src/models/products.ts diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index 4ef2536f7..607e2561c 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -20,6 +20,7 @@ import mfa from './mfa' import networks from './networks' import organization from './organization' import plans from './plans' +import products from './products' import search from './search' import sessions from './sessions' import shares from './shares' @@ -49,6 +50,7 @@ export interface RootModel extends Models { networks: typeof networks organization: typeof organization plans: typeof plans + products: typeof products search: typeof search sessions: typeof sessions shares: typeof shares @@ -79,6 +81,7 @@ export const models: RootModel = { networks, organization, plans, + products, search, sessions, shares, diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts new file mode 100644 index 000000000..61c4d526a --- /dev/null +++ b/frontend/src/models/products.ts @@ -0,0 +1,147 @@ +import { createModel } from '@rematch/core' +import { RootModel } from '.' +import { + graphQLDeviceProducts, + graphQLCreateDeviceProduct, + graphQLDeleteDeviceProduct, + graphQLUpdateDeviceProductSettings, + graphQLAddDeviceProductService, + graphQLRemoveDeviceProductService, +} from '../services/graphQLDeviceProducts' +import { graphQLGetErrors } from '../services/graphQL' + +export interface IProductService { + id: string + name: string + type: { id: number; name: string } | null + port: number + enabled: boolean + platformCode: string +} + +export interface IDeviceProduct { + id: string + name: string + platform: string + scope: 'PUBLIC' | 'PRIVATE' | 'UNLISTED' + status: 'NEW' | 'LOCKED' + hidden: boolean + created: string + updated: string + services: IProductService[] +} + +type ProductsState = { + initialized: boolean + fetching: boolean + all: IDeviceProduct[] +} + +const defaultState: ProductsState = { + initialized: false, + fetching: false, + all: [], +} + +export default createModel()({ + state: { ...defaultState }, + effects: dispatch => ({ + async fetch(_: void, state) { + dispatch.products.set({ fetching: true }) + const response = await graphQLDeviceProducts({ includeHidden: true }) + if (!graphQLGetErrors(response)) { + const products = response?.data?.data?.deviceProducts?.items || [] + console.log('LOADED PRODUCTS', products) + dispatch.products.set({ all: products, initialized: true }) + } + dispatch.products.set({ fetching: false }) + }, + + async fetchIfEmpty(_: void, state) { + if (!state.products.initialized) { + await dispatch.products.fetch() + } + }, + + async create(input: { name: string; platform: string }, state) { + const response = await graphQLCreateDeviceProduct(input) + if (!graphQLGetErrors(response)) { + const newProduct = response?.data?.data?.createDeviceProduct + if (newProduct) { + dispatch.products.set({ + all: [...state.products.all, newProduct], + }) + return newProduct + } + } + return null + }, + + async delete(id: string, state) { + const response = await graphQLDeleteDeviceProduct(id) + if (!graphQLGetErrors(response)) { + dispatch.products.set({ + all: state.products.all.filter(p => p.id !== id), + }) + } + return !graphQLGetErrors(response) + }, + + async updateSettings({ id, input }: { id: string; input: { lock?: boolean; hidden?: boolean } }, state) { + const response = await graphQLUpdateDeviceProductSettings(id, input) + if (!graphQLGetErrors(response)) { + const updatedProduct = response?.data?.data?.updateDeviceProductSettings + if (updatedProduct) { + dispatch.products.set({ + all: state.products.all.map(p => (p.id === id ? updatedProduct : p)), + }) + } + return updatedProduct + } + return null + }, + + async addService( + { productId, input }: { productId: string; input: { name: string; type: string; port: number; enabled: boolean } }, + state + ) { + const response = await graphQLAddDeviceProductService(productId, input) + if (!graphQLGetErrors(response)) { + const newService = response?.data?.data?.addDeviceProductService + if (newService) { + dispatch.products.set({ + all: state.products.all.map(p => + p.id === productId ? { ...p, services: [...p.services, newService] } : p + ), + }) + } + return newService + } + return null + }, + + async removeService({ productId, serviceId }: { productId: string; serviceId: string }, state) { + const response = await graphQLRemoveDeviceProductService(serviceId) + if (!graphQLGetErrors(response)) { + dispatch.products.set({ + all: state.products.all.map(p => + p.id === productId ? { ...p, services: p.services.filter(s => s.id !== serviceId) } : p + ), + }) + return true + } + return false + }, + }), + reducers: { + reset(state) { + state = { ...defaultState } + return state + }, + set(state, params: Partial) { + Object.keys(params).forEach(key => (state[key] = params[key])) + return state + }, + }, +}) + diff --git a/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx b/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx index ddde14b39..d6b89e944 100644 --- a/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx +++ b/frontend/src/pages/ProductsPage/AddProductServiceDialog.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react' +import { useSelector } from 'react-redux' import { Dialog, DialogTitle, @@ -15,30 +16,8 @@ import { } from '@mui/material' import { Icon } from '../../components/Icon' import { Notice } from '../../components/Notice' -import { graphQLAddDeviceProductService } from '../../services/graphQLDeviceProducts' -import { graphQLGetErrors } from '../../services/graphQL' - -// Common service types -const SERVICE_TYPES = [ - { value: '1', label: 'TCP (1)' }, - { value: '4', label: 'SSH (4)' }, - { value: '5', label: 'Web/HTTP (5)' }, - { value: '7', label: 'VNC (7)' }, - { value: '8', label: 'HTTPS (8)' }, - { value: '28', label: 'RDP (28)' }, - { value: '33', label: 'Samba (33)' }, - { value: '34', label: 'Bulk Service (34)' }, - { value: '35', label: 'Bulk Identification (35)' }, -] - -interface IProductService { - id: string - name: string - type: string - port: number - enabled: boolean - platformCode: string -} +import { dispatch, State } from '../../store' +import { IProductService } from '../../models/products' interface Props { open: boolean @@ -53,6 +32,7 @@ export const AddProductServiceDialog: React.FC = ({ onClose, onServiceAdded, }) => { + const applicationTypes = useSelector((state: State) => state.applicationTypes.all) const [name, setName] = useState('') const [type, setType] = useState('') const [port, setPort] = useState('') @@ -91,27 +71,23 @@ export const AddProductServiceDialog: React.FC = ({ setError(null) setCreating(true) - const response = await graphQLAddDeviceProductService(productId, { - name: name.trim(), - type, - port: portNum, - enabled, + const service = await dispatch.products.addService({ + productId, + input: { + name: name.trim(), + type, + port: portNum, + enabled, + }, }) - if (graphQLGetErrors(response)) { - setError('Failed to add service') - setCreating(false) - return - } - - const service = response?.data?.data?.addDeviceProductService if (service) { onServiceAdded(service) handleClose() } else { setError('Failed to add service') - setCreating(false) } + setCreating(false) } return ( @@ -143,9 +119,9 @@ export const AddProductServiceDialog: React.FC = ({ label="Service Type" disabled={creating} > - {SERVICE_TYPES.map(t => ( - - {t.label} + {applicationTypes.map(t => ( + + {t.name} ))} diff --git a/frontend/src/pages/ProductsPage/ProductAddPage.tsx b/frontend/src/pages/ProductsPage/ProductAddPage.tsx index 9d7caa3d6..912b02cef 100644 --- a/frontend/src/pages/ProductsPage/ProductAddPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductAddPage.tsx @@ -16,7 +16,8 @@ import { Title } from '../../components/Title' import { Icon } from '../../components/Icon' import { Notice } from '../../components/Notice' import { spacing } from '../../styling' -import { graphQLCreateDeviceProduct, graphQLPlatformTypes } from '../../services/graphQLDeviceProducts' +import { dispatch } from '../../store' +import { graphQLPlatformTypes } from '../../services/graphQLDeviceProducts' import { graphQLGetErrors } from '../../services/graphQL' interface IPlatformType { @@ -29,7 +30,7 @@ export const ProductAddPage: React.FC = () => { const css = useStyles() const [name, setName] = useState('') const [platform, setPlatform] = useState('') - const [platforms, setPlatforms] = useState([]) + const [platformTypes, setPlatformTypes] = useState([]) const [creating, setCreating] = useState(false) const [error, setError] = useState(null) @@ -37,7 +38,7 @@ export const ProductAddPage: React.FC = () => { const fetchPlatforms = async () => { const response = await graphQLPlatformTypes() if (!graphQLGetErrors(response)) { - setPlatforms(response?.data?.data?.platformTypes || []) + setPlatformTypes(response?.data?.data?.platformTypes || []) } } fetchPlatforms() @@ -56,18 +57,11 @@ export const ProductAddPage: React.FC = () => { setError(null) setCreating(true) - const response = await graphQLCreateDeviceProduct({ + const product = await dispatch.products.create({ name: name.trim(), platform, }) - if (graphQLGetErrors(response)) { - setError('Failed to create product') - setCreating(false) - return - } - - const product = response?.data?.data?.createDeviceProduct if (product) { history.push(`/products/${product.id}`) } else { @@ -112,9 +106,9 @@ export const ProductAddPage: React.FC = () => { value={platform} onChange={e => setPlatform(e.target.value)} label="Platform" - disabled={creating || platforms.length === 0} + disabled={creating || platformTypes.length === 0} > - {platforms.map(p => ( + {platformTypes.map(p => ( {p.name} diff --git a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx index d9689adc2..67a8cfd0e 100644 --- a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { useParams, useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' import { Typography, Button, @@ -20,110 +21,60 @@ import { Body } from '../../components/Body' import { Notice } from '../../components/Notice' import { Confirm } from '../../components/Confirm' import { LoadingMessage } from '../../components/LoadingMessage' -import { InlineTextFieldSetting } from '../../components/InlineTextFieldSetting' import { spacing } from '../../styling' -import { - graphQLDeviceProduct, - graphQLUpdateDeviceProductSettings, - graphQLRemoveDeviceProductService, -} from '../../services/graphQLDeviceProducts' -import { graphQLGetErrors } from '../../services/graphQL' +import { dispatch, State } from '../../store' +import { IProductService } from '../../models/products' import { AddProductServiceDialog } from './AddProductServiceDialog' -interface IDeveloperProduct { - id: string - name: string - platform: string - scope: 'PUBLIC' | 'PRIVATE' | 'UNLISTED' - status: 'NEW' | 'LOCKED' - hidden: boolean - created: string - updated: string - services: IProductService[] -} - -interface IProductService { - id: string - name: string - type: { id: number; name: string } | null - port: number - enabled: boolean - platformCode: string -} - export const ProductDetailPage: React.FC = () => { const { productId } = useParams<{ productId: string }>() const history = useHistory() const css = useStyles() - const [product, setProduct] = useState(null) - const [loading, setLoading] = useState(true) + const { all: products, fetching, initialized } = useSelector((state: State) => state.products) + const product = products.find(p => p.id === productId) const [updating, setUpdating] = useState(false) const [addServiceOpen, setAddServiceOpen] = useState(false) const [deleteService, setDeleteService] = useState(null) const [deleting, setDeleting] = useState(false) - const fetchProduct = async () => { - setLoading(true) - const response = await graphQLDeviceProduct(productId) - if (!graphQLGetErrors(response)) { - setProduct(response?.data?.data?.deviceProduct || null) - } - setLoading(false) - } - - useEffect(() => { - fetchProduct() - }, [productId]) - const handleLockToggle = async () => { if (!product) return setUpdating(true) - const response = await graphQLUpdateDeviceProductSettings(product.id, { - lock: product.status !== 'LOCKED', + await dispatch.products.updateSettings({ + id: product.id, + input: { lock: product.status !== 'LOCKED' }, }) - if (!graphQLGetErrors(response)) { - setProduct(response?.data?.data?.updateDeviceProductSettings || product) - } setUpdating(false) } const handleHiddenToggle = async () => { if (!product) return setUpdating(true) - const response = await graphQLUpdateDeviceProductSettings(product.id, { - hidden: !product.hidden, + await dispatch.products.updateSettings({ + id: product.id, + input: { hidden: !product.hidden }, }) - if (!graphQLGetErrors(response)) { - setProduct(response?.data?.data?.updateDeviceProductSettings || product) - } setUpdating(false) } const handleDeleteService = async () => { if (!deleteService || !product) return setDeleting(true) - const response = await graphQLRemoveDeviceProductService(deleteService.id) - if (!graphQLGetErrors(response)) { - setProduct({ - ...product, - services: product.services.filter(s => s.id !== deleteService.id), - }) - } + await dispatch.products.removeService({ + productId: product.id, + serviceId: deleteService.id, + }) setDeleting(false) setDeleteService(null) } const handleServiceAdded = (service: IProductService) => { - if (!product) return - setProduct({ - ...product, - services: [...product.services, service], - }) + // Service is already added to store by AddProductServiceDialog } const isLocked = product?.status === 'LOCKED' - if (loading) { + if (fetching && !initialized) { return ( diff --git a/frontend/src/pages/ProductsPage/ProductsPage.tsx b/frontend/src/pages/ProductsPage/ProductsPage.tsx index d09a0f776..f1f191edc 100644 --- a/frontend/src/pages/ProductsPage/ProductsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsPage.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' import { Typography, Button, IconButton, Chip, FormControlLabel, Switch } from '@mui/material' import { makeStyles } from '@mui/styles' import { Container } from '../../components/Container' @@ -10,62 +11,29 @@ import { Confirm } from '../../components/Confirm' import { Notice } from '../../components/Notice' import { LoadingMessage } from '../../components/LoadingMessage' import { spacing } from '../../styling' -import { - graphQLDeviceProducts, - graphQLDeleteDeviceProduct, -} from '../../services/graphQLDeviceProducts' -import { graphQLGetErrors } from '../../services/graphQL' - -interface IDeveloperProduct { - id: string - name: string - platform: string - scope: 'PUBLIC' | 'PRIVATE' | 'UNLISTED' - status: 'NEW' | 'LOCKED' - hidden: boolean - created: string - updated: string - services: IProductService[] -} - -interface IProductService { - id: string - name: string - type: { id: number; name: string } | null - port: number - enabled: boolean - platformCode: string -} +import { dispatch, State } from '../../store' +import { IDeviceProduct } from '../../models/products' export const ProductsPage: React.FC = () => { const history = useHistory() const css = useStyles() - const [products, setProducts] = useState([]) - const [loading, setLoading] = useState(true) + const { all: allProducts, fetching, initialized } = useSelector((state: State) => state.products) + + useEffect(() => { + dispatch.products.fetchIfEmpty() + }, []) const [showHidden, setShowHidden] = useState(false) - const [deleteProduct, setDeleteProduct] = useState(null) + const [deleteProduct, setDeleteProduct] = useState(null) const [deleting, setDeleting] = useState(false) - const fetchProducts = async (includeHidden: boolean) => { - setLoading(true) - const response = await graphQLDeviceProducts({ includeHidden }) - if (!graphQLGetErrors(response)) { - setProducts(response?.data?.data?.deviceProducts?.items || []) - } - setLoading(false) - } - - useEffect(() => { - fetchProducts(showHidden) - }, [showHidden]) + const products = useMemo(() => { + return showHidden ? allProducts : allProducts.filter(p => !p.hidden) + }, [allProducts, showHidden]) const handleDelete = async () => { if (!deleteProduct) return setDeleting(true) - const response = await graphQLDeleteDeviceProduct(deleteProduct.id) - if (!graphQLGetErrors(response)) { - setProducts(products.filter(p => p.id !== deleteProduct.id)) - } + await dispatch.products.delete(deleteProduct.id) setDeleting(false) setDeleteProduct(null) } @@ -112,7 +80,7 @@ export const ProductsPage: React.FC = () => { } > - {loading ? ( + {fetching && !initialized ? ( ) : products.length === 0 ? ( diff --git a/frontend/src/services/CloudSync.ts b/frontend/src/services/CloudSync.ts index 1382c2046..57709c8c1 100644 --- a/frontend/src/services/CloudSync.ts +++ b/frontend/src/services/CloudSync.ts @@ -64,6 +64,7 @@ class CloudSync { dispatch.networks.fetch, dispatch.connections.fetch, dispatch.files.fetch, + dispatch.products.fetch, ]) } } diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 2997be733..c820815be 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -28,6 +28,7 @@ const persistConfig: PersistConfig = { 'networks', 'organization', 'plans', + 'products', 'sessions', 'tags', 'user', From bfd592894c7e8ec2f7a38db4ac1b5d1519cf8d8d Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 11 Dec 2025 01:17:15 -0800 Subject: [PATCH 03/22] feat: match scripting and device page layout --- frontend/src/components/GridList.tsx | 5 +- frontend/src/components/GridListHeader.tsx | 2 +- .../src/components/GridListHeaderTitle.tsx | 1 + frontend/src/components/GridListItem.tsx | 2 +- frontend/src/components/Header/Header.tsx | 2 + .../Header/ProductsHeaderButtons.tsx | 44 +++ frontend/src/components/ProductAttributes.tsx | 89 +++++++ frontend/src/components/ProductList.tsx | 69 +++++ frontend/src/components/ProductListItem.tsx | 65 +++++ frontend/src/components/ProductsActionBar.tsx | 110 ++++++++ frontend/src/models/products.ts | 49 ++++ .../src/pages/ProductsPage/ProductsPage.tsx | 251 +++++------------- frontend/src/routers/Router.tsx | 7 +- 13 files changed, 509 insertions(+), 187 deletions(-) create mode 100644 frontend/src/components/Header/ProductsHeaderButtons.tsx create mode 100644 frontend/src/components/ProductAttributes.tsx create mode 100644 frontend/src/components/ProductList.tsx create mode 100644 frontend/src/components/ProductListItem.tsx create mode 100644 frontend/src/components/ProductsActionBar.tsx diff --git a/frontend/src/components/GridList.tsx b/frontend/src/components/GridList.tsx index fadb57f10..127ae3987 100644 --- a/frontend/src/components/GridList.tsx +++ b/frontend/src/components/GridList.tsx @@ -98,8 +98,9 @@ const useStyles = makeStyles(({ palette }) => ({ minHeight: rowHeight - 6 - rowShrink, }, '& .attribute': { - display: 'block', - minHeight: 0, + display: 'flex', + alignItems: 'center', + minHeight: rowHeight - 6 - rowShrink, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', diff --git a/frontend/src/components/GridListHeader.tsx b/frontend/src/components/GridListHeader.tsx index f418d543b..417066742 100644 --- a/frontend/src/components/GridListHeader.tsx +++ b/frontend/src/components/GridListHeader.tsx @@ -64,7 +64,7 @@ export const GridListHeader: React.FC = ({ /> {required && ( - {icon} + {icon && {icon}} )} {!mobile && diff --git a/frontend/src/components/GridListHeaderTitle.tsx b/frontend/src/components/GridListHeaderTitle.tsx index 4944531b6..c4cb115f9 100644 --- a/frontend/src/components/GridListHeaderTitle.tsx +++ b/frontend/src/components/GridListHeaderTitle.tsx @@ -49,6 +49,7 @@ const useStyles = makeStyles(({ palette }) => ({ whiteSpace: 'nowrap', textOverflow: 'ellipsis', display: 'flex', + alignItems: 'center', '& .hoverHide': { opacity: 0, transition: 'opacity 200ms' }, '&:hover .hoverHide': { opacity: 1, transition: 'opacity 200ms' }, }, diff --git a/frontend/src/components/GridListItem.tsx b/frontend/src/components/GridListItem.tsx index f8abd9986..cfe9c0710 100644 --- a/frontend/src/components/GridListItem.tsx +++ b/frontend/src/components/GridListItem.tsx @@ -17,7 +17,7 @@ export const GridListItem: React.FC = ({ required, icon, mobile, children - {icon} + {icon && {icon}} {required} diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index cd66bc827..49aa27169 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -20,6 +20,7 @@ import { FilterButton } from '../../buttons/FilterButton' import { IconButton } from '../../buttons/IconButton' import { Title } from '../Title' import { Box } from '@mui/material' +import { ProductsHeaderButtons } from './ProductsHeaderButtons' export const Header: React.FC = () => { const { searched } = useSelector(selectDeviceModelAttributes) @@ -123,6 +124,7 @@ export const Header: React.FC = () => { )} + {!showSearch && ( diff --git a/frontend/src/components/Header/ProductsHeaderButtons.tsx b/frontend/src/components/Header/ProductsHeaderButtons.tsx new file mode 100644 index 000000000..f25d66128 --- /dev/null +++ b/frontend/src/components/Header/ProductsHeaderButtons.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { useHistory } from 'react-router-dom' +import { Switch, Route } from 'react-router-dom' +import { Button } from '@mui/material' +import { State, dispatch } from '../../store' +import { IconButton } from '../../buttons/IconButton' +import { Icon } from '../Icon' + +export const ProductsHeaderButtons: React.FC = () => { + const history = useHistory() + const showHidden = useSelector((state: State) => state.products?.showHidden) || false + + return ( + + + ) +} + diff --git a/frontend/src/components/ProductAttributes.tsx b/frontend/src/components/ProductAttributes.tsx new file mode 100644 index 000000000..3718c1ffb --- /dev/null +++ b/frontend/src/components/ProductAttributes.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { Chip, Typography } from '@mui/material' +import { Attribute } from './Attributes' +import { Timestamp } from './Timestamp' +import { IDeviceProduct } from '../models/products' + +export class ProductAttribute extends Attribute { + type: Attribute['type'] = 'DEVICE' +} + +interface IProductOptions { + product?: IDeviceProduct +} + +export const productAttributes: ProductAttribute[] = [ + new ProductAttribute({ + id: 'productName', + label: 'Name', + required: true, + defaultWidth: 300, + value: ({ product }: IProductOptions) => ( + + {product?.name} + + ), + }), + new ProductAttribute({ + id: 'productPlatform', + label: 'Platform', + defaultWidth: 100, + value: ({ product }: IProductOptions) => ( + + {product?.platform} + + ), + }), + new ProductAttribute({ + id: 'productStatus', + label: 'Status', + defaultWidth: 100, + value: ({ product }: IProductOptions) => ( + + ), + }), + new ProductAttribute({ + id: 'productScope', + label: 'Scope', + defaultWidth: 90, + value: ({ product }: IProductOptions) => ( + + ), + }), + new ProductAttribute({ + id: 'productServices', + label: 'Services', + defaultWidth: 80, + value: ({ product }: IProductOptions) => ( + + {product?.services?.length || 0} + + ), + }), + new ProductAttribute({ + id: 'productHidden', + label: 'Hidden', + defaultWidth: 80, + value: ({ product }: IProductOptions) => + product?.hidden ? ( + + ) : null, + }), + new ProductAttribute({ + id: 'productUpdated', + label: 'Updated', + defaultWidth: 150, + value: ({ product }: IProductOptions) => + product?.updated && , + }), +] + diff --git a/frontend/src/components/ProductList.tsx b/frontend/src/components/ProductList.tsx new file mode 100644 index 000000000..845c10a0a --- /dev/null +++ b/frontend/src/components/ProductList.tsx @@ -0,0 +1,69 @@ +import React from 'react' +import { useMediaQuery, Checkbox } from '@mui/material' +import { MOBILE_WIDTH } from '../constants' +import { ProductListItem } from './ProductListItem' +import { Attribute } from './Attributes' +import { GridList } from './GridList' +import { Icon } from './Icon' +import { IDeviceProduct } from '../models/products' + +export interface ProductListProps { + attributes: Attribute[] + required?: Attribute + columnWidths: ILookup + fetching?: boolean + products?: IDeviceProduct[] + select?: boolean + selected?: string[] + onSelect?: (id: string) => void + onSelectAll?: (checked: boolean) => void +} + +export const ProductList: React.FC = ({ + attributes, + required, + products = [], + columnWidths, + fetching, + select, + selected = [], + onSelect, + onSelectAll, +}) => { + const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const allSelected = products.length > 0 && selected.length === products.length + const someSelected = selected.length > 0 && selected.length < products.length + + const headerIcon = select ? ( + onSelectAll?.(e.target.checked)} + onClick={e => e.stopPropagation()} + checkedIcon={} + indeterminateIcon={} + icon={} + color="primary" + /> + ) : ( + + ) + + return ( + + {products?.map(product => ( + + ))} + + ) +} + diff --git a/frontend/src/components/ProductListItem.tsx b/frontend/src/components/ProductListItem.tsx new file mode 100644 index 000000000..110ac029a --- /dev/null +++ b/frontend/src/components/ProductListItem.tsx @@ -0,0 +1,65 @@ +import React from 'react' +import { useHistory } from 'react-router-dom' +import { Box } from '@mui/material' +import { GridListItem } from './GridListItem' +import { Attribute } from './Attributes' +import { Icon } from './Icon' +import { IDeviceProduct } from '../models/products' + +interface Props { + product: IDeviceProduct + required?: Attribute + attributes: Attribute[] + mobile?: boolean + select?: boolean + selected?: boolean + onSelect?: (id: string) => void +} + +export const ProductListItem: React.FC = ({ + product, + required, + attributes, + mobile, + select, + selected, + onSelect, +}) => { + const history = useHistory() + + const handleClick = () => { + if (select && onSelect) { + onSelect(product.id) + } else { + history.push(`/products/${product.id}`) + } + } + + return ( + + ) : ( + + ) + ) : ( + + ) + } + required={required?.value({ product })} + > + {attributes.map(attribute => ( + + {attribute.value({ product })} + + ))} + + ) +} + diff --git a/frontend/src/components/ProductsActionBar.tsx b/frontend/src/components/ProductsActionBar.tsx new file mode 100644 index 000000000..e84748c36 --- /dev/null +++ b/frontend/src/components/ProductsActionBar.tsx @@ -0,0 +1,110 @@ +import React, { useState } from 'react' +import { makeStyles } from '@mui/styles' +import { MOBILE_WIDTH } from '../constants' +import { useMediaQuery, Box, Typography, Collapse } from '@mui/material' +import { State } from '../store' +import { useSelector } from 'react-redux' +import { useHistory } from 'react-router-dom' +import { ConfirmIconButton } from '../buttons/ConfirmIconButton' +import { IconButton } from '../buttons/IconButton' +import { dispatch } from '../store' +import { Notice } from './Notice' +import { Title } from './Title' +import { Icon } from './Icon' +import { spacing, radius } from '../styling' + +type Props = { + select?: boolean +} + +export const ProductsActionBar: React.FC = ({ select }) => { + const productsState = useSelector((state: State) => state.products) + const selected = productsState?.selected || [] + const [deleting, setDeleting] = useState(false) + const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) + const history = useHistory() + const css = useStyles() + + const handleDelete = async () => { + setDeleting(true) + await dispatch.products.deleteSelected() + setDeleting(false) + history.push('/products') + } + + return ( + + + + <Typography variant="subtitle1"> + {selected.length}  + {mobile ? <Icon name="check" inline /> : 'Selected'} + </Typography> + + + + + This action cannot be undone. + + + Are you sure you want to delete {selected.length} product + {selected.length === 1 ? '' : 's'}? + + + ), + }} + confirm + /> + { + dispatch.products.clearSelection() + history.push('/products') + }} + /> + + + + ) +} + +const useStyles = makeStyles(({ palette }) => ({ + actions: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + borderTop: `1px solid ${palette.white.main}`, + position: 'relative', + overflow: 'hidden', + backgroundColor: palette.primary.main, + borderRadius: radius.lg, + marginLeft: spacing.sm, + marginRight: spacing.sm, + marginBottom: spacing.xs, + paddingRight: spacing.sm, + zIndex: 10, + '& .MuiTypography-subtitle1': { + marginTop: spacing.xs, + marginBottom: spacing.xs, + fontWeight: 800, + color: palette.alwaysWhite.main, + }, + }, +})) + diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts index 61c4d526a..ec5e5ebfe 100644 --- a/frontend/src/models/products.ts +++ b/frontend/src/models/products.ts @@ -35,12 +35,16 @@ type ProductsState = { initialized: boolean fetching: boolean all: IDeviceProduct[] + selected: string[] + showHidden: boolean } const defaultState: ProductsState = { initialized: false, fetching: false, all: [], + selected: [], + showHidden: false, } export default createModel()({ @@ -82,11 +86,56 @@ export default createModel()({ if (!graphQLGetErrors(response)) { dispatch.products.set({ all: state.products.all.filter(p => p.id !== id), + selected: state.products.selected.filter(s => s !== id), }) } return !graphQLGetErrors(response) }, + async deleteSelected(_: void, state) { + const { selected, all } = state.products + if (!selected.length) return + + const results = await Promise.all( + selected.map(id => graphQLDeleteDeviceProduct(id)) + ) + + const successIds = selected.filter((id, i) => !graphQLGetErrors(results[i])) + + dispatch.products.set({ + all: all.filter(p => !successIds.includes(p.id)), + selected: [], + }) + }, + + select(id: string, state) { + const { selected } = state.products + if (selected.includes(id)) { + dispatch.products.set({ selected: selected.filter(s => s !== id) }) + } else { + dispatch.products.set({ selected: [...selected, id] }) + } + }, + + selectAll(checked: boolean, state) { + const { all, showHidden } = state.products + const visibleProducts = showHidden ? all : all.filter(p => !p.hidden) + dispatch.products.set({ + selected: checked ? visibleProducts.map(p => p.id) : [], + }) + }, + + clearSelection() { + dispatch.products.set({ selected: [] }) + }, + + toggleShowHidden(_: void, state) { + dispatch.products.set({ + showHidden: !state.products.showHidden, + selected: [], + }) + }, + async updateSettings({ id, input }: { id: string; input: { lock?: boolean; hidden?: boolean } }, state) { const response = await graphQLUpdateDeviceProductSettings(id, input) if (!graphQLGetErrors(response)) { diff --git a/frontend/src/pages/ProductsPage/ProductsPage.tsx b/frontend/src/pages/ProductsPage/ProductsPage.tsx index f1f191edc..e17ed3e0d 100644 --- a/frontend/src/pages/ProductsPage/ProductsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsPage.tsx @@ -1,84 +1,59 @@ -import React, { useEffect, useMemo, useState } from 'react' -import { useHistory } from 'react-router-dom' +import React, { useEffect, useMemo } from 'react' +import { useHistory, useRouteMatch } from 'react-router-dom' import { useSelector } from 'react-redux' -import { Typography, Button, IconButton, Chip, FormControlLabel, Switch } from '@mui/material' -import { makeStyles } from '@mui/styles' +import { Typography, 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 { Confirm } from '../../components/Confirm' -import { Notice } from '../../components/Notice' import { LoadingMessage } from '../../components/LoadingMessage' -import { spacing } from '../../styling' +import { ProductList } from '../../components/ProductList' +import { ProductsActionBar } from '../../components/ProductsActionBar' +import { productAttributes } from '../../components/ProductAttributes' +import { removeObject } from '../../helpers/utilHelper' import { dispatch, State } from '../../store' -import { IDeviceProduct } from '../../models/products' export const ProductsPage: React.FC = () => { const history = useHistory() - const css = useStyles() - const { all: allProducts, fetching, initialized } = useSelector((state: State) => state.products) + const selectMatch = useRouteMatch('/products/select') + const select = !!selectMatch + const productsState = useSelector((state: State) => state.products) + const allProducts = productsState?.all || [] + const fetching = productsState?.fetching || false + const initialized = productsState?.initialized || false + const selected = productsState?.selected || [] + const showHidden = productsState?.showHidden || false + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const [required, attributes] = removeObject(productAttributes, a => a.required === true) + + const products = useMemo(() => { + return showHidden ? allProducts : allProducts.filter(p => !p.hidden) + }, [allProducts, showHidden]) useEffect(() => { dispatch.products.fetchIfEmpty() }, []) - const [showHidden, setShowHidden] = useState(false) - const [deleteProduct, setDeleteProduct] = useState(null) - const [deleting, setDeleting] = useState(false) - const products = useMemo(() => { - return showHidden ? allProducts : allProducts.filter(p => !p.hidden) - }, [allProducts, showHidden]) + // Clear selection when leaving select mode + useEffect(() => { + if (!select && selected.length > 0) { + dispatch.products.clearSelection() + } + }, [select]) - const handleDelete = async () => { - if (!deleteProduct) return - setDeleting(true) - await dispatch.products.delete(deleteProduct.id) - setDeleting(false) - setDeleteProduct(null) + const handleSelect = (id: string) => { + dispatch.products.select(id) } - const getScopeColor = (scope: string): 'primary' | 'default' | 'secondary' => { - switch (scope) { - case 'PUBLIC': - return 'primary' - case 'PRIVATE': - return 'secondary' - default: - return 'default' - } + const handleSelectAll = (checked: boolean) => { + dispatch.products.selectAll(checked) } return ( - - Products - setShowHidden(e.target.checked)} - /> - } - label="Show hidden" - sx={{ marginLeft: 2, marginRight: 2 }} - /> - - - - } + bodyProps={{ verticalOverflow: true, horizontalOverflow: true }} + header={} > {fetching && !initialized ? ( @@ -86,135 +61,47 @@ export const ProductsPage: React.FC = () => { - No products yet + {showHidden ? 'No products' : 'No visible products'} - Products are used for bulk device registration and management. + {showHidden + ? 'Products are used for bulk device registration and management.' + : 'All products may be hidden. Click the eye icon to show hidden products.'} - + {!showHidden && allProducts.length > 0 ? ( + + ) : ( + + )} ) : ( -
- {products.map(product => ( -
history.push(`/products/${product.id}`)}> -
- {product.name} -
- {product.hidden && ( - } - /> - )} - - } - /> -
-
- - Platform: {product.platform} · Services: {product.services?.length || 0} · Updated: {new Date(product.updated).toLocaleDateString()} - - { - e.stopPropagation() - setDeleteProduct(product) - }} - > - - -
- ))} -
+ )} - setDeleteProduct(null)} - title="Delete Product" - action={deleting ? 'Deleting...' : 'Delete'} - disabled={deleting} - > - - This action cannot be undone. - - - Are you sure you want to delete the product {deleteProduct?.name}? - {deleteProduct && deleteProduct.services.length > 0 && ( - <> -
-
- This will also delete {deleteProduct.services.length} associated service - {deleteProduct.services.length > 1 ? 's' : ''}. - - )} -
-
) } - -const useStyles = makeStyles(({ palette }) => ({ - list: { - display: 'flex', - flexDirection: 'column', - gap: spacing.md, - padding: spacing.md, - }, - item: { - position: 'relative', - padding: spacing.md, - backgroundColor: palette.white.main, - border: `1px solid ${palette.grayLighter.main}`, - borderRadius: 8, - cursor: 'pointer', - transition: 'border-color 0.2s, box-shadow 0.2s', - '&:hover': { - borderColor: palette.primary.main, - boxShadow: `0 2px 8px ${palette.shadow.main}`, - }, - }, - itemHeader: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.xs, - }, - details: { - marginTop: spacing.xs, - }, - chips: { - display: 'flex', - gap: spacing.xs, - }, - deleteButton: { - position: 'absolute', - top: spacing.sm, - right: spacing.sm, - opacity: 0.5, - '&:hover': { - opacity: 1, - color: palette.error.main, - }, - }, -})) - diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index 5b62a766f..82eccc98e 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -234,12 +234,17 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => {
+ + + + + - + From 5152e4e61d366fc2601468c31055f51e66c566b5 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 11 Dec 2025 10:31:31 -0800 Subject: [PATCH 04/22] feat: updated product list page --- .../buttons/RefreshButton/RefreshButton.tsx | 6 ++++++ frontend/src/components/ProductAttributes.tsx | 21 +++++++------------ frontend/src/models/products.ts | 2 +- .../pages/ProductsPage/ProductDetailPage.tsx | 5 ++--- .../src/services/graphQLDeviceProducts.ts | 8 +++---- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/frontend/src/buttons/RefreshButton/RefreshButton.tsx b/frontend/src/buttons/RefreshButton/RefreshButton.tsx index d0ceff9fa..d69a501c1 100644 --- a/frontend/src/buttons/RefreshButton/RefreshButton.tsx +++ b/frontend/src/buttons/RefreshButton/RefreshButton.tsx @@ -28,6 +28,7 @@ export const RefreshButton: React.FC = props => { const networkPage = useRouteMatch('/networks') const logsPage = useRouteMatch(['/logs', '/devices/:deviceID/logs']) const devicesPage = useRouteMatch('/devices') + const productsPage = useRouteMatch('/products') const scriptingPage = useRouteMatch(['/script', '/scripts', '/runs']) const scriptPage = useRouteMatch('/script') @@ -79,6 +80,11 @@ export const RefreshButton: React.FC = props => { await dispatch.devices.fetchList() } }) + + // products pages + } else if (productsPage) { + title = 'Refresh products' + methods.push(dispatch.products.fetch) } const refresh = async () => { diff --git a/frontend/src/components/ProductAttributes.tsx b/frontend/src/components/ProductAttributes.tsx index 3718c1ffb..3d3f6938c 100644 --- a/frontend/src/components/ProductAttributes.tsx +++ b/frontend/src/components/ProductAttributes.tsx @@ -30,7 +30,7 @@ export const productAttributes: ProductAttribute[] = [ defaultWidth: 100, value: ({ product }: IProductOptions) => ( - {product?.platform} + {product?.platform?.name || product?.platform?.id} ), }), @@ -47,18 +47,6 @@ export const productAttributes: ProductAttribute[] = [ /> ), }), - new ProductAttribute({ - id: 'productScope', - label: 'Scope', - defaultWidth: 90, - value: ({ product }: IProductOptions) => ( - - ), - }), new ProductAttribute({ id: 'productServices', label: 'Services', @@ -78,6 +66,13 @@ export const productAttributes: ProductAttribute[] = [ ) : null, }), + new ProductAttribute({ + id: 'productCreated', + label: 'Created', + defaultWidth: 150, + value: ({ product }: IProductOptions) => + product?.created && , + }), new ProductAttribute({ id: 'productUpdated', label: 'Updated', diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts index ec5e5ebfe..8dd95aa69 100644 --- a/frontend/src/models/products.ts +++ b/frontend/src/models/products.ts @@ -22,7 +22,7 @@ export interface IProductService { export interface IDeviceProduct { id: string name: string - platform: string + platform: { id: number; name: string | null } | null scope: 'PUBLIC' | 'PRIVATE' | 'UNLISTED' status: 'NEW' | 'LOCKED' hidden: boolean diff --git a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx index 67a8cfd0e..a4b8688e7 100644 --- a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx @@ -153,8 +153,7 @@ export const ProductDetailPage: React.FC = () => { Services ({product.services.length}) {!isLocked && ( - )} @@ -197,7 +196,7 @@ export const ProductDetailPage: React.FC = () => { - + diff --git a/frontend/src/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts index a36435333..f99153dd6 100644 --- a/frontend/src/services/graphQLDeviceProducts.ts +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -23,7 +23,7 @@ export async function graphQLDeviceProducts(options?: { items { id name - platform + platform { id name } scope status hidden @@ -53,7 +53,7 @@ export async function graphQLDeviceProduct(id: string) { deviceProduct(id: $id) { id name - platform + platform { id name } scope status hidden @@ -82,7 +82,7 @@ export async function graphQLCreateDeviceProduct(input: { createDeviceProduct(name: $name, platform: $platform) { id name - platform + platform { id name } scope status hidden @@ -120,7 +120,7 @@ export async function graphQLUpdateDeviceProductSettings( updateDeviceProductSettings(id: $id, input: $input) { id name - platform + platform { id name } scope status hidden From 5d6c96a840aa71972d73a24cc6f0aa8060f27da5 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 11 Dec 2025 16:13:30 -0800 Subject: [PATCH 05/22] fix: fix lock --- .../src/pages/ProductsPage/ProductDetailPage.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx index a4b8688e7..9cedae94e 100644 --- a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx @@ -127,13 +127,17 @@ export const ProductDetailPage: React.FC = () => { @@ -198,9 +202,11 @@ export const ProductDetailPage: React.FC = () => { - - - + {product.scope === 'PUBLIC' && ( + + + + )} From 008419dd46c0bd7aa036ac5dea27b65117a2327e Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 11 Dec 2025 19:00:43 -0800 Subject: [PATCH 06/22] feat: support multiple accounts for products --- .../Header/ProductsHeaderButtons.tsx | 5 +- .../src/components/OrganizationSelect.tsx | 3 +- .../src/components/OrganizationSelectList.tsx | 3 +- frontend/src/components/ProductsActionBar.tsx | 5 +- frontend/src/constants.ts | 4 +- frontend/src/helpers/apiHelper.ts | 19 +-- frontend/src/models/accounts.ts | 1 + frontend/src/models/auth.ts | 1 + frontend/src/models/products.ts | 116 +++++++++++++----- .../pages/ProductsPage/ProductDetailPage.tsx | 5 +- .../src/pages/ProductsPage/ProductsPage.tsx | 13 +- frontend/src/selectors/products.ts | 53 ++++++++ .../src/services/graphQLDeviceProducts.ts | 52 ++++---- 13 files changed, 204 insertions(+), 76 deletions(-) create mode 100644 frontend/src/selectors/products.ts diff --git a/frontend/src/components/Header/ProductsHeaderButtons.tsx b/frontend/src/components/Header/ProductsHeaderButtons.tsx index f25d66128..855e9932b 100644 --- a/frontend/src/components/Header/ProductsHeaderButtons.tsx +++ b/frontend/src/components/Header/ProductsHeaderButtons.tsx @@ -3,13 +3,14 @@ import { useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { Switch, Route } from 'react-router-dom' import { Button } from '@mui/material' -import { State, dispatch } from '../../store' +import { dispatch } from '../../store' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' +import { getProductsShowHidden } from '../../selectors/products' export const ProductsHeaderButtons: React.FC = () => { const history = useHistory() - const showHidden = useSelector((state: State) => state.products?.showHidden) || false + const showHidden = useSelector(getProductsShowHidden) return ( diff --git a/frontend/src/components/OrganizationSelect.tsx b/frontend/src/components/OrganizationSelect.tsx index 60160d02b..8f70ce74b 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 } = useDispatch() + const { accounts, devices, files, tags, networks, logs, products } = useDispatch() let activeOrg = useSelector(selectOrganization) const defaultSelection = useSelector((state: State) => state.ui.defaultSelection) @@ -57,6 +57,7 @@ export const OrganizationSelect: React.FC = () => { devices.fetchIfEmpty() files.fetchIfEmpty() tags.fetchIfEmpty() + products.fetchIfEmpty() if (!mobile && ['/devices', '/networks', '/connections'].includes(menu)) { history.push(defaultSelection[id]?.[menu] || menu) } diff --git a/frontend/src/components/OrganizationSelectList.tsx b/frontend/src/components/OrganizationSelectList.tsx index 9e96faec1..ec2ecf26d 100644 --- a/frontend/src/components/OrganizationSelectList.tsx +++ b/frontend/src/components/OrganizationSelectList.tsx @@ -12,7 +12,7 @@ const AVATAR_SIZE = 28 export const OrganizationSelectList: React.FC = () => { const history = useHistory() - const { accounts, devices, tags, networks, logs } = useDispatch() + const { accounts, devices, tags, networks, logs, products } = useDispatch() const { options, activeOrg, ownOrg, user } = useSelector((state: State) => ({ activeOrg: selectOrganization(state), options: state.accounts.membership.map(m => { @@ -38,6 +38,7 @@ export const OrganizationSelectList: React.FC = () => { networks.fetchIfEmpty() devices.fetchIfEmpty() tags.fetchIfEmpty() + products.fetchIfEmpty() history.push('/devices') } } diff --git a/frontend/src/components/ProductsActionBar.tsx b/frontend/src/components/ProductsActionBar.tsx index e84748c36..dbdd900c7 100644 --- a/frontend/src/components/ProductsActionBar.tsx +++ b/frontend/src/components/ProductsActionBar.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react' import { makeStyles } from '@mui/styles' import { MOBILE_WIDTH } from '../constants' import { useMediaQuery, Box, Typography, Collapse } from '@mui/material' -import { State } from '../store' import { useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { ConfirmIconButton } from '../buttons/ConfirmIconButton' @@ -12,14 +11,14 @@ import { Notice } from './Notice' import { Title } from './Title' import { Icon } from './Icon' import { spacing, radius } from '../styling' +import { getProductsSelected } from '../selectors/products' type Props = { select?: boolean } export const ProductsActionBar: React.FC = ({ select }) => { - const productsState = useSelector((state: State) => state.products) - const selected = productsState?.selected || [] + const selected = useSelector(getProductsSelected) const [deleting, setDeleting] = useState(false) const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) const history = useHistory() diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 59ee09ed8..3bbf9e39e 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -8,8 +8,8 @@ export const COGNITO_USER_POOL_ID = env.VITE_COGNITO_USER_POOL_ID || 'us-west-2_ export const COGNITO_AUTH_DOMAIN = env.VITE_COGNITO_AUTH_DOMAIN || 'auth.remote.it' export const API_URL = env.VITE_API_URL || 'https://api.remote.it/apv/v27' export const AUTH_API_URL = env.VITE_AUTH_API_URL || env.AUTH_API_URL || 'https://auth.api.remote.it/v1' -export const GRAPHQL_API = env.VITE_GRAPHQL_API || 'https://api.remote.it/graphql/v1' -export const GRAPHQL_BETA_API = env.VITE_GRAPHQL_BETA_API || 'https://api.remote.it/graphql/beta' +export const GRAPHQL_API = env.VITE_GRAPHQL_API || 'https://api.remote.it/graphql/evan' +export const GRAPHQL_BETA_API = env.VITE_GRAPHQL_BETA_API || 'https://api.remote.it/graphql/evan' export const PORTAL = (env.VITE_PORTAL || env.PORTAL) === 'true' ? true : false export const DEVELOPER_KEY = env.VITE_DEVELOPER_KEY || 'Mjc5REIzQUQtMTQyRC00NTcxLTlGRDktMTVGNzVGNDYxQkE3' diff --git a/frontend/src/helpers/apiHelper.ts b/frontend/src/helpers/apiHelper.ts index e93862e2a..f318201c1 100644 --- a/frontend/src/helpers/apiHelper.ts +++ b/frontend/src/helpers/apiHelper.ts @@ -4,15 +4,18 @@ import { version } from './versionHelper' import { store } from '../store' export function getApiURL(): string | undefined { - if (!store) return GRAPHQL_API + // TEMPORARY: Hardcoded for testing + return 'https://api.remote.it/graphql/evan' - const { apiGraphqlURL, switchApi } = store.getState().ui.apis - const { overrides } = store.getState().backend.environment - const defaultURL = - version.includes('alpha') || version.includes('beta') - ? overrides?.betaApiURL || GRAPHQL_BETA_API - : overrides?.apiURL || GRAPHQL_API - return apiGraphqlURL && switchApi ? apiGraphqlURL : defaultURL + // if (!store) return GRAPHQL_API + + // const { apiGraphqlURL, switchApi } = store.getState().ui.apis + // const { overrides } = store.getState().backend.environment + // const defaultURL = + // version.includes('alpha') || version.includes('beta') + // ? overrides?.betaApiURL || GRAPHQL_BETA_API + // : overrides?.apiURL || GRAPHQL_API + // return apiGraphqlURL && switchApi ? apiGraphqlURL : defaultURL } export function getRestApi(): string | undefined { diff --git a/frontend/src/models/accounts.ts b/frontend/src/models/accounts.ts index 912cee287..f9562d420 100644 --- a/frontend/src/models/accounts.ts +++ b/frontend/src/models/accounts.ts @@ -69,6 +69,7 @@ export default createModel()({ await dispatch.accounts.set({ activeId: accountId }) dispatch.devices.fetchIfEmpty() dispatch.tags.fetchIfEmpty() + dispatch.products.fetchIfEmpty() }, async leaveMembership(id: string, state) { const { membership } = state.accounts diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index b2d03d4fe..72fc05351 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -264,6 +264,7 @@ export default createModel()({ dispatch.tags.reset() dispatch.mfa.reset() dispatch.ui.reset() + dispatch.products.reset() cloudSync.reset() dispatch.accounts.set({ activeId: undefined }) diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts index 8dd95aa69..d65e90baa 100644 --- a/frontend/src/models/products.ts +++ b/frontend/src/models/products.ts @@ -9,6 +9,8 @@ import { graphQLRemoveDeviceProductService, } from '../services/graphQLDeviceProducts' import { graphQLGetErrors } from '../services/graphQL' +import { selectActiveAccountId } from '../selectors/accounts' +import { State } from '../store' export interface IProductService { id: string @@ -31,7 +33,7 @@ export interface IDeviceProduct { services: IProductService[] } -type ProductsState = { +export type ProductsState = { initialized: boolean fetching: boolean all: IDeviceProduct[] @@ -39,7 +41,7 @@ type ProductsState = { showHidden: boolean } -const defaultState: ProductsState = { +export const defaultState: ProductsState = { initialized: false, fetching: false, all: [], @@ -47,33 +49,54 @@ const defaultState: ProductsState = { showHidden: false, } +type ProductsAccountState = { + [accountId: string]: ProductsState +} + +const defaultAccountState: ProductsAccountState = { + default: { ...defaultState }, +} + +// Helper to get product model for a specific account +export function getProductModel(state: State, accountId?: string): ProductsState { + const activeAccountId = selectActiveAccountId(state) + return state.products[accountId || activeAccountId] || state.products.default || defaultState +} + export default createModel()({ - state: { ...defaultState }, + state: { ...defaultAccountState }, effects: dispatch => ({ async fetch(_: void, state) { - dispatch.products.set({ fetching: true }) - const response = await graphQLDeviceProducts({ includeHidden: true }) + const accountId = selectActiveAccountId(state) + dispatch.products.set({ fetching: true, accountId }) + const response = await graphQLDeviceProducts({ accountId, includeHidden: true }) if (!graphQLGetErrors(response)) { - const products = response?.data?.data?.deviceProducts?.items || [] + const products = response?.data?.data?.login?.account?.deviceProducts?.items || [] console.log('LOADED PRODUCTS', products) - dispatch.products.set({ all: products, initialized: true }) + dispatch.products.set({ all: products, initialized: true, accountId }) } - dispatch.products.set({ fetching: false }) + dispatch.products.set({ fetching: false, accountId }) }, async fetchIfEmpty(_: void, state) { - if (!state.products.initialized) { + const accountId = selectActiveAccountId(state) + const productModel = getProductModel(state, accountId) + // Only fetch if not initialized for this account + if (!productModel.initialized) { await dispatch.products.fetch() } }, async create(input: { name: string; platform: string }, state) { - const response = await graphQLCreateDeviceProduct(input) + const accountId = selectActiveAccountId(state) + const response = await graphQLCreateDeviceProduct({ ...input, accountId }) if (!graphQLGetErrors(response)) { const newProduct = response?.data?.data?.createDeviceProduct if (newProduct) { + const productModel = getProductModel(state, accountId) dispatch.products.set({ - all: [...state.products.all, newProduct], + all: [...productModel.all, newProduct], + accountId, }) return newProduct } @@ -82,18 +105,23 @@ export default createModel()({ }, async delete(id: string, state) { + const accountId = selectActiveAccountId(state) const response = await graphQLDeleteDeviceProduct(id) if (!graphQLGetErrors(response)) { + const productModel = getProductModel(state, accountId) dispatch.products.set({ - all: state.products.all.filter(p => p.id !== id), - selected: state.products.selected.filter(s => s !== id), + all: productModel.all.filter(p => p.id !== id), + selected: productModel.selected.filter(s => s !== id), + accountId, }) } return !graphQLGetErrors(response) }, async deleteSelected(_: void, state) { - const { selected, all } = state.products + const accountId = selectActiveAccountId(state) + const productModel = getProductModel(state, accountId) + const { selected, all } = productModel if (!selected.length) return const results = await Promise.all( @@ -105,44 +133,57 @@ export default createModel()({ dispatch.products.set({ all: all.filter(p => !successIds.includes(p.id)), selected: [], + accountId, }) }, select(id: string, state) { - const { selected } = state.products + const accountId = selectActiveAccountId(state) + const productModel = getProductModel(state, accountId) + const { selected } = productModel if (selected.includes(id)) { - dispatch.products.set({ selected: selected.filter(s => s !== id) }) + dispatch.products.set({ selected: selected.filter(s => s !== id), accountId }) } else { - dispatch.products.set({ selected: [...selected, id] }) + dispatch.products.set({ selected: [...selected, id], accountId }) } }, selectAll(checked: boolean, state) { - const { all, showHidden } = state.products + const accountId = selectActiveAccountId(state) + const productModel = getProductModel(state, accountId) + const { all, showHidden } = productModel const visibleProducts = showHidden ? all : all.filter(p => !p.hidden) dispatch.products.set({ selected: checked ? visibleProducts.map(p => p.id) : [], + accountId, }) }, - clearSelection() { - dispatch.products.set({ selected: [] }) + clearSelection(_: void, state) { + const accountId = selectActiveAccountId(state) + dispatch.products.set({ selected: [], accountId }) }, toggleShowHidden(_: void, state) { + const accountId = selectActiveAccountId(state) + const productModel = getProductModel(state, accountId) dispatch.products.set({ - showHidden: !state.products.showHidden, + showHidden: !productModel.showHidden, selected: [], + accountId, }) }, async updateSettings({ id, input }: { id: string; input: { lock?: boolean; hidden?: boolean } }, state) { + const accountId = selectActiveAccountId(state) const response = await graphQLUpdateDeviceProductSettings(id, input) if (!graphQLGetErrors(response)) { const updatedProduct = response?.data?.data?.updateDeviceProductSettings if (updatedProduct) { + const productModel = getProductModel(state, accountId) dispatch.products.set({ - all: state.products.all.map(p => (p.id === id ? updatedProduct : p)), + all: productModel.all.map(p => (p.id === id ? updatedProduct : p)), + accountId, }) } return updatedProduct @@ -154,14 +195,17 @@ export default createModel()({ { productId, input }: { productId: string; input: { name: string; type: string; port: number; enabled: boolean } }, state ) { + const accountId = selectActiveAccountId(state) const response = await graphQLAddDeviceProductService(productId, input) if (!graphQLGetErrors(response)) { const newService = response?.data?.data?.addDeviceProductService if (newService) { + const productModel = getProductModel(state, accountId) dispatch.products.set({ - all: state.products.all.map(p => + all: productModel.all.map(p => p.id === productId ? { ...p, services: [...p.services, newService] } : p ), + accountId, }) } return newService @@ -170,27 +214,43 @@ export default createModel()({ }, async removeService({ productId, serviceId }: { productId: string; serviceId: string }, state) { + const accountId = selectActiveAccountId(state) const response = await graphQLRemoveDeviceProductService(serviceId) if (!graphQLGetErrors(response)) { + const productModel = getProductModel(state, accountId) dispatch.products.set({ - all: state.products.all.map(p => + all: productModel.all.map(p => p.id === productId ? { ...p, services: p.services.filter(s => s.id !== serviceId) } : p ), + accountId, }) return true } return false }, + + // Set effect that updates state for a specific account + async set(params: Partial & { accountId?: string }, state) { + const accountId = params.accountId || selectActiveAccountId(state) + const productState = { ...getProductModel(state, accountId) } + + Object.keys(params).forEach(key => { + if (key !== 'accountId') { + productState[key] = params[key] + } + }) + + await dispatch.products.rootSet({ [accountId]: productState }) + }, }), reducers: { - reset(state) { - state = { ...defaultState } + reset(state: ProductsAccountState) { + state = { ...defaultAccountState } return state }, - set(state, params: Partial) { + rootSet(state: ProductsAccountState, params: ProductsAccountState) { Object.keys(params).forEach(key => (state[key] = params[key])) return state }, }, }) - diff --git a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx index 9cedae94e..032224751 100644 --- a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx @@ -22,15 +22,16 @@ import { Notice } from '../../components/Notice' import { Confirm } from '../../components/Confirm' import { LoadingMessage } from '../../components/LoadingMessage' import { spacing } from '../../styling' -import { dispatch, State } from '../../store' +import { dispatch } from '../../store' import { IProductService } from '../../models/products' import { AddProductServiceDialog } from './AddProductServiceDialog' +import { getProductModel } from '../../selectors/products' export const ProductDetailPage: React.FC = () => { const { productId } = useParams<{ productId: string }>() const history = useHistory() const css = useStyles() - const { all: products, fetching, initialized } = useSelector((state: State) => state.products) + const { all: products, fetching, initialized } = useSelector(getProductModel) const product = products.find(p => p.id === productId) const [updating, setUpdating] = useState(false) const [addServiceOpen, setAddServiceOpen] = useState(false) diff --git a/frontend/src/pages/ProductsPage/ProductsPage.tsx b/frontend/src/pages/ProductsPage/ProductsPage.tsx index e17ed3e0d..37aa3296f 100644 --- a/frontend/src/pages/ProductsPage/ProductsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsPage.tsx @@ -11,17 +11,18 @@ import { ProductsActionBar } from '../../components/ProductsActionBar' import { productAttributes } from '../../components/ProductAttributes' import { removeObject } from '../../helpers/utilHelper' import { dispatch, State } from '../../store' +import { getProductModel } from '../../selectors/products' export const ProductsPage: React.FC = () => { const history = useHistory() const selectMatch = useRouteMatch('/products/select') const select = !!selectMatch - const productsState = useSelector((state: State) => state.products) - const allProducts = productsState?.all || [] - const fetching = productsState?.fetching || false - const initialized = productsState?.initialized || false - const selected = productsState?.selected || [] - const showHidden = productsState?.showHidden || false + const productModel = useSelector(getProductModel) + const allProducts = productModel.all || [] + const fetching = productModel.fetching || false + const initialized = productModel.initialized || false + const selected = productModel.selected || [] + const showHidden = productModel.showHidden || false const columnWidths = useSelector((state: State) => state.ui.columnWidths) const [required, attributes] = removeObject(productAttributes, a => a.required === true) diff --git a/frontend/src/selectors/products.ts b/frontend/src/selectors/products.ts new file mode 100644 index 000000000..899f4c856 --- /dev/null +++ b/frontend/src/selectors/products.ts @@ -0,0 +1,53 @@ +import { createSelector } from 'reselect' +import { State } from '../store' +import { selectActiveAccountId } from './accounts' +import { defaultState, IDeviceProduct, ProductsState } from '../models/products' + +const getProductsState = (state: State) => state.products + +export function getProductModelFn( + products: State['products'], + activeAccountId: string, + accountId?: string +): ProductsState { + return products[accountId || activeAccountId] || products.default || defaultState +} + +export const getProductModel = createSelector( + [getProductsState, selectActiveAccountId], + (products, activeAccountId) => getProductModelFn(products, activeAccountId) +) + +export const getProducts = createSelector( + [getProductsState, selectActiveAccountId], + (products, activeAccountId): IDeviceProduct[] => getProductModelFn(products, activeAccountId).all || [] +) + +export const getVisibleProducts = createSelector( + [getProductModel], + productModel => { + const { all, showHidden } = productModel + return showHidden ? all : all.filter(p => !p.hidden) + } +) + +export const getProductsFetching = createSelector( + [getProductModel], + productModel => productModel.fetching +) + +export const getProductsInitialized = createSelector( + [getProductModel], + productModel => productModel.initialized +) + +export const getProductsSelected = createSelector( + [getProductModel], + productModel => productModel.selected +) + +export const getProductsShowHidden = createSelector( + [getProductModel], + productModel => productModel.showHidden +) + diff --git a/frontend/src/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts index f99153dd6..790db51a7 100644 --- a/frontend/src/services/graphQLDeviceProducts.ts +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -12,35 +12,40 @@ export async function graphQLPlatformTypes() { } export async function graphQLDeviceProducts(options?: { + accountId?: string includeHidden?: boolean size?: number from?: number after?: string }) { return await graphQLBasicRequest( - ` query DeviceProducts($includeHidden: Boolean, $size: Int, $from: Int, $after: ID) { - deviceProducts(includeHidden: $includeHidden, size: $size, from: $from, after: $after) { - items { - id - name - platform { id name } - scope - status - hidden - created - updated - services { - id - name - type { id name } - port - enabled - platformCode + ` query DeviceProducts($accountId: String, $includeHidden: Boolean, $size: Int, $from: Int, $after: ID) { + login { + account(id: $accountId) { + deviceProducts(includeHidden: $includeHidden, size: $size, from: $from, after: $after) { + items { + id + name + platform { id name } + scope + status + hidden + created + updated + services { + id + name + type { id name } + port + enabled + platformCode + } + } + total + hasMore + last } } - total - hasMore - last } }`, options || {} @@ -76,10 +81,11 @@ export async function graphQLDeviceProduct(id: string) { export async function graphQLCreateDeviceProduct(input: { name: string platform: string + accountId?: string }) { return await graphQLBasicRequest( - ` mutation CreateDeviceProduct($name: String!, $platform: String!) { - createDeviceProduct(name: $name, platform: $platform) { + ` mutation CreateDeviceProduct($accountId: String, $name: String!, $platform: String!) { + createDeviceProduct(accountId: $accountId, name: $name, platform: $platform) { id name platform { id name } From 4f6ae7fdc1417f1ca9a41c1c82435d26e01555d7 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 12 Dec 2025 13:51:39 -0800 Subject: [PATCH 07/22] feat: remove attributes hidden and scope --- .../Header/ProductsHeaderButtons.tsx | 11 ----- frontend/src/components/ProductAttributes.tsx | 13 +----- frontend/src/models/products.ts | 22 ++-------- .../pages/ProductsPage/ProductDetailPage.tsx | 22 ---------- .../src/pages/ProductsPage/ProductsPage.tsx | 44 ++++++------------- frontend/src/selectors/products.ts | 13 ------ .../src/services/graphQLDeviceProducts.ts | 15 ++----- 7 files changed, 20 insertions(+), 120 deletions(-) diff --git a/frontend/src/components/Header/ProductsHeaderButtons.tsx b/frontend/src/components/Header/ProductsHeaderButtons.tsx index 855e9932b..8282bcd00 100644 --- a/frontend/src/components/Header/ProductsHeaderButtons.tsx +++ b/frontend/src/components/Header/ProductsHeaderButtons.tsx @@ -1,26 +1,15 @@ import React from 'react' -import { useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' import { Switch, Route } from 'react-router-dom' import { Button } from '@mui/material' -import { dispatch } from '../../store' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' -import { getProductsShowHidden } from '../../selectors/products' export const ProductsHeaderButtons: React.FC = () => { const history = useHistory() - const showHidden = useSelector(getProductsShowHidden) return ( - - - - - - - - @@ -203,11 +186,6 @@ export const ProductDetailPage: React.FC = () => { - {product.scope === 'PUBLIC' && ( - - - - )} diff --git a/frontend/src/pages/ProductsPage/ProductsPage.tsx b/frontend/src/pages/ProductsPage/ProductsPage.tsx index 37aa3296f..85eeaa6c6 100644 --- a/frontend/src/pages/ProductsPage/ProductsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react' +import React, { useEffect } from 'react' import { useHistory, useRouteMatch } from 'react-router-dom' import { useSelector } from 'react-redux' import { Typography, Button } from '@mui/material' @@ -18,18 +18,13 @@ export const ProductsPage: React.FC = () => { const selectMatch = useRouteMatch('/products/select') const select = !!selectMatch const productModel = useSelector(getProductModel) - const allProducts = productModel.all || [] + const products = productModel.all || [] const fetching = productModel.fetching || false const initialized = productModel.initialized || false const selected = productModel.selected || [] - const showHidden = productModel.showHidden || false const columnWidths = useSelector((state: State) => state.ui.columnWidths) const [required, attributes] = removeObject(productAttributes, a => a.required === true) - const products = useMemo(() => { - return showHidden ? allProducts : allProducts.filter(p => !p.hidden) - }, [allProducts, showHidden]) - useEffect(() => { dispatch.products.fetchIfEmpty() }, []) @@ -62,33 +57,20 @@ export const ProductsPage: React.FC = () => { - {showHidden ? 'No products' : 'No visible products'} + No products - {showHidden - ? 'Products are used for bulk device registration and management.' - : 'All products may be hidden. Click the eye icon to show hidden products.'} + Products are used for bulk device registration and management. - {!showHidden && allProducts.length > 0 ? ( - - ) : ( - - )} + ) : ( getProductModelFn(products, activeAccountId).all || [] ) -export const getVisibleProducts = createSelector( - [getProductModel], - productModel => { - const { all, showHidden } = productModel - return showHidden ? all : all.filter(p => !p.hidden) - } -) - export const getProductsFetching = createSelector( [getProductModel], productModel => productModel.fetching @@ -46,8 +38,3 @@ export const getProductsSelected = createSelector( productModel => productModel.selected ) -export const getProductsShowHidden = createSelector( - [getProductModel], - productModel => productModel.showHidden -) - diff --git a/frontend/src/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts index 790db51a7..c1924aa40 100644 --- a/frontend/src/services/graphQLDeviceProducts.ts +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -13,23 +13,20 @@ export async function graphQLPlatformTypes() { export async function graphQLDeviceProducts(options?: { accountId?: string - includeHidden?: boolean size?: number from?: number after?: string }) { return await graphQLBasicRequest( - ` query DeviceProducts($accountId: String, $includeHidden: Boolean, $size: Int, $from: Int, $after: ID) { + ` query DeviceProducts($accountId: String, $size: Int, $from: Int, $after: ID) { login { account(id: $accountId) { - deviceProducts(includeHidden: $includeHidden, size: $size, from: $from, after: $after) { + deviceProducts(size: $size, from: $from, after: $after) { items { id name platform { id name } - scope status - hidden created updated services { @@ -59,9 +56,7 @@ export async function graphQLDeviceProduct(id: string) { id name platform { id name } - scope status - hidden created updated services { @@ -89,9 +84,7 @@ export async function graphQLCreateDeviceProduct(input: { id name platform { id name } - scope status - hidden created updated services { @@ -119,7 +112,7 @@ export async function graphQLDeleteDeviceProduct(id: string) { export async function graphQLUpdateDeviceProductSettings( id: string, - input: { lock?: boolean; hidden?: boolean } + input: { lock?: boolean } ) { return await graphQLBasicRequest( ` mutation UpdateDeviceProductSettings($id: ID!, $input: DeviceProductSettingsInput!) { @@ -127,9 +120,7 @@ export async function graphQLUpdateDeviceProductSettings( id name platform { id name } - scope status - hidden created updated services { From 4642f6d9a12c580c6f670fc891b225164e394099 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 12 Dec 2025 14:38:25 -0800 Subject: [PATCH 08/22] feat: show icons for platform types and limit what platform types show in create menu --- frontend/src/components/ProductAttributes.tsx | 18 +++++-- frontend/src/components/ProductListItem.tsx | 2 +- .../src/pages/ProductsPage/ProductAddPage.tsx | 13 +++-- .../pages/ProductsPage/ProductDetailPage.tsx | 54 +++++++++++++++++++ .../src/services/graphQLDeviceProducts.ts | 1 + 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/ProductAttributes.tsx b/frontend/src/components/ProductAttributes.tsx index 74c60d40f..664b08bec 100644 --- a/frontend/src/components/ProductAttributes.tsx +++ b/frontend/src/components/ProductAttributes.tsx @@ -19,7 +19,7 @@ export const productAttributes: ProductAttribute[] = [ required: true, defaultWidth: 300, value: ({ product }: IProductOptions) => ( - {product?.name} + {product?.name} ), }), new ProductAttribute({ @@ -27,7 +27,7 @@ export const productAttributes: ProductAttribute[] = [ label: 'Platform', defaultWidth: 100, value: ({ product }: IProductOptions) => ( - + {product?.platform?.name || product?.platform?.id} ), @@ -50,7 +50,7 @@ export const productAttributes: ProductAttribute[] = [ label: 'Services', defaultWidth: 80, value: ({ product }: IProductOptions) => ( - + {product?.services?.length || 0} ), @@ -60,14 +60,22 @@ export const productAttributes: ProductAttribute[] = [ label: 'Created', defaultWidth: 150, value: ({ product }: IProductOptions) => - product?.created && , + product?.created && ( + + + + ), }), new ProductAttribute({ id: 'productUpdated', label: 'Updated', defaultWidth: 150, value: ({ product }: IProductOptions) => - product?.updated && , + product?.updated && ( + + + + ), }), ] diff --git a/frontend/src/components/ProductListItem.tsx b/frontend/src/components/ProductListItem.tsx index 110ac029a..902ce2843 100644 --- a/frontend/src/components/ProductListItem.tsx +++ b/frontend/src/components/ProductListItem.tsx @@ -49,7 +49,7 @@ export const ProductListItem: React.FC = ({ ) ) : ( - + ) } required={required?.value({ product })} diff --git a/frontend/src/pages/ProductsPage/ProductAddPage.tsx b/frontend/src/pages/ProductsPage/ProductAddPage.tsx index 912b02cef..3824861d6 100644 --- a/frontend/src/pages/ProductsPage/ProductAddPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductAddPage.tsx @@ -23,6 +23,7 @@ import { graphQLGetErrors } from '../../services/graphQL' interface IPlatformType { id: number name: string + visible: boolean } export const ProductAddPage: React.FC = () => { @@ -108,11 +109,13 @@ export const ProductAddPage: React.FC = () => { label="Platform" disabled={creating || platformTypes.length === 0} > - {platformTypes.map(p => ( - - {p.name} - - ))} + {platformTypes + .filter(p => p.visible) + .map(p => ( + + {p.name} + + ))} diff --git a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx index 31fee6387..6ba5fb93b 100644 --- a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx @@ -37,6 +37,8 @@ export const ProductDetailPage: React.FC = () => { const [addServiceOpen, setAddServiceOpen] = useState(false) const [deleteService, setDeleteService] = useState(null) const [deleting, setDeleting] = useState(false) + const [deleteProductOpen, setDeleteProductOpen] = useState(false) + const [deletingProduct, setDeletingProduct] = useState(false) const handleLockToggle = async () => { if (!product) return @@ -63,6 +65,16 @@ export const ProductDetailPage: React.FC = () => { // Service is already added to store by AddProductServiceDialog } + const handleDeleteProduct = async () => { + if (!product) return + setDeletingProduct(true) + const success = await dispatch.products.delete(product.id) + setDeletingProduct(false) + if (success) { + history.push('/products') + } + } + const isLocked = product?.status === 'LOCKED' if (fetching && !initialized) { @@ -194,6 +206,31 @@ export const ProductDetailPage: React.FC = () => { + +
+ + Danger Zone + + + + + + + + + +
{ Are you sure you want to remove the service {deleteService?.name}?
+ + setDeleteProductOpen(false)} + title="Delete Product" + action={deletingProduct ? 'Deleting...' : 'Delete'} + disabled={deletingProduct} + color="error" + > + + This action cannot be undone. + + + Are you sure you want to permanently delete the product {product.name} and all its services? + + ) } diff --git a/frontend/src/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts index c1924aa40..d0b72bf77 100644 --- a/frontend/src/services/graphQLDeviceProducts.ts +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -6,6 +6,7 @@ export async function graphQLPlatformTypes() { platformTypes { id name + visible } }` ) From e23b7cdc474ad646da5a107c08436b94498806a6 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 12 Dec 2025 14:49:42 -0800 Subject: [PATCH 09/22] feat: update for create product slide out instead of new page --- frontend/src/routers/ProductsRouter.tsx | 44 +++++++++++++++++++++++++ frontend/src/routers/Router.tsx | 23 ++----------- 2 files changed, 47 insertions(+), 20 deletions(-) create mode 100644 frontend/src/routers/ProductsRouter.tsx diff --git a/frontend/src/routers/ProductsRouter.tsx b/frontend/src/routers/ProductsRouter.tsx new file mode 100644 index 000000000..d434cc055 --- /dev/null +++ b/frontend/src/routers/ProductsRouter.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { Switch, Route, useLocation } from 'react-router-dom' +import { DynamicPanel } from '../components/DynamicPanel' +import { ProductsPage } from '../pages/ProductsPage/ProductsPage' +import { ProductAddPage } from '../pages/ProductsPage/ProductAddPage' +import { ProductDetailPage } from '../pages/ProductsPage/ProductDetailPage' + +export const ProductsRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { + const location = useLocation() + const locationParts = location.pathname.split('/') + + // Use single panel mode for base /products route and /products/select + if (locationParts[2] === undefined || locationParts[2] === 'select') { + layout = { ...layout, singlePanel: true } + } + + return ( + + + + + + + + + } + secondary={ + + + + + + + + + } + layout={layout} + root={['/products', '/products/select']} + /> + ) +} + diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index 82eccc98e..a234a16ee 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -6,6 +6,7 @@ import { DeviceRouter } from './DeviceRouter' import { ServiceRouter } from './ServiceRouter' import { NetworkRouter } from './NetworkRouter' import { ScriptingRouter } from './ScriptingRouter' +import { ProductsRouter } from './ProductsRouter' import { RedirectOffsite } from '../components/RedirectOffsite' import { State, Dispatch } from '../store' import { REGEX_FIRST_PATH } from '../constants' @@ -48,7 +49,6 @@ import { SharePage } from '../pages/SharePage' import { TagsPage } from '../pages/TagsPage' import { Panel } from '../components/Panel' import { LogsPage } from '../pages/LogsPage' -import { ProductsPage, ProductDetailPage, ProductAddPage } from '../pages/ProductsPage' import { isRemoteUI } from '../helpers/uiHelper' import { GraphsPage } from '../pages/GraphsPage' import { ProfilePage } from '../pages/ProfilePage' @@ -229,25 +229,8 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => {
{/* Products */} - - - - - - - - - - - - - - - - - - - + + {/* Announcements */} From 740345402faf03bab2e819aa83e417f7f6a77b71 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 12 Dec 2025 15:12:09 -0800 Subject: [PATCH 10/22] feat: update product details to look like device details --- frontend/src/components/ProductListItem.tsx | 2 +- frontend/src/models/products.ts | 1 - .../src/pages/ProductsPage/ProductAddPage.tsx | 106 ++++---- .../src/pages/ProductsPage/ProductPage.tsx | 122 ++++++++++ .../ProductsPage/ProductServiceAddPage.tsx | 227 ++++++++++++++++++ .../ProductsPage/ProductServiceDetailPage.tsx | 178 ++++++++++++++ .../ProductsPage/ProductSettingsPage.tsx | 196 +++++++++++++++ frontend/src/pages/ProductsPage/index.ts | 5 +- frontend/src/routers/ProductsRouter.tsx | 87 +++++-- .../src/services/graphQLDeviceProducts.ts | 5 - 10 files changed, 851 insertions(+), 78 deletions(-) create mode 100644 frontend/src/pages/ProductsPage/ProductPage.tsx create mode 100644 frontend/src/pages/ProductsPage/ProductServiceAddPage.tsx create mode 100644 frontend/src/pages/ProductsPage/ProductServiceDetailPage.tsx create mode 100644 frontend/src/pages/ProductsPage/ProductSettingsPage.tsx diff --git a/frontend/src/components/ProductListItem.tsx b/frontend/src/components/ProductListItem.tsx index 902ce2843..266ea6e03 100644 --- a/frontend/src/components/ProductListItem.tsx +++ b/frontend/src/components/ProductListItem.tsx @@ -31,7 +31,7 @@ export const ProductListItem: React.FC = ({ if (select && onSelect) { onSelect(product.id) } else { - history.push(`/products/${product.id}`) + history.push(`/products/${product.id}/details`) } } diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts index 8822ae28b..841f475ff 100644 --- a/frontend/src/models/products.ts +++ b/frontend/src/models/products.ts @@ -18,7 +18,6 @@ export interface IProductService { type: { id: number; name: string } | null port: number enabled: boolean - platformCode: string } export interface IDeviceProduct { diff --git a/frontend/src/pages/ProductsPage/ProductAddPage.tsx b/frontend/src/pages/ProductsPage/ProductAddPage.tsx index 3824861d6..28279b89f 100644 --- a/frontend/src/pages/ProductsPage/ProductAddPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductAddPage.tsx @@ -8,7 +8,7 @@ import { InputLabel, Select, MenuItem, - IconButton, + Box, } from '@mui/material' import { makeStyles } from '@mui/styles' import { Container } from '../../components/Container' @@ -76,80 +76,82 @@ export const ProductAddPage: React.FC = () => { gutterBottom header={ - history.push('/products')} sx={{ marginRight: 1 }}> - - Create Product } > -
+
{error && ( {error} )} - setName(e.target.value)} - fullWidth - required - autoFocus - margin="normal" - disabled={creating} - /> + + setName(e.target.value)} + fullWidth + required + autoFocus + margin="normal" + disabled={creating} + /> - - Platform - - + + Platform + + -
- - -
+ + + + +
) } const useStyles = makeStyles(({ palette }) => ({ + content: { + padding: spacing.md, + }, form: { - maxWidth: 500, - margin: '0 auto', - padding: spacing.lg, + backgroundColor: palette.white.main, + borderRadius: 8, + border: `1px solid ${palette.grayLighter.main}`, + padding: spacing.md, }, actions: { display: 'flex', justifyContent: 'flex-end', - gap: spacing.md, + gap: spacing.sm, marginTop: spacing.lg, }, })) - diff --git a/frontend/src/pages/ProductsPage/ProductPage.tsx b/frontend/src/pages/ProductsPage/ProductPage.tsx new file mode 100644 index 000000000..22d161ab8 --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductPage.tsx @@ -0,0 +1,122 @@ +import React, { useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Typography, List, ListItemText, Stack, Chip, Divider } from '@mui/material' +import { makeStyles } from '@mui/styles' +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 { Notice } from '../../components/Notice' +import { IconButton } from '../../buttons/IconButton' +import { LoadingMessage } from '../../components/LoadingMessage' +import { spacing } from '../../styling' +import { getProductModel } from '../../selectors/products' + +export const ProductPage: React.FC = () => { + const { productId } = useParams<{ productId: string }>() + const history = useHistory() + const css = useStyles() + const { all: products, fetching, initialized } = useSelector(getProductModel) + const product = products.find(p => p.id === productId) + + const isLocked = product?.status === 'LOCKED' + + if (fetching && !initialized) { + return ( + + + + ) + } + + if (!product) { + return ( + + + + + Product not found + + + The product may have been deleted or you don't have access to it. + + + + ) + } + + return ( + + } + title={ + + {product.name} + } + /> + + } + /> + + + {product.platform?.name || `Platform ${product.platform?.id}`} + + + + } + > + + Service + {!isLocked && ( + history.push(`/products/${product.id}/add`)} + size="md" + /> + )} + + + {product.services.length === 0 ? ( + + No services defined. Add at least one service before locking the product. + + ) : ( + + {product.services.map((service, index) => ( + + {index > 0 && } + } + > + + + + ))} + + )} + + ) +} + +const useStyles = makeStyles(({ palette }) => ({ + // Styles can be added as needed +})) + diff --git a/frontend/src/pages/ProductsPage/ProductServiceAddPage.tsx b/frontend/src/pages/ProductsPage/ProductServiceAddPage.tsx new file mode 100644 index 000000000..a1d6a0dc7 --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductServiceAddPage.tsx @@ -0,0 +1,227 @@ +import React, { useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { + Typography, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + FormControlLabel, + Switch, + Box, +} from '@mui/material' +import { makeStyles } from '@mui/styles' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { Notice } from '../../components/Notice' +import { spacing } from '../../styling' +import { dispatch, State } from '../../store' +import { getProductModel } from '../../selectors/products' + +export const ProductServiceAddPage: React.FC = () => { + const { productId } = useParams<{ productId: string }>() + const history = useHistory() + const css = useStyles() + const applicationTypes = useSelector((state: State) => state.applicationTypes.all) + const { all: products } = useSelector(getProductModel) + const product = products.find(p => p.id === productId) + + const [name, setName] = useState('') + const [type, setType] = useState('') + const [port, setPort] = useState('') + const [enabled, setEnabled] = useState(true) + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + const isLocked = product?.status === 'LOCKED' + + const handleCreate = async () => { + if (!productId) return + + if (!name.trim()) { + setError('Service name is required') + return + } + if (!type) { + setError('Service type is required') + return + } + const portNum = parseInt(port) + if (isNaN(portNum) || portNum < 0 || portNum > 65535) { + setError('Port must be a number between 0 and 65535') + return + } + + setError(null) + setCreating(true) + + const service = await dispatch.products.addService({ + productId, + input: { + name: name.trim(), + type, + port: portNum, + enabled, + }, + }) + + setCreating(false) + + if (service) { + history.push(`/products/${productId}/${service.id}`) + } else { + setError('Failed to add service') + } + } + + if (!product) { + return ( + + + + + Product not found + + + + ) + } + + if (isLocked) { + return ( + + + + + Product is locked + + + Services cannot be added to a locked product. + + + + ) + } + + return ( + + Add Service + + } + > +
+ {error && ( + + {error} + + )} + + + setName(e.target.value)} + fullWidth + required + autoFocus + margin="normal" + disabled={creating} + /> + + + Service Type + + + + setPort(e.target.value)} + fullWidth + required + type="number" + margin="normal" + disabled={creating} + inputProps={{ min: 0, max: 65535 }} + /> + + setEnabled(e.target.checked)} + disabled={creating} + /> + } + label="Enabled" + sx={{ marginTop: 2 }} + /> + + + + + + +
+
+ ) +} + +const useStyles = makeStyles(({ palette }) => ({ + content: { + padding: spacing.md, + }, + form: { + backgroundColor: palette.white.main, + borderRadius: 8, + border: `1px solid ${palette.grayLighter.main}`, + padding: spacing.md, + }, + actions: { + display: 'flex', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.lg, + }, +})) + diff --git a/frontend/src/pages/ProductsPage/ProductServiceDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductServiceDetailPage.tsx new file mode 100644 index 000000000..1cac28ea9 --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductServiceDetailPage.tsx @@ -0,0 +1,178 @@ +import React, { useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { + Typography, + List, + ListItem, + ListItemText, + Divider, + Button, +} from '@mui/material' +import { makeStyles } from '@mui/styles' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { Notice } from '../../components/Notice' +import { Confirm } from '../../components/Confirm' +import { spacing } from '../../styling' +import { dispatch } from '../../store' +import { getProductModel } from '../../selectors/products' + +export const ProductServiceDetailPage: React.FC = () => { + const { productId, serviceId } = useParams<{ productId: string; serviceId: string }>() + const history = useHistory() + const css = useStyles() + const { all: products } = useSelector(getProductModel) + const product = products.find(p => p.id === productId) + const service = product?.services.find(s => s.id === serviceId) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleting, setDeleting] = useState(false) + + const isLocked = product?.status === 'LOCKED' + + const handleDelete = async () => { + if (!product || !service) return + setDeleting(true) + const success = await dispatch.products.removeService({ + productId: product.id, + serviceId: service.id, + }) + setDeleting(false) + if (success) { + history.push(`/products/${product.id}`) + } + } + + if (!product) { + return ( + + + + + Product not found + + + + ) + } + + if (!service) { + return ( + + + + + Service not found + + + The service may have been removed. + + + + ) + } + + return ( + + {service.name} + + } + > +
+
+ + Service Details + + + + + + + + + + + + + + +
+ + {!isLocked && ( +
+ + Danger Zone + + + + + + + +
+ )} + + {isLocked && ( + + This product is locked. Services cannot be modified. + + )} +
+ + setDeleteOpen(false)} + title="Remove Service" + action={deleting ? 'Removing...' : 'Remove'} + disabled={deleting} + > + + This action cannot be undone. + + + Are you sure you want to remove the service {service.name}? + + +
+ ) +} + +const useStyles = makeStyles(({ palette }) => ({ + content: { + padding: spacing.md, + }, + section: { + marginBottom: spacing.lg, + backgroundColor: palette.white.main, + borderRadius: 8, + border: `1px solid ${palette.grayLighter.main}`, + padding: spacing.md, + }, +})) + diff --git a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx new file mode 100644 index 000000000..d1c6df939 --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx @@ -0,0 +1,196 @@ +import React, { useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { + Typography, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Switch, + Button, + Divider, +} from '@mui/material' +import { makeStyles } from '@mui/styles' +import { Container } from '../../components/Container' +import { Title } from '../../components/Title' +import { Icon } from '../../components/Icon' +import { Body } from '../../components/Body' +import { Notice } from '../../components/Notice' +import { Confirm } from '../../components/Confirm' +import { spacing } from '../../styling' +import { dispatch } from '../../store' +import { getProductModel } from '../../selectors/products' + +export const ProductSettingsPage: React.FC = () => { + const { productId } = useParams<{ productId: string }>() + const history = useHistory() + const css = useStyles() + const { all: products } = useSelector(getProductModel) + const product = products.find(p => p.id === productId) + const [updating, setUpdating] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const [deleting, setDeleting] = useState(false) + + const isLocked = product?.status === 'LOCKED' + + const handleLockToggle = async () => { + if (!product || isLocked) return + setUpdating(true) + await dispatch.products.updateSettings({ + id: product.id, + input: { lock: true }, + }) + setUpdating(false) + } + + const handleDelete = async () => { + if (!product) return + setDeleting(true) + const success = await dispatch.products.delete(product.id) + setDeleting(false) + if (success) { + history.push('/products') + } + } + + if (!product) { + return ( + + + + + Product not found + + + + ) + } + + return ( + + Product Settings + + } + > +
+
+ + Product Details + + + + + + + + + + + + + + + + + + +
+ +
+ + Product Settings + + + + + + + + + +
+ +
+ + Danger Zone + + + + + + + +
+
+ + setDeleteOpen(false)} + title="Delete Product" + action={deleting ? 'Deleting...' : 'Delete'} + disabled={deleting} + color="error" + > + + This action cannot be undone. + + + Are you sure you want to permanently delete the product {product.name} and all its services? + + +
+ ) +} + +const useStyles = makeStyles(({ palette }) => ({ + content: { + padding: spacing.md, + }, + section: { + marginBottom: spacing.lg, + backgroundColor: palette.white.main, + borderRadius: 8, + border: `1px solid ${palette.grayLighter.main}`, + padding: spacing.md, + }, +})) + diff --git a/frontend/src/pages/ProductsPage/index.ts b/frontend/src/pages/ProductsPage/index.ts index ebf236e63..5917fc831 100644 --- a/frontend/src/pages/ProductsPage/index.ts +++ b/frontend/src/pages/ProductsPage/index.ts @@ -1,4 +1,7 @@ export { ProductsPage } from './ProductsPage' export { ProductDetailPage } from './ProductDetailPage' export { ProductAddPage } from './ProductAddPage' - +export { ProductPage } from './ProductPage' +export { ProductServiceDetailPage } from './ProductServiceDetailPage' +export { ProductServiceAddPage } from './ProductServiceAddPage' +export { ProductSettingsPage } from './ProductSettingsPage' diff --git a/frontend/src/routers/ProductsRouter.tsx b/frontend/src/routers/ProductsRouter.tsx index d434cc055..ad80883ef 100644 --- a/frontend/src/routers/ProductsRouter.tsx +++ b/frontend/src/routers/ProductsRouter.tsx @@ -1,9 +1,15 @@ import React from 'react' -import { Switch, Route, useLocation } from 'react-router-dom' +import { Switch, Route, useLocation, useParams } from 'react-router-dom' +import { useSelector } from 'react-redux' import { DynamicPanel } from '../components/DynamicPanel' +import { Panel } from '../components/Panel' import { ProductsPage } from '../pages/ProductsPage/ProductsPage' import { ProductAddPage } from '../pages/ProductsPage/ProductAddPage' -import { ProductDetailPage } from '../pages/ProductsPage/ProductDetailPage' +import { ProductPage } from '../pages/ProductsPage/ProductPage' +import { ProductServiceDetailPage } from '../pages/ProductsPage/ProductServiceDetailPage' +import { ProductServiceAddPage } from '../pages/ProductsPage/ProductServiceAddPage' +import { ProductSettingsPage } from '../pages/ProductsPage/ProductSettingsPage' +import { getProductModel } from '../selectors/products' export const ProductsRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { const location = useLocation() @@ -14,31 +20,76 @@ export const ProductsRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { layout = { ...layout, singlePanel: true } } + return ( + + {/* Products list with add panel */} + + } + secondary={} + layout={layout} + root="/products" + /> + + {/* Products list select mode */} + + + + + + {/* Product detail routes - use ProductRouter for nested routing */} + + + + {/* Products list */} + + + + + + + ) +} + +// Nested router for individual product pages +const ProductRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { + const { productId } = useParams<{ productId: string }>() + const location = useLocation() + const locationParts = location.pathname.split('/') + const { all: products } = useSelector(getProductModel) + const product = products.find(p => p.id === productId) + + // Single panel mode when just viewing product (no service selected) + if (locationParts.length <= 3) { + layout = { ...layout, singlePanel: true } + } + + if (!product) { + return ( + + + + ) + } + return ( - - - - - - - - } + primary={} secondary={ - - + + - - + + + + + } layout={layout} - root={['/products', '/products/select']} + root="/products/:productId" /> ) } - diff --git a/frontend/src/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts index d0b72bf77..a297a03cb 100644 --- a/frontend/src/services/graphQLDeviceProducts.ts +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -36,7 +36,6 @@ export async function graphQLDeviceProducts(options?: { type { id name } port enabled - platformCode } } total @@ -66,7 +65,6 @@ export async function graphQLDeviceProduct(id: string) { type { id name } port enabled - platformCode } } }`, @@ -94,7 +92,6 @@ export async function graphQLCreateDeviceProduct(input: { type { id name } port enabled - platformCode } } }`, @@ -130,7 +127,6 @@ export async function graphQLUpdateDeviceProductSettings( type { id name } port enabled - platformCode } } }`, @@ -150,7 +146,6 @@ export async function graphQLAddDeviceProductService( type { id name } port enabled - platformCode } }`, { productId, ...input } From 77a7e091cb420e63e5543aeca40d6c1ec2254a02 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 12 Dec 2025 15:17:46 -0800 Subject: [PATCH 11/22] feat: add back button --- frontend/src/components/Header/Header.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 49aa27169..79bb8f71b 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -89,6 +89,9 @@ export const Header: React.FC = () => { /> )} + + + {!showSearch && } {!showSearch && !searched && ( From 49412352dce4fba760387951909bba7c222495ac Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 12 Dec 2025 18:10:27 -0800 Subject: [PATCH 12/22] feat: add multi panel design --- frontend/src/components/DoublePanel.tsx | 5 +- frontend/src/components/DynamicPanel.tsx | 7 +- frontend/src/components/Header/Header.tsx | 18 +- .../Header/ProductsHeaderButtons.tsx | 40 ++- frontend/src/components/ProductList.tsx | 3 + frontend/src/components/ProductListItem.tsx | 6 +- frontend/src/components/ProductsActionBar.tsx | 14 +- .../pages/ProductsPage/ProductDetailPage.tsx | 297 ------------------ .../src/pages/ProductsPage/ProductPage.tsx | 64 ++-- .../ProductsPage/ProductServiceAddPage.tsx | 27 +- .../ProductsPage/ProductServiceDetailPage.tsx | 28 +- .../ProductsPage/ProductSettingsPage.tsx | 29 +- .../pages/ProductsPage/ProductsListHeader.tsx | 108 +++++++ .../src/pages/ProductsPage/ProductsPage.tsx | 37 ++- .../ProductsPage/ProductsWithDetailPage.tsx | 264 ++++++++++++++++ frontend/src/pages/ProductsPage/index.ts | 1 + frontend/src/routers/ProductsRouter.tsx | 68 +--- 17 files changed, 573 insertions(+), 443 deletions(-) delete mode 100644 frontend/src/pages/ProductsPage/ProductDetailPage.tsx create mode 100644 frontend/src/pages/ProductsPage/ProductsListHeader.tsx create mode 100644 frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx diff --git a/frontend/src/components/DoublePanel.tsx b/frontend/src/components/DoublePanel.tsx index 294e98e19..d245ed52d 100644 --- a/frontend/src/components/DoublePanel.tsx +++ b/frontend/src/components/DoublePanel.tsx @@ -8,12 +8,13 @@ type Props = { primary: React.ReactNode secondary?: React.ReactNode layout: ILayout + header?: boolean } const MIN_WIDTH = 250 const PADDING = 9 -export const DoublePanel: React.FC = ({ primary, secondary, layout }) => { +export const DoublePanel: React.FC = ({ primary, secondary, layout, header = true }) => { const [panelWidth, setPanelWidth] = usePanelWidth() const handleRef = useRef(panelWidth) const primaryRef = useRef(null) @@ -74,7 +75,7 @@ export const DoublePanel: React.FC = ({ primary, secondary, layout }) => return ( <>
-
+ {header &&
} {primary}
diff --git a/frontend/src/components/DynamicPanel.tsx b/frontend/src/components/DynamicPanel.tsx index 17b6cb9a8..002f2fd62 100644 --- a/frontend/src/components/DynamicPanel.tsx +++ b/frontend/src/components/DynamicPanel.tsx @@ -8,15 +8,16 @@ type Props = { secondary?: React.ReactNode layout: ILayout root?: string | string[] + header?: boolean } -export const DynamicPanel: React.FC = ({ root, ...props }) => { +export const DynamicPanel: React.FC = ({ root, header = true, ...props }) => { const location = useLocation() const match = matchPath(location.pathname, { path: root, exact: true }) if (props.layout.singlePanel || !props.secondary) { - return {match ? props.primary : props.secondary} + return {match ? props.primary : props.secondary} } - return + return } diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 79bb8f71b..deffbeb38 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -5,12 +5,13 @@ import browser from '../../services/browser' import { emit } from '../../services/Controller' import { State } from '../../store' import { Dispatch } from '../../store' -import { useMediaQuery } from '@mui/material' +import { useMediaQuery, Typography } from '@mui/material' import { selectDeviceModelAttributes } from '../../selectors/devices' import { selectPermissions } from '../../selectors/organizations' import { useLocation, Switch, Route } from 'react-router-dom' import { useSelector, useDispatch } from 'react-redux' import { HeaderDeviceOptionMenu } from '../HeaderDeviceOptionMenu' +import { ProductsHeaderButtons } from './ProductsHeaderButtons' import { ScriptDeleteButton } from '../ScriptDeleteButton' import { UpgradeNotice } from '../UpgradeNotice' import { ColumnsButton } from '../../buttons/ColumnsButton' @@ -20,7 +21,6 @@ import { FilterButton } from '../../buttons/FilterButton' import { IconButton } from '../../buttons/IconButton' import { Title } from '../Title' import { Box } from '@mui/material' -import { ProductsHeaderButtons } from './ProductsHeaderButtons' export const Header: React.FC = () => { const { searched } = useSelector(selectDeviceModelAttributes) @@ -89,10 +89,14 @@ export const Header: React.FC = () => { /> )} - - - {!showSearch && } + {sidebarHidden && ( + + + Products + + + )} {!showSearch && !searched && ( { )} - {!showSearch && ( )} + + + diff --git a/frontend/src/components/Header/ProductsHeaderButtons.tsx b/frontend/src/components/Header/ProductsHeaderButtons.tsx index 8282bcd00..c31061c94 100644 --- a/frontend/src/components/Header/ProductsHeaderButtons.tsx +++ b/frontend/src/components/Header/ProductsHeaderButtons.tsx @@ -1,34 +1,46 @@ import React from 'react' -import { useHistory } from 'react-router-dom' -import { Switch, Route } from 'react-router-dom' +import { useHistory, useLocation } from 'react-router-dom' import { Button } from '@mui/material' import { IconButton } from '../../buttons/IconButton' import { Icon } from '../Icon' export const ProductsHeaderButtons: React.FC = () => { const history = useHistory() + const location = useLocation() + + const searchParams = new URLSearchParams(location.search) + const isSelectMode = searchParams.get('select') === 'true' + + const toggleSelect = () => { + const newParams = new URLSearchParams(location.search) + if (isSelectMode) { + newParams.delete('select') + } else { + newParams.set('select', 'true') + } + const search = newParams.toString() + history.push(`${location.pathname}${search ? `?${search}` : ''}`) + } return ( - - - - - - - - - + <> + - + ) } diff --git a/frontend/src/components/ProductList.tsx b/frontend/src/components/ProductList.tsx index 845c10a0a..f3530f3eb 100644 --- a/frontend/src/components/ProductList.tsx +++ b/frontend/src/components/ProductList.tsx @@ -15,6 +15,7 @@ export interface ProductListProps { products?: IDeviceProduct[] select?: boolean selected?: string[] + activeProductId?: string onSelect?: (id: string) => void onSelectAll?: (checked: boolean) => void } @@ -27,6 +28,7 @@ export const ProductList: React.FC = ({ fetching, select, selected = [], + activeProductId, onSelect, onSelectAll, }) => { @@ -60,6 +62,7 @@ export const ProductList: React.FC = ({ mobile={mobile} select={select} selected={selected.includes(product.id)} + active={product.id === activeProductId} onSelect={onSelect} /> ))} diff --git a/frontend/src/components/ProductListItem.tsx b/frontend/src/components/ProductListItem.tsx index 266ea6e03..d4e3eacdc 100644 --- a/frontend/src/components/ProductListItem.tsx +++ b/frontend/src/components/ProductListItem.tsx @@ -13,6 +13,7 @@ interface Props { mobile?: boolean select?: boolean selected?: boolean + active?: boolean onSelect?: (id: string) => void } @@ -23,6 +24,7 @@ export const ProductListItem: React.FC = ({ mobile, select, selected, + active, onSelect, }) => { const history = useHistory() @@ -31,14 +33,14 @@ export const ProductListItem: React.FC = ({ if (select && onSelect) { onSelect(product.id) } else { - history.push(`/products/${product.id}/details`) + history.push(`/products/${product.id}`) } } return ( = ({ select }) => { const [deleting, setDeleting] = useState(false) const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) const history = useHistory() + const location = useLocation() const css = useStyles() + const clearSelectMode = () => { + const newParams = new URLSearchParams(location.search) + newParams.delete('select') + const search = newParams.toString() + history.push(`${location.pathname}${search ? `?${search}` : ''}`) + } + const handleDelete = async () => { setDeleting(true) await dispatch.products.deleteSelected() setDeleting(false) - history.push('/products') + clearSelectMode() } return ( @@ -74,7 +82,7 @@ export const ProductsActionBar: React.FC = ({ select }) => { placement="bottom" onClick={() => { dispatch.products.clearSelection() - history.push('/products') + clearSelectMode() }} /> diff --git a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductDetailPage.tsx deleted file mode 100644 index 6ba5fb93b..000000000 --- a/frontend/src/pages/ProductsPage/ProductDetailPage.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import React, { useState } from 'react' -import { useParams, useHistory } from 'react-router-dom' -import { useSelector } from 'react-redux' -import { - Typography, - Button, - IconButton, - List, - ListItem, - ListItemText, - ListItemSecondaryAction, - Switch, - Divider, - Chip, -} from '@mui/material' -import { makeStyles } from '@mui/styles' -import { Container } from '../../components/Container' -import { Title } from '../../components/Title' -import { Icon } from '../../components/Icon' -import { Body } from '../../components/Body' -import { Notice } from '../../components/Notice' -import { Confirm } from '../../components/Confirm' -import { LoadingMessage } from '../../components/LoadingMessage' -import { spacing } from '../../styling' -import { dispatch } from '../../store' -import { IProductService } from '../../models/products' -import { AddProductServiceDialog } from './AddProductServiceDialog' -import { getProductModel } from '../../selectors/products' - -export const ProductDetailPage: React.FC = () => { - const { productId } = useParams<{ productId: string }>() - const history = useHistory() - const css = useStyles() - const { all: products, fetching, initialized } = useSelector(getProductModel) - const product = products.find(p => p.id === productId) - const [updating, setUpdating] = useState(false) - const [addServiceOpen, setAddServiceOpen] = useState(false) - const [deleteService, setDeleteService] = useState(null) - const [deleting, setDeleting] = useState(false) - const [deleteProductOpen, setDeleteProductOpen] = useState(false) - const [deletingProduct, setDeletingProduct] = useState(false) - - const handleLockToggle = async () => { - if (!product) return - setUpdating(true) - await dispatch.products.updateSettings({ - id: product.id, - input: { lock: product.status !== 'LOCKED' }, - }) - setUpdating(false) - } - - const handleDeleteService = async () => { - if (!deleteService || !product) return - setDeleting(true) - await dispatch.products.removeService({ - productId: product.id, - serviceId: deleteService.id, - }) - setDeleting(false) - setDeleteService(null) - } - - const handleServiceAdded = (service: IProductService) => { - // Service is already added to store by AddProductServiceDialog - } - - const handleDeleteProduct = async () => { - if (!product) return - setDeletingProduct(true) - const success = await dispatch.products.delete(product.id) - setDeletingProduct(false) - if (success) { - history.push('/products') - } - } - - const isLocked = product?.status === 'LOCKED' - - if (fetching && !initialized) { - return ( - - - - ) - } - - if (!product) { - return ( - - - - - Product not found - - - - - ) - } - - return ( - - - history.push('/products')} sx={{ marginRight: 1 }}> - - - {product.name} - } - /> - - - } - > -
-
- - Product Settings - - - - - - - - - -
- -
-
- - Services ({product.services.length}) - - {!isLocked && ( - - )} -
- {product.services.length === 0 ? ( - - No services defined. Add at least one service before locking the product. - - ) : ( - - {product.services.map((service, index) => ( - - {index > 0 && } - - - {!isLocked && ( - - setDeleteService(service)} - size="small" - > - - - - )} - - - ))} - - )} -
- -
- - Product Details - - - - - - - - - - - - -
- -
- - Danger Zone - - - - - - - - - -
-
- - setAddServiceOpen(false)} - onServiceAdded={handleServiceAdded} - /> - - setDeleteService(null)} - title="Remove Service" - action={deleting ? 'Removing...' : 'Remove'} - disabled={deleting} - > - - This action cannot be undone. - - - Are you sure you want to remove the service {deleteService?.name}? - - - - setDeleteProductOpen(false)} - title="Delete Product" - action={deletingProduct ? 'Deleting...' : 'Delete'} - disabled={deletingProduct} - color="error" - > - - This action cannot be undone. - - - Are you sure you want to permanently delete the product {product.name} and all its services? - - -
- ) -} - -const useStyles = makeStyles(({ palette }) => ({ - content: { - padding: spacing.md, - }, - section: { - marginBottom: spacing.lg, - backgroundColor: palette.white.main, - borderRadius: 8, - border: `1px solid ${palette.grayLighter.main}`, - padding: spacing.md, - }, - sectionHeader: { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.sm, - }, -})) - diff --git a/frontend/src/pages/ProductsPage/ProductPage.tsx b/frontend/src/pages/ProductsPage/ProductPage.tsx index 22d161ab8..d79a3dc95 100644 --- a/frontend/src/pages/ProductsPage/ProductPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductPage.tsx @@ -1,7 +1,7 @@ -import React, { useState } from 'react' +import React from 'react' import { useParams, useHistory } from 'react-router-dom' import { useSelector } from 'react-redux' -import { Typography, List, ListItemText, Stack, Chip, Divider } from '@mui/material' +import { Typography, List, ListItemText, Stack, Chip, Divider, Box } from '@mui/material' import { makeStyles } from '@mui/styles' import { Container } from '../../components/Container' import { ListItemLocation } from '../../components/ListItemLocation' @@ -17,6 +17,10 @@ import { getProductModel } from '../../selectors/products' export const ProductPage: React.FC = () => { const { productId } = useParams<{ productId: string }>() const history = useHistory() + + const handleBack = () => { + history.push('/products') + } const css = useStyles() const { all: products, fetching, initialized } = useSelector(getProductModel) const product = products.find(p => p.id === productId) @@ -51,29 +55,39 @@ export const ProductPage: React.FC = () => { - } - title={ - - {product.name} - } - /> - - } - /> - - - {product.platform?.name || `Platform ${product.platform?.id}`} - - - + + + + + + } + title={ + + {product.name} + } + /> + + } + /> + + + {product.platform?.name || `Platform ${product.platform?.id}`} + + + + } > diff --git a/frontend/src/pages/ProductsPage/ProductServiceAddPage.tsx b/frontend/src/pages/ProductsPage/ProductServiceAddPage.tsx index a1d6a0dc7..d02332933 100644 --- a/frontend/src/pages/ProductsPage/ProductServiceAddPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductServiceAddPage.tsx @@ -15,21 +15,29 @@ import { } from '@mui/material' import { makeStyles } from '@mui/styles' import { Container } from '../../components/Container' -import { Title } from '../../components/Title' import { Icon } from '../../components/Icon' +import { IconButton } from '../../buttons/IconButton' import { Body } from '../../components/Body' import { Notice } from '../../components/Notice' import { spacing } from '../../styling' import { dispatch, State } from '../../store' import { getProductModel } from '../../selectors/products' -export const ProductServiceAddPage: React.FC = () => { +type Props = { + showBack?: boolean +} + +export const ProductServiceAddPage: React.FC = ({ showBack = true }) => { const { productId } = useParams<{ productId: string }>() const history = useHistory() const css = useStyles() const applicationTypes = useSelector((state: State) => state.applicationTypes.all) const { all: products } = useSelector(getProductModel) const product = products.find(p => p.id === productId) + + const handleBack = () => { + history.push(`/products/${productId}`) + } const [name, setName] = useState('') const [type, setType] = useState('') @@ -109,12 +117,19 @@ export const ProductServiceAddPage: React.FC = () => { } return ( - - Add Service - + showBack ? ( + + + + ) : undefined } >
diff --git a/frontend/src/pages/ProductsPage/ProductServiceDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductServiceDetailPage.tsx index 1cac28ea9..dca5f32d7 100644 --- a/frontend/src/pages/ProductsPage/ProductServiceDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductServiceDetailPage.tsx @@ -8,11 +8,12 @@ import { ListItemText, Divider, Button, + Box, } from '@mui/material' import { makeStyles } from '@mui/styles' import { Container } from '../../components/Container' -import { Title } from '../../components/Title' import { Icon } from '../../components/Icon' +import { IconButton } from '../../buttons/IconButton' import { Body } from '../../components/Body' import { Notice } from '../../components/Notice' import { Confirm } from '../../components/Confirm' @@ -20,13 +21,21 @@ import { spacing } from '../../styling' import { dispatch } from '../../store' import { getProductModel } from '../../selectors/products' -export const ProductServiceDetailPage: React.FC = () => { +type Props = { + showBack?: boolean +} + +export const ProductServiceDetailPage: React.FC = ({ showBack = true }) => { const { productId, serviceId } = useParams<{ productId: string; serviceId: string }>() const history = useHistory() const css = useStyles() const { all: products } = useSelector(getProductModel) const product = products.find(p => p.id === productId) const service = product?.services.find(s => s.id === serviceId) + + const handleBack = () => { + history.push(`/products/${productId}`) + } const [deleteOpen, setDeleteOpen] = useState(false) const [deleting, setDeleting] = useState(false) @@ -75,12 +84,19 @@ export const ProductServiceDetailPage: React.FC = () => { } return ( - - {service.name} - + showBack ? ( + + + + ) : undefined } >
diff --git a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx index d1c6df939..cc31f4717 100644 --- a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx @@ -10,11 +10,12 @@ import { Switch, Button, Divider, + Box, } from '@mui/material' import { makeStyles } from '@mui/styles' import { Container } from '../../components/Container' -import { Title } from '../../components/Title' import { Icon } from '../../components/Icon' +import { IconButton } from '../../buttons/IconButton' import { Body } from '../../components/Body' import { Notice } from '../../components/Notice' import { Confirm } from '../../components/Confirm' @@ -22,12 +23,20 @@ import { spacing } from '../../styling' import { dispatch } from '../../store' import { getProductModel } from '../../selectors/products' -export const ProductSettingsPage: React.FC = () => { +type Props = { + showBack?: boolean +} + +export const ProductSettingsPage: React.FC = ({ showBack = true }) => { const { productId } = useParams<{ productId: string }>() const history = useHistory() const css = useStyles() const { all: products } = useSelector(getProductModel) const product = products.find(p => p.id === productId) + + const handleBack = () => { + history.push(`/products/${productId}`) + } const [updating, setUpdating] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) const [deleting, setDeleting] = useState(false) @@ -68,12 +77,19 @@ export const ProductSettingsPage: React.FC = () => { } return ( - - Product Settings - + showBack ? ( + + + + ) : undefined } >
@@ -193,4 +209,3 @@ const useStyles = makeStyles(({ palette }) => ({ padding: spacing.md, }, })) - diff --git a/frontend/src/pages/ProductsPage/ProductsListHeader.tsx b/frontend/src/pages/ProductsPage/ProductsListHeader.tsx new file mode 100644 index 000000000..9b3fb79e1 --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductsListHeader.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { useDispatch } from 'react-redux' +import { Box, Button, Typography, useMediaQuery } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { IconButton } from '../../buttons/IconButton' +import { RefreshButton } from '../../buttons/RefreshButton' +import { Icon } from '../../components/Icon' +import { Title } from '../../components/Title' +import { spacing } from '../../styling' +import { HIDE_SIDEBAR_WIDTH } from '../../constants' +import { Dispatch } from '../../store' + +type Props = { + showBack?: boolean + onBack?: () => void +} + +export const ProductsListHeader: React.FC = ({ showBack, onBack }) => { + const history = useHistory() + const location = useLocation() + const dispatch = useDispatch() + const css = useStyles() + const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + + const searchParams = new URLSearchParams(location.search) + const isSelectMode = searchParams.get('select') === 'true' + + const toggleSelect = () => { + const newParams = new URLSearchParams(location.search) + if (isSelectMode) { + newParams.delete('select') + } else { + newParams.set('select', 'true') + } + const search = newParams.toString() + history.push(`${location.pathname}${search ? `?${search}` : ''}`) + } + + return ( + + + {sidebarHidden && ( + dispatch.ui.set({ sidebarMenu: true })} + /> + )} + {showBack && ( + + )} + + {sidebarHidden && ( + + Products + + )} + + + + + + + ) +} + +const useStyles = makeStyles(({ palette }) => ({ + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + height: 45, + paddingLeft: spacing.md, + paddingRight: spacing.md, + marginTop: spacing.sm, // 12px to match global header + }, + left: { + display: 'flex', + alignItems: 'center', + }, + right: { + display: 'flex', + alignItems: 'center', + }, + title: {}, +})) + diff --git a/frontend/src/pages/ProductsPage/ProductsPage.tsx b/frontend/src/pages/ProductsPage/ProductsPage.tsx index 85eeaa6c6..d2f7e1c8e 100644 --- a/frontend/src/pages/ProductsPage/ProductsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsPage.tsx @@ -1,22 +1,31 @@ import React, { useEffect } from 'react' -import { useHistory, useRouteMatch } from 'react-router-dom' +import { useHistory, useLocation, useParams } from 'react-router-dom' import { useSelector } from 'react-redux' -import { Typography, Button } from '@mui/material' +import { Typography, Button, Box } from '@mui/material' import { Container } from '../../components/Container' import { Icon } from '../../components/Icon' import { Body } from '../../components/Body' import { LoadingMessage } from '../../components/LoadingMessage' import { ProductList } from '../../components/ProductList' import { ProductsActionBar } from '../../components/ProductsActionBar' +import { ProductsListHeader } from './ProductsListHeader' import { productAttributes } from '../../components/ProductAttributes' import { removeObject } from '../../helpers/utilHelper' import { dispatch, State } from '../../store' import { getProductModel } from '../../selectors/products' -export const ProductsPage: React.FC = () => { +type Props = { + showHeader?: boolean + showBack?: boolean + onBack?: () => void +} + +export const ProductsPage: React.FC = ({ showHeader = false, showBack, onBack }) => { const history = useHistory() - const selectMatch = useRouteMatch('/products/select') - const select = !!selectMatch + const location = useLocation() + const { productId } = useParams<{ productId?: string }>() + const searchParams = new URLSearchParams(location.search) + const select = searchParams.get('select') === 'true' const productModel = useSelector(getProductModel) const products = productModel.all || [] const fetching = productModel.fetching || false @@ -45,12 +54,14 @@ export const ProductsPage: React.FC = () => { } return ( - } - > + + {showHeader && } + + {fetching && !initialized ? ( ) : products.length === 0 ? ( @@ -81,10 +92,12 @@ export const ProductsPage: React.FC = () => { fetching={fetching} select={select} selected={selected} + activeProductId={productId} onSelect={handleSelect} onSelectAll={handleSelectAll} /> )} - + + ) } diff --git a/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx new file mode 100644 index 000000000..7e510f3d4 --- /dev/null +++ b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx @@ -0,0 +1,264 @@ +import React, { useRef, useState, useEffect, useCallback } from 'react' +import { Switch, Route, useParams, Redirect } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Box } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { ProductsPage } from './ProductsPage' +import { ProductPage } from './ProductPage' +import { ProductSettingsPage } from './ProductSettingsPage' +import { ProductServiceDetailPage } from './ProductServiceDetailPage' +import { ProductServiceAddPage } from './ProductServiceAddPage' +import { getProductModel } from '../../selectors/products' +import { State } from '../../store' + +const MIN_WIDTH = 250 +const THREE_PANEL_WIDTH = 900 // Width threshold for showing 3 panels +const DEFAULT_LEFT_WIDTH = 350 +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 { all: products } = useSelector(getProductModel) + const product = products.find(p => p.id === productId) + + // Determine panel count based on layout.singlePanel and container width + // - singlePanel (<=750px): 1 panel + // - !singlePanel + wide container (>=900px): 3 panels + // - !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 + const showMiddle = maxPanels >= 2 + const showRight = maxPanels >= 1 + + return ( + + + {/* Left Panel - Products List */} + {showLeft && ( + <> + + + + + {/* Left Divider */} + + + + + + + )} + + {/* Middle Panel - Product Details */} + {showMiddle && ( + <> + + + + + {/* Right Divider */} + + + + + + + )} + + {/* Right Panel - Settings/Service Details */} + {showRight && ( + + + + + + + + + + + + + {/* In single panel mode, show ProductPage; otherwise redirect to details */} + {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, + // When shown alone, take full width + 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/ProductsPage/index.ts b/frontend/src/pages/ProductsPage/index.ts index 5917fc831..b35057bac 100644 --- a/frontend/src/pages/ProductsPage/index.ts +++ b/frontend/src/pages/ProductsPage/index.ts @@ -5,3 +5,4 @@ export { ProductPage } from './ProductPage' export { ProductServiceDetailPage } from './ProductServiceDetailPage' export { ProductServiceAddPage } from './ProductServiceAddPage' export { ProductSettingsPage } from './ProductSettingsPage' +export { ProductsWithDetailPage } from './ProductsWithDetailPage' diff --git a/frontend/src/routers/ProductsRouter.tsx b/frontend/src/routers/ProductsRouter.tsx index ad80883ef..87a08de4f 100644 --- a/frontend/src/routers/ProductsRouter.tsx +++ b/frontend/src/routers/ProductsRouter.tsx @@ -1,22 +1,17 @@ import React from 'react' -import { Switch, Route, useLocation, useParams } from 'react-router-dom' -import { useSelector } from 'react-redux' +import { Switch, Route, useLocation } from 'react-router-dom' import { DynamicPanel } from '../components/DynamicPanel' import { Panel } from '../components/Panel' import { ProductsPage } from '../pages/ProductsPage/ProductsPage' import { ProductAddPage } from '../pages/ProductsPage/ProductAddPage' -import { ProductPage } from '../pages/ProductsPage/ProductPage' -import { ProductServiceDetailPage } from '../pages/ProductsPage/ProductServiceDetailPage' -import { ProductServiceAddPage } from '../pages/ProductsPage/ProductServiceAddPage' -import { ProductSettingsPage } from '../pages/ProductsPage/ProductSettingsPage' -import { getProductModel } from '../selectors/products' +import { ProductsWithDetailPage } from '../pages/ProductsPage/ProductsWithDetailPage' export const ProductsRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { const location = useLocation() const locationParts = location.pathname.split('/') - // Use single panel mode for base /products route and /products/select - if (locationParts[2] === undefined || locationParts[2] === 'select') { + // Use single panel mode for base /products route + if (locationParts[2] === undefined) { layout = { ...layout, singlePanel: true } } @@ -31,15 +26,11 @@ export const ProductsRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { root="/products" /> - {/* Products list select mode */} - - - - - - {/* Product detail routes - use ProductRouter for nested routing */} + {/* Product detail routes - custom three panel layout */} - + + + {/* Products list */} @@ -50,46 +41,3 @@ export const ProductsRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { ) } - -// Nested router for individual product pages -const ProductRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { - const { productId } = useParams<{ productId: string }>() - const location = useLocation() - const locationParts = location.pathname.split('/') - const { all: products } = useSelector(getProductModel) - const product = products.find(p => p.id === productId) - - // Single panel mode when just viewing product (no service selected) - if (locationParts.length <= 3) { - layout = { ...layout, singlePanel: true } - } - - if (!product) { - return ( - - - - ) - } - - return ( - } - secondary={ - - - - - - - - - - - - } - layout={layout} - root="/products/:productId" - /> - ) -} From cb68cd592f2b0d3faf0948f47bcb2a3e0217fdb9 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Fri, 12 Dec 2025 18:18:17 -0800 Subject: [PATCH 13/22] fix: fix thresholds --- frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx index 7e510f3d4..6d0f7813b 100644 --- a/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx @@ -12,7 +12,7 @@ import { getProductModel } from '../../selectors/products' import { State } from '../../store' const MIN_WIDTH = 250 -const THREE_PANEL_WIDTH = 900 // Width threshold for showing 3 panels +const THREE_PANEL_WIDTH = 800 // Width threshold for showing 3 panels const DEFAULT_LEFT_WIDTH = 350 const DEFAULT_RIGHT_WIDTH = 350 From e42d51ae447fe587f7460bd56fdc897264ba029a Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 16 Dec 2025 13:52:35 -0800 Subject: [PATCH 14/22] feat: adjust sizing, add memory to orgs for selection/not selected, add registration code and product id. --- .../src/components/OrganizationSelect.tsx | 2 +- frontend/src/components/ProductListItem.tsx | 7 +++++- frontend/src/models/products.ts | 1 + .../src/pages/ProductsPage/ProductPage.tsx | 2 +- .../ProductsPage/ProductSettingsPage.tsx | 25 +++++++++++++++++++ .../ProductsPage/ProductsWithDetailPage.tsx | 4 +-- .../src/services/graphQLDeviceProducts.ts | 4 +++ 7 files changed, 40 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/OrganizationSelect.tsx b/frontend/src/components/OrganizationSelect.tsx index 8f70ce74b..809c843b6 100644 --- a/frontend/src/components/OrganizationSelect.tsx +++ b/frontend/src/components/OrganizationSelect.tsx @@ -58,7 +58,7 @@ export const OrganizationSelect: React.FC = () => { files.fetchIfEmpty() tags.fetchIfEmpty() products.fetchIfEmpty() - if (!mobile && ['/devices', '/networks', '/connections'].includes(menu)) { + if (!mobile && ['/devices', '/networks', '/connections', '/products'].includes(menu)) { history.push(defaultSelection[id]?.[menu] || menu) } } diff --git a/frontend/src/components/ProductListItem.tsx b/frontend/src/components/ProductListItem.tsx index d4e3eacdc..bcb7ff922 100644 --- a/frontend/src/components/ProductListItem.tsx +++ b/frontend/src/components/ProductListItem.tsx @@ -1,10 +1,12 @@ import React from 'react' import { useHistory } from 'react-router-dom' +import { useDispatch } from 'react-redux' import { Box } from '@mui/material' import { GridListItem } from './GridListItem' import { Attribute } from './Attributes' import { Icon } from './Icon' import { IDeviceProduct } from '../models/products' +import { Dispatch } from '../store' interface Props { product: IDeviceProduct @@ -28,12 +30,15 @@ export const ProductListItem: React.FC = ({ onSelect, }) => { const history = useHistory() + const dispatch = useDispatch() const handleClick = () => { if (select && onSelect) { onSelect(product.id) } else { - history.push(`/products/${product.id}`) + const to = `/products/${product.id}` + dispatch.ui.setDefaultSelected({ key: '/products', value: to }) + history.push(to) } } diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts index 841f475ff..9ac167bb8 100644 --- a/frontend/src/models/products.ts +++ b/frontend/src/models/products.ts @@ -25,6 +25,7 @@ export interface IDeviceProduct { name: string platform: { id: number; name: string | null } | null status: 'NEW' | 'LOCKED' + registrationCode?: string created: string updated: string services: IProductService[] diff --git a/frontend/src/pages/ProductsPage/ProductPage.tsx b/frontend/src/pages/ProductsPage/ProductPage.tsx index d79a3dc95..51781cf9c 100644 --- a/frontend/src/pages/ProductsPage/ProductPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductPage.tsx @@ -67,7 +67,7 @@ export const ProductPage: React.FC = () => { } title={ diff --git a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx index cc31f4717..77b4acccb 100644 --- a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx @@ -16,6 +16,7 @@ import { makeStyles } from '@mui/styles' import { Container } from '../../components/Container' import { Icon } from '../../components/Icon' import { IconButton } from '../../buttons/IconButton' +import { CopyIconButton } from '../../buttons/CopyIconButton' import { Body } from '../../components/Body' import { Notice } from '../../components/Notice' import { Confirm } from '../../components/Confirm' @@ -98,6 +99,20 @@ export const ProductSettingsPage: React.FC = ({ showBack = true }) => { Product Details + {isLocked && product.registrationCode && ( + <> + + + + + + + + + )} = ({ showBack = true }) => { secondary={new Date(product.updated).toLocaleString()} /> + + + + + + + diff --git a/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx index 6d0f7813b..6a74a9e97 100644 --- a/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx @@ -12,8 +12,8 @@ import { getProductModel } from '../../selectors/products' import { State } from '../../store' const MIN_WIDTH = 250 -const THREE_PANEL_WIDTH = 800 // Width threshold for showing 3 panels -const DEFAULT_LEFT_WIDTH = 350 +const THREE_PANEL_WIDTH = 961 // Width threshold for showing 3 panels +const DEFAULT_LEFT_WIDTH = 300 const DEFAULT_RIGHT_WIDTH = 350 export const ProductsWithDetailPage: React.FC = () => { diff --git a/frontend/src/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts index a297a03cb..5e0831ce6 100644 --- a/frontend/src/services/graphQLDeviceProducts.ts +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -28,6 +28,7 @@ export async function graphQLDeviceProducts(options?: { name platform { id name } status + registrationCode created updated services { @@ -57,6 +58,7 @@ export async function graphQLDeviceProduct(id: string) { name platform { id name } status + registrationCode created updated services { @@ -84,6 +86,7 @@ export async function graphQLCreateDeviceProduct(input: { name platform { id name } status + registrationCode created updated services { @@ -119,6 +122,7 @@ export async function graphQLUpdateDeviceProductSettings( name platform { id name } status + registrationCode created updated services { From c3353ff8294c27ac1ceed28c9c3321a54ec74be4 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 16 Dec 2025 14:43:08 -0800 Subject: [PATCH 15/22] feat: auto refresh and add refresh button --- frontend/src/models/products.ts | 24 ++++++++++++++++++ .../src/pages/ProductsPage/ProductPage.tsx | 25 +++++++++++++++++-- .../ProductsPage/ProductsWithDetailPage.tsx | 4 +-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts index 9ac167bb8..1db7bcebe 100644 --- a/frontend/src/models/products.ts +++ b/frontend/src/models/products.ts @@ -2,6 +2,7 @@ import { createModel } from '@rematch/core' import { RootModel } from '.' import { graphQLDeviceProducts, + graphQLDeviceProduct, graphQLCreateDeviceProduct, graphQLDeleteDeviceProduct, graphQLUpdateDeviceProductSettings, @@ -83,6 +84,29 @@ export default createModel()({ } }, + async fetchSingle(id: string, state) { + const accountId = selectActiveAccountId(state) + dispatch.products.set({ fetching: true, accountId }) + const response = await graphQLDeviceProduct(id) + if (!graphQLGetErrors(response)) { + const product = response?.data?.data?.deviceProduct + if (product) { + const productModel = getProductModel(state, accountId) + const exists = productModel.all.some(p => p.id === id) + dispatch.products.set({ + all: exists + ? productModel.all.map(p => (p.id === id ? product : p)) + : [...productModel.all, product], + accountId, + }) + dispatch.products.set({ fetching: false, accountId }) + return product + } + } + dispatch.products.set({ fetching: false, accountId }) + return null + }, + async create(input: { name: string; platform: string }, state) { const accountId = selectActiveAccountId(state) const response = await graphQLCreateDeviceProduct({ ...input, accountId }) diff --git a/frontend/src/pages/ProductsPage/ProductPage.tsx b/frontend/src/pages/ProductsPage/ProductPage.tsx index 51781cf9c..928d7930d 100644 --- a/frontend/src/pages/ProductsPage/ProductPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductPage.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import { useParams, useHistory } from 'react-router-dom' import { useSelector } from 'react-redux' import { Typography, List, ListItemText, Stack, Chip, Divider, Box } from '@mui/material' @@ -12,9 +12,14 @@ import { Notice } from '../../components/Notice' import { IconButton } from '../../buttons/IconButton' import { LoadingMessage } from '../../components/LoadingMessage' import { spacing } from '../../styling' +import { dispatch } from '../../store' import { getProductModel } from '../../selectors/products' -export const ProductPage: React.FC = () => { +type Props = { + showRefresh?: boolean +} + +export const ProductPage: React.FC = ({ showRefresh = true }) => { const { productId } = useParams<{ productId: string }>() const history = useHistory() @@ -27,6 +32,13 @@ export const ProductPage: React.FC = () => { const isLocked = product?.status === 'LOCKED' + // Refresh product data when loading + useEffect(() => { + if (productId) { + dispatch.products.fetchSingle(productId) + } + }, [productId]) + if (fetching && !initialized) { return ( @@ -63,6 +75,15 @@ export const ProductPage: React.FC = () => { onClick={handleBack} size="md" /> + {showRefresh && ( + dispatch.products.fetchSingle(productId)} + spin={fetching} + size="md" + /> + )} { {showMiddle && ( <> - + {/* Right Divider */} @@ -185,7 +185,7 @@ export const ProductsWithDetailPage: React.FC = () => { {showMiddle ? ( ) : ( - + )} From 9ffab957026a50209d72ed8a7a7a435d3042aea6 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 6 Jan 2026 15:56:03 -0800 Subject: [PATCH 16/22] feat: DESK-1692 Add admin pages --- frontend/src/components/AdminSidebarNav.tsx | 31 +++ frontend/src/components/AvatarMenu.tsx | 29 ++ frontend/src/components/Sidebar.tsx | 12 +- frontend/src/models/products.ts | 5 +- frontend/src/models/ui.ts | 2 + frontend/src/models/user.ts | 2 + .../AdminPartnerDetailPanel.tsx | 255 ++++++++++++++++++ .../AdminPartnersListPage.tsx | 174 ++++++++++++ .../AdminPartnersPage/AdminPartnersPage.tsx | 169 ++++++++++++ .../AdminUsersPage/AdminUserAccountPanel.tsx | 137 ++++++++++ .../AdminUsersPage/AdminUserDetailPage.tsx | 119 ++++++++ .../AdminUsersPage/AdminUserDevicesPanel.tsx | 150 +++++++++++ .../AdminUsersPage/AdminUserListItem.tsx | 52 ++++ .../AdminUsersPage/AdminUsersListPage.tsx | 184 +++++++++++++ .../AdminUsersWithDetailPage.tsx | 247 +++++++++++++++++ .../AdminUsersPage/adminUserAttributes.tsx | 46 ++++ frontend/src/routers/Router.tsx | 25 ++ .../src/services/graphQLDeviceProducts.ts | 40 +-- frontend/src/services/graphQLRequest.ts | 184 +++++++++++++ 19 files changed, 1840 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/AdminSidebarNav.tsx create mode 100644 frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx create mode 100644 frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx create mode 100644 frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx create mode 100644 frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx create mode 100644 frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx create mode 100644 frontend/src/pages/AdminUsersPage/AdminUserDevicesPanel.tsx create mode 100644 frontend/src/pages/AdminUsersPage/AdminUserListItem.tsx create mode 100644 frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx create mode 100644 frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx create mode 100644 frontend/src/pages/AdminUsersPage/adminUserAttributes.tsx diff --git a/frontend/src/components/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx new file mode 100644 index 000000000..b286315c8 --- /dev/null +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { makeStyles } from '@mui/styles' +import { List } from '@mui/material' +import { ListItemLocation } from './ListItemLocation' +import { spacing } from '../styling' + +export const AdminSidebarNav: React.FC = () => { + const css = useStyles() + + return ( + + + + + ) +} + +const useStyles = makeStyles(({ palette }) => ({ + list: { + position: 'static', + '& .MuiListItemIcon-root': { color: palette.grayDark.main }, + '& .MuiListItemText-primary': { color: palette.grayDarkest.main }, + '& .MuiListItemButton-root:hover .MuiListItemText-primary': { color: palette.black.main }, + '& .Mui-selected, & .Mui-selected:hover': { + backgroundColor: palette.primaryLighter.main, + '& .MuiListItemIcon-root': { color: palette.grayDarker.main }, + '& .MuiListItemText-primary': { color: palette.black.main, fontWeight: 500 }, + }, + }, +})) + 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') + }} + /> + ) + )} = ({ 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/models/products.ts b/frontend/src/models/products.ts index 1db7bcebe..207ffe26e 100644 --- a/frontend/src/models/products.ts +++ b/frontend/src/models/products.ts @@ -87,9 +87,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) diff --git a/frontend/src/models/ui.ts b/frontend/src/models/ui.ts index 4c5c05bee..a12b77958 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()({ 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..c3cdfed97 --- /dev/null +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx @@ -0,0 +1,255 @@ +import React, { useEffect, useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { Typography, List, ListItem, ListItemText, ListItemButton, ListItemIcon, Box, Divider, Chip } 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 { graphQLAdminPartner } from '../../services/graphQLRequest' +import { spacing } from '../../styling' + +export const AdminPartnerDetailPanel: React.FC = () => { + const { partnerId } = useParams<{ partnerId: string }>() + const history = useHistory() + const [partner, setPartner] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (partnerId) { + fetchPartner() + } + }, [partnerId]) + + const fetchPartner = async () => { + setLoading(true) + const result = await graphQLAdminPartner(partnerId) + if (result !== 'ERROR' && result?.data?.data?.admin?.partners?.[0]) { + setPartner(result.data.data.admin.partners[0]) + } + 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}`) + } + + if (loading) { + return ( + + + + ) + } + + if (!partner) { + return ( + + + + + Partner not found + + + + ) + } + + const users = partner.users || [] + const children = partner.children || [] + + // Split users into admins and registrants + const admins = users.filter((u: any) => u.role === 'admin' || u.role === 'admin_registrant') + const registrants = users.filter((u: any) => u.role === 'admin_registrant' || u.role === 'device_registrant') + + 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.length > 0 && ( + <> + + Registrants ({registrants.length}) + + + {registrants.map((user: any, index: number) => ( + + {index > 0 && } + handleNavigateToUser(user.id)}> + + + + + {user.email} + + + } + secondary={`${user.deviceCount || 0} total • ${user.online || 0} online • ${user.active || 0} active`} + /> + + + + ))} + + + )} + + {/* Admins in this Partner */} + {admins.length > 0 && ( + <> + + Admins ({admins.length}) + + + {admins.map((user: any, index: number) => ( + + {index > 0 && } + handleNavigateToUser(user.id)}> + + + + + {user.email} + + + } + /> + + + + ))} + + + )} + + ) +} + diff --git a/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx new file mode 100644 index 000000000..765623ab9 --- /dev/null +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx @@ -0,0 +1,174 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Typography, Box, TextField, InputAdornment, 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 { graphQLAdminPartners } 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 } 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 css = useStyles() + const [partners, setPartners] = useState([]) + const [loading, setLoading] = useState(true) + const [searchValue, setSearchValue] = useState('') + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const [required, attributes] = removeObject(adminPartnerAttributes, a => a.required === true) + + useEffect(() => { + fetchPartners() + }, []) + + const fetchPartners = async () => { + setLoading(true) + const result = await graphQLAdminPartners() + if (result !== 'ERROR' && result?.data?.data?.admin?.partners) { + setPartners(result.data.data.admin.partners || []) + } + setLoading(false) + } + + 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) => { + history.push(`/admin/partners/${partnerId}`) + } + + return ( + + + + setSearchValue(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + } + > + {loading ? ( + + ) : filteredPartners.length === 0 ? ( + + + + {searchValue ? 'No matching partners' : 'No partners found'} + + + ) : ( + + {filteredPartners.map(partner => ( + handlePartnerClick(partner.id)} + selected={location.pathname.includes(`/admin/partners/${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/AdminPartnersPage/AdminPartnersPage.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx new file mode 100644 index 000000000..559c97c4a --- /dev/null +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx @@ -0,0 +1,169 @@ +import React, { useRef, useState, useEffect, useCallback } from 'react' +import { useParams } 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' + +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 css = useStyles() + const containerRef = useRef(null) + + const layout = useSelector((state: State) => state.ui.layout) + + const [containerWidth, setContainerWidth] = useState(1000) + + 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) + + const maxPanels = layout.singlePanel ? 1 : (containerWidth >= TWO_PANEL_WIDTH ? 2 : 1) + + 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() + }, []) + + 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) { + setLeftWidth(leftHandleRef.current) + } + }, []) + + 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) + } + + 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..b96673b35 --- /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 { 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 { graphQLAdminUser } from '../../services/graphQLRequest' + +export const AdminUserAccountPanel: React.FC = () => { + const { userId } = useParams<{ userId: string }>() + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (userId) { + fetchUser() + } + }, [userId]) + + const fetchUser = async () => { + setLoading(true) + const result = await graphQLAdminUser(userId) + if (result !== 'ERROR' && result?.data?.data?.admin?.users?.items?.[0]) { + setUser(result.data.data.admin.users.items[0]) + } + 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..79e42f154 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react' +import { useParams, useHistory } from 'react-router-dom' +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 { graphQLAdminUser } from '../../services/graphQLRequest' + +type Props = { + showRefresh?: boolean +} + +export const AdminUserDetailPage: React.FC = ({ showRefresh = true }) => { + const { userId } = useParams<{ userId: string }>() + const history = useHistory() + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (userId) { + fetchUser() + } + }, [userId]) + + const fetchUser = async () => { + setLoading(true) + const result = await graphQLAdminUser(userId) + if (result !== 'ERROR' && result?.data?.data?.admin?.users?.items?.[0]) { + setUser(result.data.data.admin.users.items[0]) + } + 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 && ( + + )} + + + } + 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..bd5b9eb14 --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx @@ -0,0 +1,184 @@ +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 [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [searchInput, setSearchInput] = useState('') + const [searchValue, setSearchValue] = useState('') + const [searchType, setSearchType] = useState('email') + const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) + const pageSize = 50 + const columnWidths = useSelector((state: State) => state.ui.columnWidths) + const [required, attributes] = removeObject(adminUserAttributes, a => a.required === true) + + useEffect(() => { + fetchUsers() + }, [page, searchValue, searchType]) + + const fetchUsers = async () => { + setLoading(true) + + // Build filters based on search type + const filters: { search?: string; email?: string; accountId?: string } = {} + const trimmedValue = searchValue.trim() + + if (trimmedValue) { + switch (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: (page - 1) * pageSize, size: pageSize }, + Object.keys(filters).length > 0 ? filters : undefined, + 'email' + ) + if (result !== 'ERROR' && result?.data?.data?.admin?.users) { + const data = result.data.data.admin.users + setUsers(data.items || []) + setTotal(data.total || 0) + } + setLoading(false) + } + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchInput(event.target.value) + } + + const handleSearchKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + setSearchValue(searchInput) + setPage(1) + } + } + + const handleSearchTypeChange = (_: React.MouseEvent, newType: SearchType | null) => { + if (newType !== null) { + setSearchType(newType) + } + } + + const getPlaceholder = () => { + switch (searchType) { + case 'email': + return 'Search by email address...' + case 'userId': + return 'Search by user ID (UUID)...' + case 'all': + default: + return 'Search by email, name, or user ID...' + } + } + + const handleUserClick = (userId: string) => { + dispatch.ui.setDefaultSelected({ key: '/admin/users', value: `/admin/users/${userId}` }) + history.push(`/admin/users/${userId}`) + } + + 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..4777bf88f --- /dev/null +++ b/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx @@ -0,0 +1,247 @@ +import React, { useRef, useState, useEffect, useCallback } from 'react' +import { Switch, Route, useParams, Redirect } 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' + +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 css = useStyles() + const containerRef = useRef(null) + + const layout = useSelector((state: State) => state.ui.layout) + + const [containerWidth, setContainerWidth] = useState(1000) + + 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) + + const rightHandleRef = useRef(DEFAULT_RIGHT_WIDTH) + const rightMoveRef = useRef(0) + const [rightWidth, setRightWidth] = useState(DEFAULT_RIGHT_WIDTH) + const [rightGrab, setRightGrab] = useState(false) + + const maxPanels = layout.singlePanel ? 1 : (containerWidth >= THREE_PANEL_WIDTH ? 3 : 2) + + 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() + }, []) + + 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) + } + + 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) + } + + // 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/routers/Router.tsx b/frontend/src/routers/Router.tsx index a234a16ee..f43f39e72 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -57,6 +57,8 @@ 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 browser, { getOs } from '../services/browser' import analytics from '../services/analytics' @@ -71,6 +73,19 @@ 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 }) + } else if (!isAdminRoute && adminMode) { + // Exit admin mode when leaving admin routes + ui.set({ adminMode: false }) + } + }, [location.pathname, userAdmin, adminMode, ui]) useEffect(() => { const initialRoute = window.localStorage.getItem('initialRoute') @@ -383,6 +398,16 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { root="/account" /> + {/* Admin Routes */} + + + + + + + + + {/* Not found */} diff --git a/frontend/src/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts index 5e0831ce6..ecf05f699 100644 --- a/frontend/src/services/graphQLDeviceProducts.ts +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -50,27 +50,33 @@ 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 + created + updated + services { + id + name + type { id name } + port + enabled + } + } + } } } }`, - { id } + { id, accountId } ) } diff --git a/frontend/src/services/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index 579c04609..7b3ee1baa 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 + } + } } } }`, @@ -391,6 +399,72 @@ export async function graphQLFetchOrganizations(ids: string[]) { ) } +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) { @@ -479,3 +553,113 @@ 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 + } + } + }` + ) +} + +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 + users { + id + email + role + deviceCount + online + active + activated + updated + } + children { + id + name + deviceCount + online + active + activated + } + } + } + }`, + { id } + ) +} From 3f7a481aa892eeb13ce902ead57cc3b896394c74 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Tue, 13 Jan 2026 17:28:11 -0800 Subject: [PATCH 17/22] incorporate pr changes and add updates for partner entity --- electron/src/ElectronApp.ts | 16 +- .../buttons/RefreshButton/RefreshButton.tsx | 22 ++ frontend/src/components/AdminSidebarNav.tsx | 43 ++- frontend/src/components/Header/Header.tsx | 6 +- .../src/components/OrganizationSelect.tsx | 5 +- frontend/src/components/SidebarNav.tsx | 7 + frontend/src/hooks/useContainerWidth.ts | 30 ++ frontend/src/hooks/useResizablePanel.ts | 63 +++ frontend/src/models/auth.ts | 1 + frontend/src/models/index.ts | 3 + frontend/src/models/partnerStats.ts | 162 ++++++++ .../AdminPartnerDetailPanel.tsx | 358 +++++++++++++++--- .../AdminPartnersListPage.tsx | 83 +++- .../AdminPartnersPage/AdminPartnersPage.tsx | 70 +--- .../AdminUsersPage/AdminUserDetailPage.tsx | 1 + .../AdminUsersPage/AdminUsersListPage.tsx | 15 +- .../AdminUsersWithDetailPage.tsx | 118 ++---- .../PartnerStatsDetailPanel.tsx | 203 ++++++++++ .../PartnerStatsPage/PartnerStatsListPage.tsx | 170 +++++++++ .../PartnerStatsPage/PartnerStatsPage.tsx | 19 + .../ProductsPage/ProductsWithDetailPage.tsx | 111 +----- frontend/src/routers/Router.tsx | 16 +- frontend/src/services/CloudSync.ts | 1 + frontend/src/services/graphQLRequest.ts | 116 ++++++ 24 files changed, 1301 insertions(+), 338 deletions(-) create mode 100644 frontend/src/hooks/useContainerWidth.ts create mode 100644 frontend/src/hooks/useResizablePanel.ts create mode 100644 frontend/src/models/partnerStats.ts create mode 100644 frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx create mode 100644 frontend/src/pages/PartnerStatsPage/PartnerStatsListPage.tsx create mode 100644 frontend/src/pages/PartnerStatsPage/PartnerStatsPage.tsx 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 index b286315c8..ef5c49d68 100644 --- a/frontend/src/components/AdminSidebarNav.tsx +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -1,31 +1,36 @@ import React from 'react' -import { makeStyles } from '@mui/styles' import { List } from '@mui/material' import { ListItemLocation } from './ListItemLocation' -import { spacing } from '../styling' export const AdminSidebarNav: React.FC = () => { - const css = useStyles() - return ( - + ) } -const useStyles = makeStyles(({ palette }) => ({ - list: { - position: 'static', - '& .MuiListItemIcon-root': { color: palette.grayDark.main }, - '& .MuiListItemText-primary': { color: palette.grayDarkest.main }, - '& .MuiListItemButton-root:hover .MuiListItemText-primary': { color: palette.black.main }, - '& .Mui-selected, & .Mui-selected:hover': { - backgroundColor: palette.primaryLighter.main, - '& .MuiListItemIcon-root': { color: palette.grayDarker.main }, - '& .MuiListItemText-primary': { color: palette.black.main, fontWeight: 500 }, - }, - }, -})) - diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index deffbeb38..2533c500b 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -39,7 +39,11 @@ export const Header: React.FC = () => { 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/SidebarNav.tsx b/frontend/src/components/SidebarNav.tsx index d91a47d4a..fa331864c 100644 --- a/frontend/src/components/SidebarNav.tsx +++ b/frontend/src/components/SidebarNav.tsx @@ -112,6 +112,13 @@ export const SidebarNav: React.FC = () => { dense /> + dispatch.partnerStats.fetchIfEmpty()} + /> setMore(!more)} sx={{ marginTop: 2 }}> diff --git a/frontend/src/hooks/useContainerWidth.ts b/frontend/src/hooks/useContainerWidth.ts new file mode 100644 index 000000000..6df2e1e3e --- /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..0ed4abcc3 --- /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/auth.ts b/frontend/src/models/auth.ts index 72fc05351..8a5eb3cac 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -265,6 +265,7 @@ export default createModel()({ dispatch.mfa.reset() dispatch.ui.reset() dispatch.products.reset() + dispatch.partnerStats.reset() cloudSync.reset() dispatch.accounts.set({ activeId: undefined }) diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index 607e2561c..ed195f3c7 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -19,6 +19,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' @@ -49,6 +50,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 @@ -80,6 +82,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..731d31b1e --- /dev/null +++ b/frontend/src/models/partnerStats.ts @@ -0,0 +1,162 @@ +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 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: string + 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/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx index c3cdfed97..1eb1a5762 100644 --- a/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx @@ -1,6 +1,10 @@ import React, { useEffect, useState } from 'react' import { useParams, useHistory } from 'react-router-dom' -import { Typography, List, ListItem, ListItemText, ListItemButton, ListItemIcon, Box, Divider, Chip } from '@mui/material' +import { + Typography, List, ListItem, ListItemText, ListItemButton, ListItemIcon, Box, Divider, Chip, 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' @@ -8,7 +12,17 @@ import { Body } from '../../components/Body' import { IconButton } from '../../buttons/IconButton' import { CopyIconButton } from '../../buttons/CopyIconButton' import { LoadingMessage } from '../../components/LoadingMessage' -import { graphQLAdminPartner } from '../../services/graphQLRequest' +import { + graphQLAdminPartner, + graphQLAdminPartners, + graphQLAddPartnerAdmin, + graphQLRemovePartnerAdmin, + graphQLAddPartnerChild, + graphQLRemovePartnerChild, + graphQLDeletePartner, + graphQLExportPartnerDevices +} from '../../services/graphQLRequest' +import { windowOpen } from '../../services/browser' import { spacing } from '../../styling' export const AdminPartnerDetailPanel: React.FC = () => { @@ -16,6 +30,18 @@ export const AdminPartnerDetailPanel: React.FC = () => { const history = useHistory() const [partner, setPartner] = useState(null) const [loading, setLoading] = useState(true) + const [addAdminDialogOpen, setAddAdminDialogOpen] = useState(false) + const [newAdminEmail, setNewAdminEmail] = useState('') + const [newAdminRole, setNewAdminRole] = useState('admin') + 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) useEffect(() => { if (partnerId) { @@ -25,6 +51,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { const fetchPartner = async () => { setLoading(true) + setPartner(null) // Clear stale data const result = await graphQLAdminPartner(partnerId) if (result !== 'ERROR' && result?.data?.data?.admin?.partners?.[0]) { setPartner(result.data.data.admin.partners[0]) @@ -44,6 +71,115 @@ export const AdminPartnerDetailPanel: React.FC = () => { history.push(`/admin/users/${userId}`) } + const handleAddAdmin = async () => { + if (!newAdminEmail) return + + setAddingAdmin(true) + const result = await graphQLAddPartnerAdmin(partnerId, newAdminEmail, newAdminRole) + setAddingAdmin(false) + + if (result !== 'ERROR') { + setAddAdminDialogOpen(false) + setNewAdminEmail('') + setNewAdminRole('admin') + fetchPartner() + } else { + alert('Failed to add admin. They may already have access to this entity.') + } + } + + 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() + } else { + alert('Failed to remove admin.') + } + } + + 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() + } 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() + } 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 ( @@ -78,20 +214,41 @@ export const AdminPartnerDetailPanel: React.FC = () => { bodyProps={{ verticalOverflow: true }} header={ - - - + + + + + + + + + @@ -164,30 +321,51 @@ export const AdminPartnerDetailPanel: React.FC = () => { {/* Children Partners */} - {children.length > 0 && ( - <> - + <> + + Child Partners ({children.length}) + + + + + + {/* 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 index 765623ab9..639474a1f 100644 --- a/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx @@ -1,12 +1,15 @@ import React, { useEffect, useState, useMemo } from 'react' import { useHistory, useLocation } from 'react-router-dom' import { useSelector } from 'react-redux' -import { Typography, Box, TextField, InputAdornment, Stack } from '@mui/material' +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 { graphQLAdminPartners } from '../../services/graphQLRequest' +import { graphQLAdminPartners, graphQLCreatePartner } from '../../services/graphQLRequest' import { Gutters } from '../../components/Gutters' import { GridList } from '../../components/GridList' import { GridListItem } from '../../components/GridListItem' @@ -62,11 +65,23 @@ export const AdminPartnersListPage: React.FC = () => { const [searchValue, setSearchValue] = useState('') 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) useEffect(() => { fetchPartners() }, []) + useEffect(() => { + const handleRefresh = () => { + fetchPartners() + } + window.addEventListener('refreshAdminData', handleRefresh) + return () => window.removeEventListener('refreshAdminData', handleRefresh) + }, []) + const fetchPartners = async () => { setLoading(true) const result = await graphQLAdminPartners() @@ -89,6 +104,25 @@ export const AdminPartnersListPage: React.FC = () => { 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('') + fetchPartners() + // Navigate to new partner + history.push(`/admin/partners/${result.data.data.createPartner.id}`) + } else { + alert('Failed to create partner.') + } + } + return ( { header={ - setCreateDialogOpen(true)} + size="small" + children="Create Partner" /> { ))} )} + + {/* Create Partner Dialog */} + setCreateDialogOpen(false)} maxWidth="sm" fullWidth> + Create New Partner + + setNewPartnerName(e.target.value)} + sx={{ marginTop: 2 }} + /> + + Parent Partner (Optional) + + + + + + + + ) } diff --git a/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx index 559c97c4a..356c8be78 100644 --- a/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react' +import React from 'react' import { useParams } from 'react-router-dom' import { useSelector } from 'react-redux' import { Box } from '@mui/material' @@ -6,6 +6,8 @@ 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 @@ -14,62 +16,16 @@ const DEFAULT_LEFT_WIDTH = 350 export const AdminPartnersPage: React.FC = () => { const { partnerId } = useParams<{ partnerId?: string }>() const css = useStyles() - const containerRef = useRef(null) - const layout = useSelector((state: State) => state.ui.layout) - const [containerWidth, setContainerWidth] = useState(1000) - - 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) + 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) - 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() - }, []) - - 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) { - setLeftWidth(leftHandleRef.current) - } - }, []) - - 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) - } - const hasPartnerSelected = !!partnerId const showLeft = !hasPartnerSelected || maxPanels >= 2 const showRight = hasPartnerSelected @@ -82,19 +38,19 @@ export const AdminPartnersPage: React.FC = () => { {hasPartnerSelected && ( - - + + )} diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index 79e42f154..a0f541054 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -29,6 +29,7 @@ export const AdminUserDetailPage: React.FC = ({ showRefresh = true }) => const fetchUser = async () => { setLoading(true) + setUser(null) // Clear stale data const result = await graphQLAdminUser(userId) if (result !== 'ERROR' && result?.data?.data?.admin?.users?.items?.[0]) { setUser(result.data.data.admin.users.items[0]) diff --git a/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx index bd5b9eb14..8d115a562 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx @@ -35,6 +35,14 @@ export const AdminUsersListPage: React.FC = () => { fetchUsers() }, [page, searchValue, searchType]) + useEffect(() => { + const handleRefresh = () => { + fetchUsers() + } + window.addEventListener('refreshAdminData', handleRefresh) + return () => window.removeEventListener('refreshAdminData', handleRefresh) + }, [page, searchValue, searchType]) + const fetchUsers = async () => { setLoading(true) @@ -112,13 +120,6 @@ export const AdminUsersListPage: React.FC = () => { header={ - { const { userId } = useParams<{ userId?: string }>() + const history = useHistory() const css = useStyles() - const containerRef = useRef(null) - const layout = useSelector((state: State) => state.ui.layout) - const [containerWidth, setContainerWidth] = useState(1000) - - 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) - - 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 maxPanels = layout.singlePanel ? 1 : (containerWidth >= THREE_PANEL_WIDTH ? 3 : 2) + // Redirect to /account tab without adding to history 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() - }, []) - - 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) + if (userId && window.location.pathname === `/admin/users/${userId}`) { + history.replace(`/admin/users/${userId}/account`) } - }, [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) - } - - 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) - } + }, [userId, history]) // Only show detail panels when a user is selected const hasUserSelected = !!userId @@ -120,19 +56,19 @@ export const AdminUsersWithDetailPage: React.FC = () => { {hasUserSelected && ( - - + + )} @@ -146,8 +82,8 @@ export const AdminUsersWithDetailPage: React.FC = () => { - - + + @@ -156,7 +92,7 @@ export const AdminUsersWithDetailPage: React.FC = () => { {showRight && ( @@ -166,11 +102,7 @@ export const AdminUsersWithDetailPage: React.FC = () => { - {showMiddle ? ( - - ) : ( - - )} + {!showMiddle && } diff --git a/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx b/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx new file mode 100644 index 000000000..931a52be6 --- /dev/null +++ b/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Typography, List, ListItem, ListItemText, ListItemButton, ListItemIcon, 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 { 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 } from '../../store' +import { getPartnerStatsModel } from '../../models/partnerStats' + +export const PartnerStatsDetailPanel: React.FC = () => { + const { partnerId } = useParams<{ partnerId: string }>() + const history = useHistory() + 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 || [] + + 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)}> + + + + + + + + ))} + + + )} + + ) +} 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/ProductsWithDetailPage.tsx b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx index 02747ea77..de6f57c1d 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' @@ -10,6 +10,8 @@ import { ProductServiceDetailPage } from './ProductServiceDetailPage' import { ProductServiceAddPage } from './ProductServiceAddPage' 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 +21,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 +42,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 @@ -133,16 +56,16 @@ export const ProductsWithDetailPage: React.FC = () => { <> {/* Left Divider */} - - + + @@ -157,8 +80,8 @@ export const ProductsWithDetailPage: React.FC = () => { {/* Right Divider */} - - + + @@ -168,7 +91,7 @@ export const ProductsWithDetailPage: React.FC = () => { {showRight && ( diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index f43f39e72..fe2dfe18b 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -59,6 +59,7 @@ 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' @@ -81,9 +82,13 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { 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]) @@ -403,10 +408,17 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { - + + + - + + + + + + {/* 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/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index 7b3ee1baa..94d956568 100644 --- a/frontend/src/services/graphQLRequest.ts +++ b/frontend/src/services/graphQLRequest.ts @@ -663,3 +663,119 @@ export async function graphQLAdminPartner(id: string) { { 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, role: string = 'admin') { + return await graphQLBasicRequest( + `mutation AddPartnerAdmin($entityId: String!, $email: String!, $role: String!) { + addPartnerAdmin(entityId: $entityId, email: $email, role: $role) + }`, + { entityId, email, role } + ) +} + +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 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 + children { + id + name + deviceCount + online + active + activated + children { + id + name + deviceCount + online + active + activated + } + } + } + } + } + }`, + { accountId } + ) +} From a63cb9b81564150c2b5d2fd1ce9bddacf9fbd39a Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 22 Jan 2026 08:35:16 -0800 Subject: [PATCH 18/22] feat: updates to admin pages for use and caching --- frontend/src/components/AdminSidebarNav.tsx | 40 ++++- frontend/src/models/adminPartners.ts | 104 +++++++++++++ frontend/src/models/adminUsers.ts | 144 ++++++++++++++++++ frontend/src/models/auth.ts | 6 +- frontend/src/models/index.ts | 6 + frontend/src/models/ui.ts | 8 +- .../AdminPartnerDetailPanel.tsx | 28 ++-- .../AdminPartnersListPage.tsx | 33 ++-- .../AdminPartnersPage/AdminPartnersPage.tsx | 16 +- .../AdminUsersPage/AdminUserAccountPanel.tsx | 10 +- .../AdminUsersPage/AdminUserDetailPage.tsx | 16 +- .../AdminUsersPage/AdminUsersListPage.tsx | 83 ++++------ .../AdminUsersWithDetailPage.tsx | 19 ++- frontend/src/services/graphQLRequest.ts | 34 ++++- 14 files changed, 429 insertions(+), 118 deletions(-) create mode 100644 frontend/src/models/adminPartners.ts create mode 100644 frontend/src/models/adminUsers.ts diff --git a/frontend/src/components/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx index ef5c49d68..b8b6ef42e 100644 --- a/frontend/src/components/AdminSidebarNav.tsx +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -1,8 +1,21 @@ import React from 'react' -import { List } from '@mui/material' -import { ListItemLocation } from './ListItemLocation' +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/models/adminPartners.ts b/frontend/src/models/adminPartners.ts new file mode 100644 index 000000000..b5635b865 --- /dev/null +++ b/frontend/src/models/adminPartners.ts @@ -0,0 +1,104 @@ +import { createModel } from '@rematch/core' +import { graphQLAdminPartners, graphQLAdminPartner } from '../services/graphQLRequest' +import type { RootModel } from '.' + +interface AdminPartner { + id: string + name: string + deviceCount: number + online: number + active: number + activated: number + updated: string + [key: string]: any +} + +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..d1a790eef --- /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 8a5eb3cac..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()({ @@ -266,6 +266,8 @@ export default createModel()({ 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 ed195f3c7..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' @@ -31,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 @@ -63,6 +67,8 @@ export interface RootModel extends Models { export const models: RootModel = { accounts, + adminPartners, + adminUsers, announcements, applicationTypes, auth, diff --git a/frontend/src/models/ui.ts b/frontend/src/models/ui.ts index a12b77958..28f410968 100644 --- a/frontend/src/models/ui.ts +++ b/frontend/src/models/ui.ts @@ -298,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/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx index 2956a87a7..b9d8e9383 100644 --- a/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx @@ -1,5 +1,6 @@ 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, @@ -13,7 +14,6 @@ import { IconButton } from '../../buttons/IconButton' import { CopyIconButton } from '../../buttons/CopyIconButton' import { LoadingMessage } from '../../components/LoadingMessage' import { - graphQLAdminPartner, graphQLAdminPartners, graphQLAddPartnerAdmin, graphQLRemovePartnerAdmin, @@ -26,10 +26,12 @@ import { } 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) @@ -54,13 +56,13 @@ export const AdminPartnerDetailPanel: React.FC = () => { } }, [partnerId]) - const fetchPartner = async () => { + const fetchPartner = async (forceRefresh = false) => { setLoading(true) - setPartner(null) // Clear stale data - const result = await graphQLAdminPartner(partnerId) - if (result !== 'ERROR' && result?.data?.data?.admin?.partners?.[0]) { - setPartner(result.data.data.admin.partners[0]) + if (forceRefresh) { + dispatch.adminPartners.invalidatePartnerDetail(partnerId) } + const partnerData = await dispatch.adminPartners.fetchPartnerDetail(partnerId) + setPartner(partnerData) setLoading(false) } @@ -86,7 +88,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { if (result !== 'ERROR') { setAddAdminDialogOpen(false) setNewAdminEmail('') - fetchPartner() + fetchPartner(true) } else { alert('Failed to add admin.') } @@ -100,7 +102,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { setRemovingAdmin(null) if (result !== 'ERROR') { - fetchPartner() + fetchPartner(true) } else { alert('Failed to remove admin.') } @@ -116,7 +118,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { if (result !== 'ERROR') { setAddRegistrantDialogOpen(false) setNewRegistrantEmail('') - fetchPartner() + fetchPartner(true) } else { alert('Failed to add registrant. They may already have access to this entity.') } @@ -130,7 +132,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { setRemovingRegistrant(null) if (result !== 'ERROR') { - fetchPartner() + fetchPartner(true) } else { alert('Failed to remove registrant.') } @@ -162,7 +164,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { if (result !== 'ERROR') { setAddChildDialogOpen(false) setSelectedChildId('') - fetchPartner() + fetchPartner(true) } else { alert('Failed to add child partner.') } @@ -176,7 +178,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { setRemovingChild(null) if (result !== 'ERROR') { - fetchPartner() + fetchPartner(true) } else { alert('Failed to remove child partner.') } @@ -259,7 +261,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { fetchPartner(true)} spin={loading} size="md" /> diff --git a/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx index 639474a1f..7d595eef7 100644 --- a/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useMemo } from 'react' import { useHistory, useLocation } from 'react-router-dom' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Typography, Box, TextField, InputAdornment, Stack, Button, Dialog, DialogTitle, DialogContent, DialogActions, Select, MenuItem, FormControl, InputLabel @@ -9,12 +9,12 @@ import { Container } from '../../components/Container' import { Icon } from '../../components/Icon' import { IconButton } from '../../buttons/IconButton' import { LoadingMessage } from '../../components/LoadingMessage' -import { graphQLAdminPartners, graphQLCreatePartner } from '../../services/graphQLRequest' +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 } from '../../store' +import { State, Dispatch } from '../../store' import { makeStyles } from '@mui/styles' import { removeObject } from '../../helpers/utilHelper' @@ -59,10 +59,8 @@ const adminPartnerAttributes: Attribute[] = [ export const AdminPartnersListPage: React.FC = () => { const history = useHistory() const location = useLocation() + const dispatch = useDispatch() const css = useStyles() - const [partners, setPartners] = useState([]) - const [loading, setLoading] = useState(true) - const [searchValue, setSearchValue] = useState('') const columnWidths = useSelector((state: State) => state.ui.columnWidths) const [required, attributes] = removeObject(adminPartnerAttributes, a => a.required === true) const [createDialogOpen, setCreateDialogOpen] = useState(false) @@ -70,27 +68,23 @@ export const AdminPartnersListPage: React.FC = () => { 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(() => { - fetchPartners() + dispatch.adminPartners.fetchIfEmpty() }, []) useEffect(() => { const handleRefresh = () => { - fetchPartners() + dispatch.adminPartners.fetch() } window.addEventListener('refreshAdminData', handleRefresh) return () => window.removeEventListener('refreshAdminData', handleRefresh) }, []) - const fetchPartners = async () => { - setLoading(true) - const result = await graphQLAdminPartners() - if (result !== 'ERROR' && result?.data?.data?.admin?.partners) { - setPartners(result.data.data.admin.partners || []) - } - setLoading(false) - } - const filteredPartners = useMemo(() => { if (!searchValue.trim()) return partners const search = searchValue.toLowerCase() @@ -101,6 +95,7 @@ export const AdminPartnersListPage: React.FC = () => { }, [partners, searchValue]) const handlePartnerClick = (partnerId: string) => { + dispatch.ui.setDefaultSelected({ key: '/admin/partners', value: `/admin/partners/${partnerId}`, accountId: 'admin' }) history.push(`/admin/partners/${partnerId}`) } @@ -115,7 +110,7 @@ export const AdminPartnersListPage: React.FC = () => { setCreateDialogOpen(false) setNewPartnerName('') setNewPartnerParentId('') - fetchPartners() + dispatch.adminPartners.fetch() // Navigate to new partner history.push(`/admin/partners/${result.data.data.createPartner.id}`) } else { @@ -141,7 +136,7 @@ export const AdminPartnersListPage: React.FC = () => { size="small" placeholder="Search partners..." value={searchValue} - onChange={e => setSearchValue(e.target.value)} + onChange={e => dispatch.adminPartners.setSearchValue(e.target.value)} InputProps={{ startAdornment: ( diff --git a/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx index 356c8be78..6f1a9e485 100644 --- a/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnersPage.tsx @@ -1,5 +1,5 @@ -import React from 'react' -import { useParams } from 'react-router-dom' +import React, { useEffect } 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' @@ -15,8 +15,11 @@ 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 { containerRef, containerWidth } = useContainerWidth() const leftPanel = useResizablePanel(DEFAULT_LEFT_WIDTH, containerRef, { @@ -26,6 +29,15 @@ export const AdminPartnersPage: React.FC = () => { const maxPanels = layout.singlePanel ? 1 : (containerWidth >= TWO_PANEL_WIDTH ? 2 : 1) + // Restore previously selected partner if navigating to /admin/partners without a partnerId + useEffect(() => { + const adminSelection = defaultSelection['admin'] + const savedRoute = adminSelection?.['/admin/partners'] + if (location.pathname === '/admin/partners' && savedRoute) { + history.replace(savedRoute) + } + }, [location.pathname, defaultSelection]) + const hasPartnerSelected = !!partnerId const showLeft = !hasPartnerSelected || maxPanels >= 2 const showRight = hasPartnerSelected diff --git a/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx b/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx index b96673b35..6d4dc8978 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserAccountPanel.tsx @@ -1,5 +1,6 @@ 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' @@ -7,10 +8,11 @@ import { Icon } from '../../components/Icon' import { Body } from '../../components/Body' import { CopyIconButton } from '../../buttons/CopyIconButton' import { LoadingMessage } from '../../components/LoadingMessage' -import { graphQLAdminUser } from '../../services/graphQLRequest' +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) @@ -22,10 +24,8 @@ export const AdminUserAccountPanel: React.FC = () => { const fetchUser = async () => { setLoading(true) - const result = await graphQLAdminUser(userId) - if (result !== 'ERROR' && result?.data?.data?.admin?.users?.items?.[0]) { - setUser(result.data.data.admin.users.items[0]) - } + const userData = await dispatch.adminUsers.fetchUserDetail(userId) + setUser(userData) setLoading(false) } diff --git a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx index a0f541054..8e6afa261 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUserDetailPage.tsx @@ -1,5 +1,6 @@ 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' @@ -9,7 +10,7 @@ import { Body } from '../../components/Body' import { IconButton } from '../../buttons/IconButton' import { LoadingMessage } from '../../components/LoadingMessage' import { spacing } from '../../styling' -import { graphQLAdminUser } from '../../services/graphQLRequest' +import { Dispatch } from '../../store' type Props = { showRefresh?: boolean @@ -18,6 +19,7 @@ type Props = { 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) @@ -27,13 +29,13 @@ export const AdminUserDetailPage: React.FC = ({ showRefresh = true }) => } }, [userId]) - const fetchUser = async () => { + const fetchUser = async (forceRefresh = false) => { setLoading(true) - setUser(null) // Clear stale data - const result = await graphQLAdminUser(userId) - if (result !== 'ERROR' && result?.data?.data?.admin?.users?.items?.[0]) { - setUser(result.data.data.admin.users.items[0]) + if (forceRefresh) { + dispatch.adminUsers.invalidateUserDetail(userId) } + const userData = await dispatch.adminUsers.fetchUserDetail(userId) + setUser(userData) setLoading(false) } @@ -81,7 +83,7 @@ export const AdminUserDetailPage: React.FC = ({ showRefresh = true }) => fetchUser(true)} spin={loading} size="md" /> diff --git a/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx index 8d115a562..d7673522d 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx @@ -20,63 +20,46 @@ export const AdminUsersListPage: React.FC = () => { const history = useHistory() const location = useLocation() const dispatch = useDispatch() - const [users, setUsers] = useState([]) - const [loading, setLoading] = useState(true) const [searchInput, setSearchInput] = useState('') - const [searchValue, setSearchValue] = useState('') - const [searchType, setSearchType] = useState('email') - const [page, setPage] = useState(1) - const [total, setTotal] = useState(0) - const pageSize = 50 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(() => { - fetchUsers() + 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 = () => { - fetchUsers() + dispatch.adminUsers.fetch() } window.addEventListener('refreshAdminData', handleRefresh) return () => window.removeEventListener('refreshAdminData', handleRefresh) - }, [page, searchValue, searchType]) - - const fetchUsers = async () => { - setLoading(true) - - // Build filters based on search type - const filters: { search?: string; email?: string; accountId?: string } = {} - const trimmedValue = searchValue.trim() - - if (trimmedValue) { - switch (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: (page - 1) * pageSize, size: pageSize }, - Object.keys(filters).length > 0 ? filters : undefined, - 'email' - ) - if (result !== 'ERROR' && result?.data?.data?.admin?.users) { - const data = result.data.data.admin.users - setUsers(data.items || []) - setTotal(data.total || 0) - } - setLoading(false) - } + }, []) const handleSearchChange = (event: React.ChangeEvent) => { setSearchInput(event.target.value) @@ -84,14 +67,13 @@ export const AdminUsersListPage: React.FC = () => { const handleSearchKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { - setSearchValue(searchInput) - setPage(1) + dispatch.adminUsers.setSearch({ searchValue: searchInput, searchType }) } } const handleSearchTypeChange = (_: React.MouseEvent, newType: SearchType | null) => { if (newType !== null) { - setSearchType(newType) + dispatch.adminUsers.setSearch({ searchValue, searchType: newType }) } } @@ -108,8 +90,9 @@ export const AdminUsersListPage: React.FC = () => { } const handleUserClick = (userId: string) => { - dispatch.ui.setDefaultSelected({ key: '/admin/users', value: `/admin/users/${userId}` }) - history.push(`/admin/users/${userId}`) + const route = `/admin/users/${userId}/account` + dispatch.ui.setDefaultSelected({ key: '/admin/users', value: route, accountId: 'admin' }) + history.push(route) } return ( diff --git a/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx index 21f276e29..7ad05f79a 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react' -import { Switch, Route, useParams, useHistory } from 'react-router-dom' +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' @@ -19,8 +19,10 @@ 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 { containerRef, containerWidth } = useContainerWidth() const leftPanel = useResizablePanel(DEFAULT_LEFT_WIDTH, containerRef, { @@ -32,12 +34,21 @@ export const AdminUsersWithDetailPage: React.FC = () => { const maxPanels = layout.singlePanel ? 1 : (containerWidth >= THREE_PANEL_WIDTH ? 3 : 2) - // Redirect to /account tab without adding to history + // Restore previously selected user if navigating to /admin/users without a userId useEffect(() => { - if (userId && window.location.pathname === `/admin/users/${userId}`) { + const adminSelection = defaultSelection['admin'] + const savedRoute = adminSelection?.['/admin/users'] + if (location.pathname === '/admin/users' && savedRoute) { + history.replace(savedRoute) + } + }, [location.pathname, defaultSelection]) + + // 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, history]) + }, [userId, location.pathname, history]) // Only show detail panels when a user is selected const hasUserSelected = !!userId diff --git a/frontend/src/services/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index 5d37cafeb..d08f57506 100644 --- a/frontend/src/services/graphQLRequest.ts +++ b/frontend/src/services/graphQLRequest.ts @@ -322,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 @@ -392,8 +392,8 @@ export async function graphQLFetchOrganizations(ids: string[]) { ${LIMITS_QUERY} } }` - ) - .join('\n')} + ) + .join('\n')} } }` ) @@ -494,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 @@ -530,8 +530,8 @@ export async function graphQLFetchSessions(ids: string[]) { } } }` - ) - .join('\n')} + ) + .join('\n')} } }` ) @@ -613,6 +613,24 @@ export async function graphQLAdminPartners() { active activated updated + users { + id + email + role + deviceCount + online + active + activated + updated + } + children { + id + name + deviceCount + online + active + activated + } } } }` From 59937251de6a394223e19ad2996213639065d86b Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Thu, 22 Jan 2026 18:36:55 -0800 Subject: [PATCH 19/22] feat: clean issues with partner entities --- frontend/src/components/SidebarNav.tsx | 22 ++- frontend/src/models/adminPartners.ts | 35 +++- frontend/src/models/partnerStats.ts | 14 +- .../AdminPartnerDetailPanel.tsx | 7 +- .../AdminPartnersPage/AdminPartnersPage.tsx | 32 ++-- .../AdminUsersWithDetailPage.tsx | 42 +++-- .../PartnerStatsDetailPanel.tsx | 172 +----------------- frontend/src/services/graphQLRequest.ts | 90 ++++++++- 8 files changed, 194 insertions(+), 220 deletions(-) diff --git a/frontend/src/components/SidebarNav.tsx b/frontend/src/components/SidebarNav.tsx index fa331864c..a29b3cfca 100644 --- a/frontend/src/components/SidebarNav.tsx +++ b/frontend/src/components/SidebarNav.tsx @@ -4,6 +4,7 @@ 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 { @@ -26,6 +27,7 @@ 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() @@ -40,6 +42,10 @@ export const SidebarNav: React.FC = () => { const dispatch = useDispatch() 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,13 +118,15 @@ export const SidebarNav: React.FC = () => { dense /> - dispatch.partnerStats.fetchIfEmpty()} - /> + {hasPartnerAdminAccess && ( + dispatch.partnerStats.fetchIfEmpty()} + /> + )} setMore(!more)} sx={{ marginTop: 2 }}> diff --git a/frontend/src/models/adminPartners.ts b/frontend/src/models/adminPartners.ts index b5635b865..2b2ebe9ba 100644 --- a/frontend/src/models/adminPartners.ts +++ b/frontend/src/models/adminPartners.ts @@ -2,15 +2,46 @@ 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: string - [key: string]: any + updated?: Date + admins?: AdminPartnerUser[] + registrants?: AdminPartnerUser[] + children?: AdminPartnerChild[] } interface AdminPartnersState { diff --git a/frontend/src/models/partnerStats.ts b/frontend/src/models/partnerStats.ts index 731d31b1e..26a9b9915 100644 --- a/frontend/src/models/partnerStats.ts +++ b/frontend/src/models/partnerStats.ts @@ -5,6 +5,16 @@ 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 @@ -20,7 +30,9 @@ export interface IPartnerEntity { online: number active: number activated: number - updated: string + updated?: Date + admins?: IPartnerEntityUser[] + registrants?: IPartnerEntityUser[] children?: IPartnerEntity[] } diff --git a/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx index b9d8e9383..fba490e5b 100644 --- a/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx @@ -237,12 +237,9 @@ export const AdminPartnerDetailPanel: React.FC = () => { ) } - const users = partner.users || [] + const admins = partner.admins || [] + const registrants = partner.registrants || [] const children = partner.children || [] - - // Split users into admins and registrants (admin_registrant users show in both lists) - const admins = users.filter((u: any) => u.role === 'admin' || u.role === 'admin_registrant') - const registrants = users.filter((u: any) => u.role === 'device_registrant' || u.role === 'admin_registrant') return ( { 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, @@ -29,14 +30,17 @@ export const AdminPartnersPage: React.FC = () => { const maxPanels = layout.singlePanel ? 1 : (containerWidth >= TWO_PANEL_WIDTH ? 2 : 1) - // Restore previously selected partner if navigating to /admin/partners without a partnerId + // Restore previously selected partner ONLY on initial mount useEffect(() => { - const adminSelection = defaultSelection['admin'] - const savedRoute = adminSelection?.['/admin/partners'] - if (location.pathname === '/admin/partners' && savedRoute) { - history.replace(savedRoute) + if (!hasRestoredRef.current) { + const adminSelection = defaultSelection['admin'] + const savedRoute = adminSelection?.['/admin/partners'] + if (location.pathname === '/admin/partners' && savedRoute) { + history.replace(savedRoute) + } + hasRestoredRef.current = true } - }, [location.pathname, defaultSelection]) + }, []) // Empty dependency array - only run once on mount const hasPartnerSelected = !!partnerId const showLeft = !hasPartnerSelected || maxPanels >= 2 @@ -47,18 +51,18 @@ export const AdminPartnersPage: React.FC = () => { {showLeft && ( <> - - + {hasPartnerSelected && ( @@ -68,7 +72,7 @@ export const AdminPartnersPage: React.FC = () => { )} )} - + {showRight && ( diff --git a/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx index 7ad05f79a..f03f361ac 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUsersWithDetailPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +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' @@ -23,7 +23,8 @@ export const AdminUsersWithDetailPage: React.FC = () => { 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, @@ -34,14 +35,17 @@ export const AdminUsersWithDetailPage: React.FC = () => { const maxPanels = layout.singlePanel ? 1 : (containerWidth >= THREE_PANEL_WIDTH ? 3 : 2) - // Restore previously selected user if navigating to /admin/users without a userId + // Restore previously selected user ONLY on initial mount useEffect(() => { - const adminSelection = defaultSelection['admin'] - const savedRoute = adminSelection?.['/admin/users'] - if (location.pathname === '/admin/users' && savedRoute) { - history.replace(savedRoute) + if (!hasRestoredRef.current) { + const adminSelection = defaultSelection['admin'] + const savedRoute = adminSelection?.['/admin/users'] + if (location.pathname === '/admin/users' && savedRoute) { + history.replace(savedRoute) + } + hasRestoredRef.current = true } - }, [location.pathname, defaultSelection]) + }, []) // Empty dependency array - only run once on mount // Redirect to /account tab if navigating directly to user without a sub-route useEffect(() => { @@ -52,7 +56,7 @@ export const AdminUsersWithDetailPage: React.FC = () => { // 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 @@ -64,18 +68,18 @@ export const AdminUsersWithDetailPage: React.FC = () => { {showLeft && ( <> - - + {hasUserSelected && ( @@ -85,13 +89,13 @@ export const AdminUsersWithDetailPage: React.FC = () => { )} )} - + {showMiddle && ( <> - + @@ -99,10 +103,10 @@ export const AdminUsersWithDetailPage: React.FC = () => { )} - + {showRight && ( - diff --git a/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx b/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx index 999641455..418329f4b 100644 --- a/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx +++ b/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx @@ -2,9 +2,7 @@ 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, - Dialog, DialogTitle, DialogContent, DialogActions, TextField, Select, MenuItem, FormControl, InputLabel, - IconButton as MuiIconButton + Typography, List, ListItem, ListItemText, ListItemButton, ListItemIcon, Box, Divider, Button } from '@mui/material' import { Container } from '../../components/Container' import { Title } from '../../components/Title' @@ -13,13 +11,7 @@ import { Body } from '../../components/Body' import { IconButton } from '../../buttons/IconButton' import { CopyIconButton } from '../../buttons/CopyIconButton' import { LoadingMessage } from '../../components/LoadingMessage' -import { - graphQLExportPartnerDevices, - graphQLAddPartnerAdmin, - graphQLRemovePartnerAdmin, - graphQLAddPartnerRegistrant, - graphQLRemovePartnerRegistrant -} from '../../services/graphQLRequest' +import { graphQLExportPartnerDevices } from '../../services/graphQLRequest' import { windowOpen } from '../../services/browser' import { spacing } from '../../styling' import { State, Dispatch as AppDispatch } from '../../store' @@ -33,14 +25,6 @@ export const PartnerStatsDetailPanel: React.FC = () => { const userId = useSelector((state: State) => state.user.id) const partnerStatsModel = useSelector((state: State) => getPartnerStatsModel(state)) const { flattened: partners, all: rootPartners, fetching: loading } = partnerStatsModel - const [addAdminDialogOpen, setAddAdminDialogOpen] = useState(false) - const [newAdminEmail, setNewAdminEmail] = useState('') - const [addingAdmin, setAddingAdmin] = useState(false) - const [removingAdmin, setRemovingAdmin] = useState(null) - const [addRegistrantDialogOpen, setAddRegistrantDialogOpen] = useState(false) - const [newRegistrantEmail, setNewRegistrantEmail] = useState('') - const [addingRegistrant, setAddingRegistrant] = useState(false) - const [removingRegistrant, setRemovingRegistrant] = useState(null) // Find the partner in the flattened list const partner = useMemo(() => { @@ -70,66 +54,6 @@ export const PartnerStatsDetailPanel: React.FC = () => { } } - const handleAddAdmin = async () => { - if (!newAdminEmail || !partnerId) return - - setAddingAdmin(true) - const result = await graphQLAddPartnerAdmin(partnerId, newAdminEmail) - setAddingAdmin(false) - - if (result !== 'ERROR') { - setAddAdminDialogOpen(false) - setNewAdminEmail('') - dispatch.partnerStats.fetch() - } else { - alert('Failed to add admin.') - } - } - - const handleRemoveAdmin = async (userId: string) => { - if (!confirm('Are you sure you want to remove this admin?') || !partnerId) return - - setRemovingAdmin(userId) - const result = await graphQLRemovePartnerAdmin(partnerId, userId) - setRemovingAdmin(null) - - if (result !== 'ERROR') { - dispatch.partnerStats.fetch() - } else { - alert('Failed to remove admin.') - } - } - - const handleAddRegistrant = async () => { - if (!newRegistrantEmail || !partnerId) return - - setAddingRegistrant(true) - const result = await graphQLAddPartnerRegistrant(partnerId, newRegistrantEmail) - setAddingRegistrant(false) - - if (result !== 'ERROR') { - setAddRegistrantDialogOpen(false) - setNewRegistrantEmail('') - dispatch.partnerStats.fetch() - } 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?') || !partnerId) return - - setRemovingRegistrant(userId) - const result = await graphQLRemovePartnerRegistrant(partnerId, userId) - setRemovingRegistrant(null) - - if (result !== 'ERROR') { - dispatch.partnerStats.fetch() - } else { - alert('Failed to remove registrant.') - } - } - // Don't show anything if no partner is selected if (!partnerId) { return null @@ -157,11 +81,8 @@ export const PartnerStatsDetailPanel: React.FC = () => { } const children = partner.children || [] - const users = partner.users || [] - - // Split users into admins and registrants (admin_registrant users show in both lists) - const admins = users.filter((u: any) => u.role === 'admin' || u.role === 'admin_registrant') - const registrants = users.filter((u: any) => u.role === 'device_registrant' || u.role === 'admin_registrant') + const admins = partner.admins || [] + const registrants = partner.registrants || [] return ( { Registrants ({registrants.length}) - - - - - - {/* Add Registrant Dialog */} - setAddRegistrantDialogOpen(false)} maxWidth="sm" fullWidth> - Add Registrant to Partner - - setNewRegistrantEmail(e.target.value)} - sx={{ marginTop: 2 }} - /> - - - - - - ) } diff --git a/frontend/src/services/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index d08f57506..2dcd83d79 100644 --- a/frontend/src/services/graphQLRequest.ts +++ b/frontend/src/services/graphQLRequest.ts @@ -613,10 +613,18 @@ export async function graphQLAdminPartners() { active activated updated - users { + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { id email - role deviceCount online active @@ -630,6 +638,24 @@ export async function graphQLAdminPartners() { online active activated + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { + id + email + deviceCount + online + active + activated + updated + } } } } @@ -657,10 +683,18 @@ export async function graphQLAdminPartner(id: string) { active activated updated - users { + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { id email - role deviceCount online active @@ -674,6 +708,24 @@ export async function graphQLAdminPartner(id: string) { online active activated + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { + id + email + deviceCount + online + active + activated + updated + } } } } @@ -792,10 +844,18 @@ export async function graphQLPartnerEntities(accountId?: string) { active activated updated - users { + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { id email - role deviceCount online active @@ -809,6 +869,24 @@ export async function graphQLPartnerEntities(accountId?: string) { online active activated + admins { + id + email + deviceCount + online + active + activated + updated + } + registrants { + id + email + deviceCount + online + active + activated + updated + } children { id name From cf3eec3a81c1b7a4147023c9e5a9c0eb8e8b6ad2 Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 28 Jan 2026 14:01:49 -0800 Subject: [PATCH 20/22] fix: fixes for product creation and transfer --- frontend/src/components/AdminSidebarNav.tsx | 24 ++-- frontend/src/components/Header/Header.tsx | 2 +- frontend/src/components/SidebarNav.tsx | 12 +- frontend/src/hooks/useContainerWidth.ts | 6 +- frontend/src/hooks/useResizablePanel.ts | 10 +- frontend/src/models/adminPartners.ts | 2 +- frontend/src/models/adminUsers.ts | 2 +- frontend/src/models/products.ts | 29 ++++ .../AdminPartnerDetailPanel.tsx | 48 +++---- .../AdminPartnersListPage.tsx | 8 +- .../AdminUsersPage/AdminUsersListPage.tsx | 60 ++++---- .../PartnerStatsDetailPanel.tsx | 8 +- .../ProductsPage/AddProductServiceDialog.tsx | 11 +- .../ProductsPage/ProductServiceAddPage.tsx | 23 +-- .../ProductsPage/ProductSettingsPage.tsx | 66 +++++++++ .../ProductsPage/ProductTransferPage.tsx | 133 ++++++++++++++++++ .../ProductsPage/ProductsWithDetailPage.tsx | 26 ++-- frontend/src/pages/ProductsPage/index.ts | 1 + .../src/services/graphQLDeviceProducts.ts | 8 ++ 19 files changed, 368 insertions(+), 111 deletions(-) create mode 100644 frontend/src/pages/ProductsPage/ProductTransferPage.tsx diff --git a/frontend/src/components/AdminSidebarNav.tsx b/frontend/src/components/AdminSidebarNav.tsx index b8b6ef42e..b48a9613a 100644 --- a/frontend/src/components/AdminSidebarNav.tsx +++ b/frontend/src/components/AdminSidebarNav.tsx @@ -20,23 +20,23 @@ export const AdminSidebarNav: React.FC = () => { { - + { const manager = permissions.includes('MANAGE') const menu = location.pathname.match(REGEX_FIRST_PATH)?.[0] - + // Admin pages have two-level roots: /admin/users and /admin/partners (without IDs) const adminRootPages = ['/admin/users', '/admin/partners', '/partner-stats'] const isAdminRootPage = adminRootPages.includes(location.pathname) diff --git a/frontend/src/components/SidebarNav.tsx b/frontend/src/components/SidebarNav.tsx index a29b3cfca..6a1ba312b 100644 --- a/frontend/src/components/SidebarNav.tsx +++ b/frontend/src/components/SidebarNav.tsx @@ -42,7 +42,7 @@ export const SidebarNav: React.FC = () => { const dispatch = useDispatch() 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 @@ -119,11 +119,11 @@ export const SidebarNav: React.FC = () => { /> {hasPartnerAdminAccess && ( - dispatch.partnerStats.fetchIfEmpty()} /> )} diff --git a/frontend/src/hooks/useContainerWidth.ts b/frontend/src/hooks/useContainerWidth.ts index 6df2e1e3e..bc0cb3b99 100644 --- a/frontend/src/hooks/useContainerWidth.ts +++ b/frontend/src/hooks/useContainerWidth.ts @@ -15,14 +15,14 @@ export function useContainerWidth() { setContainerWidth(containerRef.current.offsetWidth) } } - + updateWidth() - + const resizeObserver = new ResizeObserver(updateWidth) if (containerRef.current) { resizeObserver.observe(containerRef.current) } - + return () => resizeObserver.disconnect() }, []) diff --git a/frontend/src/hooks/useResizablePanel.ts b/frontend/src/hooks/useResizablePanel.ts index 0ed4abcc3..b37900711 100644 --- a/frontend/src/hooks/useResizablePanel.ts +++ b/frontend/src/hooks/useResizablePanel.ts @@ -22,7 +22,7 @@ export function useResizablePanel( options: UseResizablePanelOptions = {} ) { const { minWidth = 250, maxWidthConstraint } = options - + const panelRef = useRef(null) const handleRef = useRef(defaultWidth) const moveRef = useRef(0) @@ -33,11 +33,11 @@ export function useResizablePanel( const fullWidth = containerRef?.current?.offsetWidth || 1000 handleRef.current += event.clientX - moveRef.current moveRef.current = event.clientX - - const maxConstraint = maxWidthConstraint !== undefined - ? maxWidthConstraint + + const maxConstraint = maxWidthConstraint !== undefined + ? maxWidthConstraint : fullWidth - minWidth - + if (handleRef.current > minWidth && handleRef.current < maxConstraint) { setWidth(handleRef.current) } diff --git a/frontend/src/models/adminPartners.ts b/frontend/src/models/adminPartners.ts index 2b2ebe9ba..ec00fde05 100644 --- a/frontend/src/models/adminPartners.ts +++ b/frontend/src/models/adminPartners.ts @@ -101,7 +101,7 @@ export const adminPartners = createModel()({ 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 }) diff --git a/frontend/src/models/adminUsers.ts b/frontend/src/models/adminUsers.ts index d1a790eef..5d0f1ac0c 100644 --- a/frontend/src/models/adminUsers.ts +++ b/frontend/src/models/adminUsers.ts @@ -110,7 +110,7 @@ export const adminUsers = createModel()({ users, total: data.total || 0 }) - + // Cache user details for users in the list users.forEach((user: AdminUser) => { dispatch.adminUsers.cacheUserDetail({ userId: user.id, user }) diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts index 207ffe26e..3404d07f0 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' @@ -238,6 +239,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/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx index fba490e5b..06f6cc2ab 100644 --- a/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnerDetailPanel.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react' import { useParams, useHistory } from 'react-router-dom' import { useDispatch } from 'react-redux' -import { +import { Typography, List, ListItem, ListItemText, ListItemButton, ListItemIcon, Box, Divider, Button, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Select, MenuItem, FormControl, InputLabel, IconButton as MuiIconButton @@ -13,9 +13,9 @@ import { Body } from '../../components/Body' import { IconButton } from '../../buttons/IconButton' import { CopyIconButton } from '../../buttons/CopyIconButton' import { LoadingMessage } from '../../components/LoadingMessage' -import { +import { graphQLAdminPartners, - graphQLAddPartnerAdmin, + graphQLAddPartnerAdmin, graphQLRemovePartnerAdmin, graphQLAddPartnerRegistrant, graphQLRemovePartnerRegistrant, @@ -80,11 +80,11 @@ export const AdminPartnerDetailPanel: React.FC = () => { const handleAddAdmin = async () => { if (!newAdminEmail) return - + setAddingAdmin(true) const result = await graphQLAddPartnerAdmin(partnerId, newAdminEmail) setAddingAdmin(false) - + if (result !== 'ERROR') { setAddAdminDialogOpen(false) setNewAdminEmail('') @@ -96,11 +96,11 @@ export const AdminPartnerDetailPanel: React.FC = () => { 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 { @@ -110,11 +110,11 @@ export const AdminPartnerDetailPanel: React.FC = () => { const handleAddRegistrant = async () => { if (!newRegistrantEmail) return - + setAddingRegistrant(true) const result = await graphQLAddPartnerRegistrant(partnerId, newRegistrantEmail) setAddingRegistrant(false) - + if (result !== 'ERROR') { setAddRegistrantDialogOpen(false) setNewRegistrantEmail('') @@ -126,11 +126,11 @@ export const AdminPartnerDetailPanel: React.FC = () => { 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 { @@ -145,8 +145,8 @@ export const AdminPartnerDetailPanel: React.FC = () => { 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 && + const filtered = result.data.data.admin.partners.filter((p: any) => + p.id !== partnerId && !childIds.includes(p.id) && p.id !== partner.parent?.id ) @@ -156,11 +156,11 @@ export const AdminPartnerDetailPanel: React.FC = () => { const handleAddChild = async () => { if (!selectedChildId) return - + setAddingChild(true) const result = await graphQLAddPartnerChild(partnerId, selectedChildId) setAddingChild(false) - + if (result !== 'ERROR') { setAddChildDialogOpen(false) setSelectedChildId('') @@ -172,11 +172,11 @@ export const AdminPartnerDetailPanel: React.FC = () => { 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 { @@ -186,16 +186,16 @@ export const AdminPartnerDetailPanel: React.FC = () => { const handleDeletePartner = async () => { const childCount = children.length - const message = childCount > 0 + 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 { @@ -207,7 +207,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { setExporting(true) const result = await graphQLExportPartnerDevices(partnerId) setExporting(false) - + if (result !== 'ERROR' && result?.data?.data?.exportPartnerDevices) { const url = result.data.data.exportPartnerDevices windowOpen(url) @@ -435,7 +435,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { - @@ -482,7 +482,7 @@ export const AdminPartnerDetailPanel: React.FC = () => { - diff --git a/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx b/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx index 7d595eef7..5a9a038fc 100644 --- a/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx +++ b/frontend/src/pages/AdminPartnersPage/AdminPartnersListPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useMemo } from 'react' import { useHistory, useLocation } from 'react-router-dom' import { useDispatch, useSelector } from 'react-redux' -import { +import { Typography, Box, TextField, InputAdornment, Stack, Button, Dialog, DialogTitle, DialogContent, DialogActions, Select, MenuItem, FormControl, InputLabel } from '@mui/material' @@ -88,7 +88,7 @@ export const AdminPartnersListPage: React.FC = () => { const filteredPartners = useMemo(() => { if (!searchValue.trim()) return partners const search = searchValue.toLowerCase() - return partners.filter(partner => + return partners.filter(partner => partner.name?.toLowerCase().includes(search) || partner.id?.toLowerCase().includes(search) ) @@ -101,11 +101,11 @@ export const AdminPartnersListPage: React.FC = () => { 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('') diff --git a/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx index d7673522d..b1cb581a5 100644 --- a/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx +++ b/frontend/src/pages/AdminUsersPage/AdminUsersListPage.tsx @@ -102,36 +102,36 @@ export const AdminUsersListPage: React.FC = () => { bodyProps={{ verticalOverflow: true, horizontalOverflow: true }} header={ - - - - - - - - - - - - - , - }} - /> - - + + + + + + + + + + + + + , + }} + /> + + } > {loading ? ( diff --git a/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx b/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx index 418329f4b..f3b5e53a0 100644 --- a/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx +++ b/frontend/src/pages/PartnerStatsPage/PartnerStatsDetailPanel.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useMemo } from 'react' import { useParams, useHistory } from 'react-router-dom' import { useSelector, useDispatch } from 'react-redux' -import { +import { Typography, List, ListItem, ListItemText, ListItemButton, ListItemIcon, Box, Divider, Button } from '@mui/material' import { Container } from '../../components/Container' @@ -45,7 +45,7 @@ export const PartnerStatsDetailPanel: React.FC = () => { setExporting(true) const result = await graphQLExportPartnerDevices(partnerId) setExporting(false) - + if (result !== 'ERROR' && result?.data?.data?.exportPartnerDevices) { const url = result.data.data.exportPartnerDevices windowOpen(url) @@ -220,7 +220,7 @@ export const PartnerStatsDetailPanel: React.FC = () => { - @@ -247,7 +247,7 @@ export const PartnerStatsDetailPanel: React.FC = () => { - 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..bb5edcc96 100644 --- a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx @@ -17,12 +17,14 @@ 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' import { spacing } from '../../styling' import { dispatch } from '../../store' import { getProductModel } from '../../selectors/products' +import { platforms } from '../../platforms' type Props = { showBack?: boolean @@ -44,6 +46,30 @@ export const ProductSettingsPage: React.FC = ({ showBack = true }) => { const isLocked = product?.status === 'LOCKED' + // Generate registration command from platform template + const getRegistrationCommand = (): string | undefined => { + if (!product?.registrationCode || !isLocked) { + return undefined + } + + const platform = platforms.get(product.platform?.id) + const installationCommand = platform?.installation?.command + + // No installation command, boolean true, or '[CODE]' means show code only (no command) + if (!installationCommand || installationCommand === true || installationCommand === '[CODE]') { + return undefined + } + + // String template - replace [CODE] with actual registration code + if (typeof installationCommand === 'string') { + return installationCommand.replace('[CODE]', product.registrationCode) + } + + return undefined + } + + const registrationCommand = getRegistrationCommand() + const handleLockToggle = async () => { if (!product || isLocked) return setUpdating(true) @@ -94,6 +120,24 @@ 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 @@ -178,6 +222,28 @@ export const ProductSettingsPage: React.FC = ({ 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 de6f57c1d..53455e505 100644 --- a/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsWithDetailPage.tsx @@ -8,6 +8,7 @@ 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' @@ -21,10 +22,10 @@ const DEFAULT_RIGHT_WIDTH = 350 export const ProductsWithDetailPage: React.FC = () => { const { productId } = useParams<{ productId: string }>() const css = useStyles() - + // Get layout from Redux for singlePanel breakpoint (750px) const layout = useSelector((state: State) => state.ui.layout) - + const { containerRef, containerWidth } = useContainerWidth() const leftPanel = useResizablePanel(DEFAULT_LEFT_WIDTH, containerRef, { minWidth: MIN_WIDTH, @@ -54,14 +55,14 @@ export const ProductsWithDetailPage: React.FC = () => { {/* Left Panel - Products List */} {showLeft && ( <> - - + {/* Left Divider */} @@ -70,14 +71,14 @@ export const ProductsWithDetailPage: React.FC = () => { )} - + {/* Middle Panel - Product Details */} {showMiddle && ( <> - + {/* Right Divider */} @@ -86,17 +87,20 @@ export const ProductsWithDetailPage: React.FC = () => { )} - + {/* 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/services/graphQLDeviceProducts.ts b/frontend/src/services/graphQLDeviceProducts.ts index ecf05f699..df44a51bd 100644 --- a/frontend/src/services/graphQLDeviceProducts.ts +++ b/frontend/src/services/graphQLDeviceProducts.ts @@ -171,3 +171,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 } + ) +} From 962ef97d987c1e4f14d1e1e93103a2400f263fbd Mon Sep 17 00:00:00 2001 From: Evan Bowers Date: Wed, 28 Jan 2026 14:15:58 -0800 Subject: [PATCH 21/22] fix: fix for registration command and spacing for lock layout --- frontend/src/models/products.ts | 9 +++--- .../ProductsPage/ProductSettingsPage.tsx | 29 ++----------------- .../src/services/graphQLDeviceProducts.ts | 2 ++ 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/frontend/src/models/products.ts b/frontend/src/models/products.ts index 3404d07f0..60a6b9a89 100644 --- a/frontend/src/models/products.ts +++ b/frontend/src/models/products.ts @@ -28,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[] @@ -151,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: [], @@ -243,12 +244,12 @@ export default createModel()({ 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({ @@ -262,7 +263,7 @@ export default createModel()({ dispatch.ui.set({ transferring: false }) return true } - + dispatch.ui.set({ transferring: false }) return false }, diff --git a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx index bb5edcc96..1dae63f8d 100644 --- a/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductSettingsPage.tsx @@ -24,7 +24,6 @@ import { Confirm } from '../../components/Confirm' import { spacing } from '../../styling' import { dispatch } from '../../store' import { getProductModel } from '../../selectors/products' -import { platforms } from '../../platforms' type Props = { showBack?: boolean @@ -45,30 +44,7 @@ export const ProductSettingsPage: React.FC = ({ showBack = true }) => { const [deleting, setDeleting] = useState(false) const isLocked = product?.status === 'LOCKED' - - // Generate registration command from platform template - const getRegistrationCommand = (): string | undefined => { - if (!product?.registrationCode || !isLocked) { - return undefined - } - - const platform = platforms.get(product.platform?.id) - const installationCommand = platform?.installation?.command - - // No installation command, boolean true, or '[CODE]' means show code only (no command) - if (!installationCommand || installationCommand === true || installationCommand === '[CODE]') { - return undefined - } - - // String template - replace [CODE] with actual registration code - if (typeof installationCommand === 'string') { - return installationCommand.replace('[CODE]', product.registrationCode) - } - - return undefined - } - - const registrationCommand = getRegistrationCommand() + const registrationCommand = product?.registrationCommand const handleLockToggle = async () => { if (!product || isLocked) return @@ -133,7 +109,6 @@ export const ProductSettingsPage: React.FC = ({ showBack = true }) => {
)} @@ -202,7 +177,7 @@ export const ProductSettingsPage: React.FC = ({ showBack = true }) => { Product Settings - + Date: Wed, 28 Jan 2026 14:25:10 -0800 Subject: [PATCH 22/22] feat: remove link to registrations --- frontend/src/components/SidebarNav.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/frontend/src/components/SidebarNav.tsx b/frontend/src/components/SidebarNav.tsx index 6a1ba312b..4d07fbb65 100644 --- a/frontend/src/components/SidebarNav.tsx +++ b/frontend/src/components/SidebarNav.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React from 'react' import browser from '../services/browser' import { makeStyles } from '@mui/styles' import { MOBILE_WIDTH } from '../constants' @@ -11,11 +11,8 @@ import { Box, Badge, List, - ListItemButton, Divider, - Typography, Tooltip, - Collapse, Chip, useMediaQuery, } from '@mui/material' @@ -23,14 +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) @@ -129,15 +124,6 @@ export const SidebarNav: React.FC = () => { )} - setMore(!more)} sx={{ marginTop: 2 }}> - - More - - - - - -