diff --git a/frontend/src/components/DeviceOptionMenu.tsx b/frontend/src/components/DeviceOptionMenu.tsx index 85508cccc..21dbe2ac6 100644 --- a/frontend/src/components/DeviceOptionMenu.tsx +++ b/frontend/src/components/DeviceOptionMenu.tsx @@ -1,10 +1,11 @@ import React, { useState } from 'react' import { PROTOCOL } from '../constants' import { Dispatch } from '../store' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { Link, useParams, useHistory } from 'react-router-dom' -import { Divider, Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material' +import { Divider, Menu, MenuItem, ListItemIcon, ListItemText, Tooltip } from '@mui/material' +import { selectPermissions } from '../selectors/organizations' import { DeleteServiceMenuItem } from '../buttons/DeleteServiceMenuItem' import { ListItemLocation } from './ListItemLocation' import { CopyMenuItem } from './CopyMenuItem' @@ -24,6 +25,7 @@ export const DeviceOptionMenu: React.FC = ({ device, service }) => { const handleClick = event => setAnchorEl(event.currentTarget) const handleClose = () => setAnchorEl(null) const dispatch = useDispatch() + const admin = useSelector(selectPermissions).includes('ADMIN') const { t } = useTranslation() const devicesSection = !!deviceID const deviceOnly = device && !service @@ -45,6 +47,17 @@ export const DeviceOptionMenu: React.FC = ({ device, service }) => { if (product) history.push(`/products/${product.id}/details`) } + // Only wrapped in a tooltip when disabled — MenuList arrow-key traversal skips a + // span-wrapped item, so an enabled one has to stay a direct child of the Menu. + const makeProductItem = ( + + + + + + + ) + return ( <> {!devicesSection && } @@ -123,12 +136,18 @@ export const DeviceOptionMenu: React.FC = ({ device, service }) => { , - - - - - - , + admin ? ( + makeProductItem + ) : ( + + {makeProductItem} + + ), ]} {device.permissions.includes('MANAGE') && devicesSection && @@ -140,7 +159,13 @@ export const DeviceOptionMenu: React.FC = ({ device, service }) => { service && devicesSection && [ , - , + , ]} diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 83e63292d..85ab21f08 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -133,7 +133,7 @@ export const Header: React.FC = ({ panels = 1 }) => { )} - + diff --git a/frontend/src/components/Header/ProductsHeaderButtons.tsx b/frontend/src/components/Header/ProductsHeaderButtons.tsx index c31061c94..67e99e07f 100644 --- a/frontend/src/components/Header/ProductsHeaderButtons.tsx +++ b/frontend/src/components/Header/ProductsHeaderButtons.tsx @@ -1,46 +1,48 @@ import React from 'react' import { useHistory, useLocation } from 'react-router-dom' -import { Button } from '@mui/material' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { Button, Tooltip } from '@mui/material' import { IconButton } from '../../buttons/IconButton' +import { selectPermissions } from '../../selectors/organizations' +import { getHasProducts } from '../../selectors/products' 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 hasProducts = useSelector(getHasProducts) + const permissions = useSelector(selectPermissions) + const { t } = useTranslation() - 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}` : ''}`) - } + const admin = permissions.includes('ADMIN') + const isSelectMode = location.pathname === '/products/select' + const adminRequired = admin ? '' : t('productsHeaderButtons.adminRequired', 'Admin permissions required') return ( <> - + + + + + ) } - diff --git a/frontend/src/components/ProductOptionMenu.tsx b/frontend/src/components/ProductOptionMenu.tsx index 7c77415f3..c4a503350 100644 --- a/frontend/src/components/ProductOptionMenu.tsx +++ b/frontend/src/components/ProductOptionMenu.tsx @@ -1,9 +1,11 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { Link, useHistory } from 'react-router-dom' -import { Divider, ListItemIcon, ListItemText, Menu, MenuItem, Typography } from '@mui/material' +import { useSelector } from 'react-redux' +import { Divider, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip, Typography } from '@mui/material' import { IDeviceProduct } from '../models/products' import { IconButton } from '../buttons/IconButton' +import { selectPermissions } from '../selectors/organizations' import { Icon } from './Icon' import { DeleteButton } from '../buttons/DeleteButton' import { Notice } from './Notice' @@ -15,11 +17,37 @@ export const ProductOptionMenu: React.FC = ({ product }) => { const { t } = useTranslation() const [anchorEl, setAnchorEl] = useState(null) const history = useHistory() + const admin = useSelector(selectPermissions).includes('ADMIN') const handleClick = (event: React.MouseEvent) => setAnchorEl(event.currentTarget as HTMLButtonElement) const handleClose = () => setAnchorEl(null) if (!product) return null + const deleteButton = ( + { + handleClose() + const success = await dispatch.products.delete(product.id) + if (success) history.push('/products') + }} + warning={ + <> + + {t('productOptionMenu.cannotBeUndone', 'This action cannot be undone.')} + + + {t('productOptionMenu.deleteConfirmPrefix', 'Are you sure you want to permanently delete the product')}{' '} + {product.name} {t('productOptionMenu.deleteConfirmSuffix', 'and all its services?')} + + + } + /> + ) + return ( <> @@ -40,27 +68,13 @@ export const ProductOptionMenu: React.FC = ({ product }) => { - { - handleClose() - const success = await dispatch.products.delete(product.id) - if (success) history.push('/products') - }} - warning={ - <> - - {t('productOptionMenu.cannotBeUndone', 'This action cannot be undone.')} - - - {t('productOptionMenu.deleteConfirmPrefix', 'Are you sure you want to permanently delete the product')}{' '} - {product.name} {t('productOptionMenu.deleteConfirmSuffix', 'and all its services?')} - - - } - /> + {admin ? ( + deleteButton + ) : ( + + {deleteButton} + + )} ) diff --git a/frontend/src/components/ProductsActionBar.tsx b/frontend/src/components/ProductsActionBar.tsx index 3a1e5703e..a416948eb 100644 --- a/frontend/src/components/ProductsActionBar.tsx +++ b/frontend/src/components/ProductsActionBar.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react' import { MOBILE_WIDTH } from '../constants' import { useMediaQuery, Box, Typography, Collapse } from '@mui/material' import { useSelector } from 'react-redux' -import { useHistory, useLocation } from 'react-router-dom' +import { useHistory } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { ConfirmIconButton } from '../buttons/ConfirmIconButton' import { IconButton } from '../buttons/IconButton' @@ -12,6 +12,7 @@ import { Title } from './Title' import { Icon } from './Icon' import { spacing, radius } from '../styling' import { getProductsSelected } from '../selectors/products' +import { selectPermissions } from '../selectors/organizations' type Props = { select?: boolean @@ -19,18 +20,13 @@ type Props = { export const ProductsActionBar: React.FC = ({ select }) => { const selected = useSelector(getProductsSelected) + const admin = useSelector(selectPermissions).includes('ADMIN') const [deleting, setDeleting] = useState(false) const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) const history = useHistory() - const location = useLocation() const { t } = useTranslation() - const clearSelectMode = () => { - const newParams = new URLSearchParams(location.search) - newParams.delete('select') - const search = newParams.toString() - history.push(`${location.pathname}${search ? `?${search}` : ''}`) - } + const clearSelectMode = () => history.push('/products') const handleDelete = async () => { setDeleting(true) @@ -73,10 +69,15 @@ export const ProductsActionBar: React.FC = ({ select }) => { = ({ select }) => { ) } - diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 4a4c66587..c08d86288 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -599,6 +599,7 @@ "label": "Gerätename" }, "deviceOptionMenu": { + "adminRequired": "", "deviceDetails": "Gerätedetails", "deviceLink": "Gerätelink", "makeProduct": "Produkt erstellen", @@ -774,6 +775,7 @@ }, "header": { "back": "Zurück", + "create": "", "deviceSearch": "Gerätesuche", "hideSelect": "Auswahl ausblenden", "products": "Produkte", @@ -1424,6 +1426,7 @@ "localPort": "Lokaler Port" }, "productAddPage": { + "adminRequired": "", "createFailed": "Erstellen des Produkts fehlgeschlagen", "creating": "Wird erstellt...", "nameRequired": "Produktname ist erforderlich", @@ -1433,6 +1436,7 @@ "title": "Produkt erstellen" }, "productOptionMenu": { + "adminRequired": "", "cannotBeUndone": "Diese Aktion kann nicht rückgängig gemacht werden.", "deleteConfirmPrefix": "Möchten Sie das Produkt wirklich dauerhaft löschen", "deleteConfirmSuffix": "und alle zugehörigen Dienste?", @@ -1450,6 +1454,7 @@ "unknown": "Unbekannt" }, "productsActionBar": { + "adminRequired": "", "clearSelection": "Auswahl aufheben", "confirmAction_one": "{{count}} Produkt löschen", "confirmAction_other": "{{count}} Produkte löschen", @@ -1459,6 +1464,15 @@ "deleteSelected": "Auswahl löschen", "selected": "Ausgewählt" }, + "productsHeaderButtons": { + "adminRequired": "" + }, + "productsPage": { + "createFirst": "", + "emptyHelp": "", + "emptyHelpReadOnly": "", + "loading": "" + }, "productServiceAddPage": { "failedToAddService": "Dienst konnte nicht hinzugefügt werden", "newService": "Neuer Dienst", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 68681196f..f57d6be6b 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -599,6 +599,7 @@ "label": "Device Name" }, "deviceOptionMenu": { + "adminRequired": "Admin permissions required", "deviceDetails": "Device Details", "deviceLink": "Device Link", "makeProduct": "Make Product", @@ -790,6 +791,7 @@ }, "header": { "back": "Back", + "create": "Create", "deviceSearch": "Device Search", "hideSelect": "Hide Select", "products": "Products", @@ -1470,6 +1472,7 @@ "localPort": "Local Port" }, "productAddPage": { + "adminRequired": "You must have the admin permission to create a product.", "createFailed": "Failed to create product", "creating": "Creating...", "nameRequired": "Product name is required", @@ -1479,6 +1482,7 @@ "title": "Create Product" }, "productOptionMenu": { + "adminRequired": "Admin permissions required", "cannotBeUndone": "This action cannot be undone.", "deleteConfirmPrefix": "Are you sure you want to permanently delete the product", "deleteConfirmSuffix": "and all its services?", @@ -1496,6 +1500,7 @@ "unknown": "Unknown" }, "productsActionBar": { + "adminRequired": "Admin permissions required", "clearSelection": "Clear selection", "confirmAction_one": "Delete {{count}} product", "confirmAction_other": "Delete {{count}} products", @@ -1505,6 +1510,15 @@ "deleteSelected": "Delete selected", "selected": "Selected" }, + "productsHeaderButtons": { + "adminRequired": "Admin permissions required" + }, + "productsPage": { + "createFirst": "Create your first product", + "emptyHelp": "Products are used for bulk device registration and management.", + "emptyHelpReadOnly": "Products are used for bulk device registration and management. You must have the admin permission to create one.", + "loading": "Loading products..." + }, "productServiceAddPage": { "failedToAddService": "Failed to add service", "newService": "New service", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index be0ca45fd..33c74547e 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -605,6 +605,7 @@ "label": "Nombre del dispositivo" }, "deviceOptionMenu": { + "adminRequired": "", "deviceDetails": "Detalles del dispositivo", "deviceLink": "Enlace del dispositivo", "makeProduct": "Crear producto", @@ -783,6 +784,7 @@ }, "header": { "back": "Atrás", + "create": "", "deviceSearch": "Búsqueda de dispositivos", "hideSelect": "Ocultar selección", "products": "Productos", @@ -1444,6 +1446,7 @@ "localPort": "Puerto local" }, "productAddPage": { + "adminRequired": "", "createFailed": "Error al crear el producto", "creating": "Creando...", "nameRequired": "El nombre del producto es obligatorio", @@ -1453,6 +1456,7 @@ "title": "Crear producto" }, "productOptionMenu": { + "adminRequired": "", "cannotBeUndone": "Esta acción no se puede deshacer.", "deleteConfirmPrefix": "¿Seguro que quieres eliminar permanentemente el producto", "deleteConfirmSuffix": "y todos sus servicios?", @@ -1470,6 +1474,7 @@ "unknown": "Desconocido" }, "productsActionBar": { + "adminRequired": "", "clearSelection": "Borrar selección", "confirmAction_one": "Eliminar {{count}} producto", "confirmAction_many": "Eliminar {{count}} productos", @@ -1481,6 +1486,15 @@ "deleteSelected": "Eliminar selección", "selected": "Seleccionado" }, + "productsHeaderButtons": { + "adminRequired": "" + }, + "productsPage": { + "createFirst": "", + "emptyHelp": "", + "emptyHelpReadOnly": "", + "loading": "" + }, "productServiceAddPage": { "failedToAddService": "Error al añadir el servicio", "newService": "Nuevo servicio", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 1b3edcee8..de56b6f7b 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -593,6 +593,7 @@ "label": "デバイス名" }, "deviceOptionMenu": { + "adminRequired": "", "deviceDetails": "デバイスの詳細", "deviceLink": "デバイスリンク", "makeProduct": "プロダクトを作成", @@ -765,6 +766,7 @@ }, "header": { "back": "戻る", + "create": "", "deviceSearch": "デバイス検索", "hideSelect": "選択を隠す", "products": "プロダクト", @@ -1404,6 +1406,7 @@ "localPort": "ローカルポート" }, "productAddPage": { + "adminRequired": "", "createFailed": "プロダクトの作成に失敗しました", "creating": "作成中...", "nameRequired": "プロダクト名は必須です", @@ -1413,6 +1416,7 @@ "title": "プロダクトを作成" }, "productOptionMenu": { + "adminRequired": "", "cannotBeUndone": "この操作は元に戻せません。", "deleteConfirmPrefix": "次のプロダクト", "deleteConfirmSuffix": "とそのすべてのサービスを完全に削除してもよろしいですか?", @@ -1430,6 +1434,7 @@ "unknown": "不明" }, "productsActionBar": { + "adminRequired": "", "clearSelection": "選択を解除", "confirmAction_other": "{{count}}件のプロダクトを削除", "confirmBody_other": "{{count}}件のプロダクトを削除してもよろしいですか?", @@ -1437,6 +1442,15 @@ "deleteSelected": "選択項目を削除", "selected": "選択済み" }, + "productsHeaderButtons": { + "adminRequired": "" + }, + "productsPage": { + "createFirst": "", + "emptyHelp": "", + "emptyHelpReadOnly": "", + "loading": "" + }, "productServiceAddPage": { "failedToAddService": "サービスの追加に失敗しました", "newService": "新しいサービス", diff --git a/frontend/src/pages/ProductsPage/ProductAddPage.tsx b/frontend/src/pages/ProductsPage/ProductAddPage.tsx index d49732297..1bf925dc6 100644 --- a/frontend/src/pages/ProductsPage/ProductAddPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductAddPage.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' import { Typography, Button, TextField, FormControl, InputLabel, Select, MenuItem } from '@mui/material' import { Container } from '../../components/Container' import { Title } from '../../components/Title' @@ -10,6 +11,8 @@ import { Gutters } from '../../components/Gutters' import { dispatch } from '../../store' import { graphQLPlatformTypes } from '../../services/graphQLDeviceProducts' import { graphQLGetErrors } from '../../services/graphQL' +import { selectPermissions } from '../../selectors/organizations' +import { byName } from '../../helpers/utilHelper' interface IPlatformType { id: number @@ -20,6 +23,7 @@ interface IPlatformType { export const ProductAddPage: React.FC = () => { const { t } = useTranslation() const history = useHistory() + const admin = useSelector(selectPermissions).includes('ADMIN') const [name, setName] = useState('') const [platform, setPlatform] = useState('') const [platformTypes, setPlatformTypes] = useState([]) @@ -30,13 +34,15 @@ export const ProductAddPage: React.FC = () => { const fetchPlatforms = async () => { const response = await graphQLPlatformTypes() if (response !== 'ERROR' && !graphQLGetErrors(response)) { - setPlatformTypes(response?.data?.data?.platformTypes || []) + const types: IPlatformType[] = response?.data?.data?.platformTypes || [] + setPlatformTypes(types.filter(p => p.visible).sort(byName)) } } fetchPlatforms() }, []) - const handleCreate = async () => { + const handleCreate = async (event: React.FormEvent) => { + event.preventDefault() if (!name.trim()) { setError(t('productAddPage.nameRequired', 'Product name is required')) return @@ -74,57 +80,65 @@ export const ProductAddPage: React.FC = () => { } > + {!admin && ( + + {t('productAddPage.adminRequired', 'You must have the admin permission to create a product.')} + + )} {error && ( {error} )} - setName(e.target.value)} - fullWidth - required - autoFocus - margin="normal" - disabled={creating} - /> + {/* noValidate so the checks in handleCreate report through the Notice above — + the Select's native input is visually hidden, so browser validation on it + blocks submit and anchors its bubble to an invisible control. */} +
+ setName(e.target.value)} + fullWidth + required + autoFocus + margin="normal" + disabled={!admin || creating} + /> - - {t('productAddPage.platform', 'Platform')} - setPlatform(e.target.value)} + label={t('productAddPage.platform', 'Platform')} + disabled={!admin || creating || platformTypes.length === 0} + > + {platformTypes.map(p => ( {p.name} ))} - - + + - - - - + + + + +
) diff --git a/frontend/src/pages/ProductsPage/ProductsPage.tsx b/frontend/src/pages/ProductsPage/ProductsPage.tsx index 2a8b2ff1c..72d6ca1bd 100644 --- a/frontend/src/pages/ProductsPage/ProductsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsPage.tsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react' -import { useHistory, useLocation, useParams } from 'react-router-dom' +import { useHistory, useParams } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { Typography, Button, Box } from '@mui/material' import { Container } from '../../components/Container' @@ -12,13 +13,13 @@ import { productAttributes } from '../../components/ProductAttributes' import { removeObject } from '../../helpers/utilHelper' import { dispatch, State } from '../../store' import { getProductModel } from '../../selectors/products' +import { selectPermissions } from '../../selectors/organizations' -export const ProductsPage: React.FC = () => { +export const ProductsPage: React.FC<{ select?: boolean }> = ({ select }) => { const history = useHistory() - const location = useLocation() + const { t } = useTranslation() const { productId } = useParams<{ productId?: string }>() - const searchParams = new URLSearchParams(location.search) - const select = searchParams.get('select') === 'true' + const admin = useSelector(selectPermissions).includes('ADMIN') const productModel = useSelector(getProductModel) const products = productModel.all || [] const fetching = productModel.fetching || false @@ -58,25 +59,23 @@ export const ProductsPage: React.FC = () => { bodyProps={{ verticalOverflow: true, horizontalOverflow: true }} > {fetching && !initialized ? ( - + ) : products.length === 0 ? ( - - - No products + {admin && ( + + )} + + {admin + ? t('productsPage.emptyHelp', 'Products are used for bulk device registration and management.') + : t( + 'productsPage.emptyHelpReadOnly', + 'Products are used for bulk device registration and management. You must have the admin permission to create one.' + )} - - Products are used for bulk device registration and management. - - ) : ( = ({ layout }) => { const location = useLocation() const locationParts = location.pathname.split('/') - // Use single panel mode for base /products route - if (locationParts[2] === undefined) { + // Use single panel mode for the products list, in or out of select mode + if (locationParts[2] === undefined || locationParts[2] === 'select') { layout = { ...layout, singlePanel: true } } @@ -52,6 +52,12 @@ export const ProductsRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { layout={layout} />
+ {/* Products list in select mode — must precede /products/:productId so "select" isn't read as an id */} + + + + + {/* Product detail: primary=product overview, secondary=service/settings detail, tertiary=products list (triple only) */} getProductModelFn(products, activeAccountId).all || [] ) +export const getHasProducts = createSelector([getProducts], products => products.length > 0) + export const getProductsFetching = createSelector( [getProductModel], productModel => productModel.fetching