From 3d4f70b7be519863c5d6d08b54b8bbf6d7156bcf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 21:05:52 +0000 Subject: [PATCH 1/6] fix(products): polish the product list, empty state and create form - Sort the platform dropdown on create product alphabetically, through the shared byName comparator rather than the API's id order. - Only show the select toggle in the products header when the account has products, so the empty state isn't offering a selection mode for nothing. - Use the conveyor-belt-boxes icon on the "No products" empty state, matching the products entry in the sidebar nav instead of an unrelated open box. - Give the create button's spinner a right margin. It used `inline`, which sets margin-left, so the spinner sat flush against the "Creating..." label with a gap on the wrong side. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018YfRBMvuTVpXniu6MNi5jo --- .../Header/ProductsHeaderButtons.tsx | 21 ++++++++++++------- .../src/pages/ProductsPage/ProductAddPage.tsx | 4 +++- .../src/pages/ProductsPage/ProductsPage.tsx | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Header/ProductsHeaderButtons.tsx b/frontend/src/components/Header/ProductsHeaderButtons.tsx index c31061c94..44ed6dd9b 100644 --- a/frontend/src/components/Header/ProductsHeaderButtons.tsx +++ b/frontend/src/components/Header/ProductsHeaderButtons.tsx @@ -1,13 +1,16 @@ import React from 'react' import { useHistory, useLocation } from 'react-router-dom' +import { useSelector } from 'react-redux' import { Button } from '@mui/material' import { IconButton } from '../../buttons/IconButton' +import { getProducts } from '../../selectors/products' import { Icon } from '../Icon' export const ProductsHeaderButtons: React.FC = () => { const history = useHistory() const location = useLocation() - + const hasProducts = useSelector(getProducts).length > 0 + const searchParams = new URLSearchParams(location.search) const isSelectMode = searchParams.get('select') === 'true' @@ -24,13 +27,15 @@ export const ProductsHeaderButtons: React.FC = () => { return ( <> - + {hasProducts && ( + + )} ) diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 4a4c66587..5cb2d381c 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -774,6 +774,7 @@ }, "header": { "back": "Zurück", + "create": "", "deviceSearch": "Gerätesuche", "hideSelect": "Auswahl ausblenden", "products": "Produkte", @@ -1459,6 +1460,11 @@ "deleteSelected": "Auswahl löschen", "selected": "Ausgewählt" }, + "productsPage": { + "createFirst": "", + "emptyHelp": "", + "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..2c1e3bf24 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -790,6 +790,7 @@ }, "header": { "back": "Back", + "create": "Create", "deviceSearch": "Device Search", "hideSelect": "Hide Select", "products": "Products", @@ -1505,6 +1506,11 @@ "deleteSelected": "Delete selected", "selected": "Selected" }, + "productsPage": { + "createFirst": "Create your first product", + "emptyHelp": "Products are used for bulk device registration and management.", + "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..82de0ab5b 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -783,6 +783,7 @@ }, "header": { "back": "Atrás", + "create": "", "deviceSearch": "Búsqueda de dispositivos", "hideSelect": "Ocultar selección", "products": "Productos", @@ -1481,6 +1482,11 @@ "deleteSelected": "Eliminar selección", "selected": "Seleccionado" }, + "productsPage": { + "createFirst": "", + "emptyHelp": "", + "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..c5372ffef 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -765,6 +765,7 @@ }, "header": { "back": "戻る", + "create": "", "deviceSearch": "デバイス検索", "hideSelect": "選択を隠す", "products": "プロダクト", @@ -1437,6 +1438,11 @@ "deleteSelected": "選択項目を削除", "selected": "選択済み" }, + "productsPage": { + "createFirst": "", + "emptyHelp": "", + "loading": "" + }, "productServiceAddPage": { "failedToAddService": "サービスの追加に失敗しました", "newService": "新しいサービス", diff --git a/frontend/src/pages/ProductsPage/ProductAddPage.tsx b/frontend/src/pages/ProductsPage/ProductAddPage.tsx index 0ac4e9c32..08b9c56a1 100644 --- a/frontend/src/pages/ProductsPage/ProductAddPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductAddPage.tsx @@ -31,7 +31,8 @@ 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() @@ -101,14 +102,11 @@ export const ProductAddPage: React.FC = () => { label={t('productAddPage.platform', 'Platform')} disabled={creating || platformTypes.length === 0} > - {platformTypes - .filter(p => p.visible) - .sort(byName) - .map(p => ( - - {p.name} - - ))} + {platformTypes.map(p => ( + + {p.name} + + ))} diff --git a/frontend/src/pages/ProductsPage/ProductsPage.tsx b/frontend/src/pages/ProductsPage/ProductsPage.tsx index 0e613c8b7..93337924d 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 { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { Typography, Button, Box } from '@mui/material' import { Container } from '../../components/Container' @@ -16,6 +17,7 @@ import { getProductModel } from '../../selectors/products' export const ProductsPage: React.FC = () => { 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' @@ -58,25 +60,15 @@ export const ProductsPage: React.FC = () => { bodyProps={{ verticalOverflow: true, horizontalOverflow: true }} > {fetching && !initialized ? ( - + ) : products.length === 0 ? ( - - - No products - - - Products are used for bulk device registration and management. - - + + {t('productsPage.emptyHelp', 'Products are used for bulk device registration and management.')} + ) : ( getProductModelFn(products, activeAccountId).all || [] ) +export const getHasProducts = createSelector([getProducts], products => products.length > 0) + export const getProductsFetching = createSelector( [getProductModel], productModel => productModel.fetching From 9020ed688796a92e3585df873b43cd0a14beee57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 22:09:58 +0000 Subject: [PATCH 3/6] feat(products): gate creation on admin, submit via form, route select mode Creating a product requires the admin permission on the API, but the UI offered it to anyone. An org account manager could fill in the form and submit, then get an API error. Gate the affordances on ADMIN the way the scripting pages already do: - Header create button is disabled with an "Admin permissions required" tooltip, matching ScriptsListHeader. - The add page shows a notice and disables its inputs, so a direct link to /products/add explains itself rather than failing on submit. - The empty state hides its create button and says why. - The select toggle is hidden without admin, since it exists to reach bulk delete. The create form is now a real form element, so enter submits it. The submit button carries type="submit" and cancel is explicitly type="button". Select mode moves from a ?select=true query param to a /products/select route, matching /devices/select. The route passes `select` to the page as a prop instead of the page parsing the URL, which is how DevicesPage receives it. The header buttons and action bar navigate between the two routes, so the three copies of the query-param logic are gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018YfRBMvuTVpXniu6MNi5jo --- frontend/src/components/Header/Header.tsx | 2 +- .../Header/ProductsHeaderButtons.tsx | 41 ++++---- frontend/src/components/ProductsActionBar.tsx | 10 +- frontend/src/i18n/locales/de/app.json | 5 + frontend/src/i18n/locales/en/app.json | 5 + frontend/src/i18n/locales/es/app.json | 5 + frontend/src/i18n/locales/ja/app.json | 5 + .../src/pages/ProductsPage/ProductAddPage.tsx | 95 +++++++++++-------- .../src/pages/ProductsPage/ProductsPage.tsx | 25 +++-- frontend/src/routers/ProductsRouter.tsx | 10 +- 10 files changed, 122 insertions(+), 81 deletions(-) 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 a68809871..67e99e07f 100644 --- a/frontend/src/components/Header/ProductsHeaderButtons.tsx +++ b/frontend/src/components/Header/ProductsHeaderButtons.tsx @@ -2,8 +2,9 @@ import React from 'react' import { useHistory, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' -import { Button } from '@mui/material' +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' @@ -11,35 +12,37 @@ export const ProductsHeaderButtons: React.FC = () => { const history = useHistory() const location = useLocation() const hasProducts = useSelector(getHasProducts) + const permissions = useSelector(selectPermissions) const { t } = useTranslation() - const searchParams = new URLSearchParams(location.search) - const isSelectMode = searchParams.get('select') === 'true' - - // Same params with `select` flipped — the destination the toggle navigates to. - isSelectMode ? searchParams.delete('select') : searchParams.set('select', 'true') - const search = searchParams.toString() + 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/ProductsActionBar.tsx b/frontend/src/components/ProductsActionBar.tsx index 3a1e5703e..8566c1f35 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' @@ -22,15 +22,9 @@ export const ProductsActionBar: React.FC = ({ select }) => { 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) diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index 5cb2d381c..09ed02eee 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -1425,6 +1425,7 @@ "localPort": "Lokaler Port" }, "productAddPage": { + "adminRequired": "", "createFailed": "Erstellen des Produkts fehlgeschlagen", "creating": "Wird erstellt...", "nameRequired": "Produktname ist erforderlich", @@ -1460,9 +1461,13 @@ "deleteSelected": "Auswahl löschen", "selected": "Ausgewählt" }, + "productsHeaderButtons": { + "adminRequired": "" + }, "productsPage": { "createFirst": "", "emptyHelp": "", + "emptyHelpReadOnly": "", "loading": "" }, "productServiceAddPage": { diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 2c1e3bf24..8eacf6617 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -1471,6 +1471,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", @@ -1506,9 +1507,13 @@ "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": { diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index 82de0ab5b..f30283bc6 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -1445,6 +1445,7 @@ "localPort": "Puerto local" }, "productAddPage": { + "adminRequired": "", "createFailed": "Error al crear el producto", "creating": "Creando...", "nameRequired": "El nombre del producto es obligatorio", @@ -1482,9 +1483,13 @@ "deleteSelected": "Eliminar selección", "selected": "Seleccionado" }, + "productsHeaderButtons": { + "adminRequired": "" + }, "productsPage": { "createFirst": "", "emptyHelp": "", + "emptyHelpReadOnly": "", "loading": "" }, "productServiceAddPage": { diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index c5372ffef..e58f6f35e 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -1405,6 +1405,7 @@ "localPort": "ローカルポート" }, "productAddPage": { + "adminRequired": "", "createFailed": "プロダクトの作成に失敗しました", "creating": "作成中...", "nameRequired": "プロダクト名は必須です", @@ -1438,9 +1439,13 @@ "deleteSelected": "選択項目を削除", "selected": "選択済み" }, + "productsHeaderButtons": { + "adminRequired": "" + }, "productsPage": { "createFirst": "", "emptyHelp": "", + "emptyHelpReadOnly": "", "loading": "" }, "productServiceAddPage": { diff --git a/frontend/src/pages/ProductsPage/ProductAddPage.tsx b/frontend/src/pages/ProductsPage/ProductAddPage.tsx index 08b9c56a1..66c48f523 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,7 @@ 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 { @@ -21,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([]) @@ -38,7 +41,8 @@ export const ProductAddPage: React.FC = () => { fetchPlatforms() }, []) - const handleCreate = async () => { + const handleCreate = async (event: React.FormEvent) => { + event.preventDefault() if (!name.trim()) { setError(t('productAddPage.nameRequired', 'Product name is required')) return @@ -76,55 +80,62 @@ 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} - /> +
+ setName(e.target.value)} + fullWidth + required + autoFocus + margin="normal" + disabled={!admin || creating} + /> - - {t('productAddPage.platform', 'Platform')} - - + + {t('productAddPage.platform', 'Platform')} + + - - - - + + + + +
) diff --git a/frontend/src/pages/ProductsPage/ProductsPage.tsx b/frontend/src/pages/ProductsPage/ProductsPage.tsx index 93337924d..72d6ca1bd 100644 --- a/frontend/src/pages/ProductsPage/ProductsPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductsPage.tsx @@ -1,5 +1,5 @@ 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' @@ -13,14 +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 @@ -63,11 +62,19 @@ export const ProductsPage: React.FC = () => { ) : products.length === 0 ? ( - + {admin && ( + + )} - {t('productsPage.emptyHelp', 'Products are used for bulk device registration and management.')} + {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.' + )} ) : ( diff --git a/frontend/src/routers/ProductsRouter.tsx b/frontend/src/routers/ProductsRouter.tsx index 7b2688a2d..2062ba6ee 100644 --- a/frontend/src/routers/ProductsRouter.tsx +++ b/frontend/src/routers/ProductsRouter.tsx @@ -37,8 +37,8 @@ export const ProductsRouter: React.FC<{ layout: ILayout }> = ({ 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) */} Date: Tue, 4 Aug 2026 22:17:59 +0000 Subject: [PATCH 4/6] feat(products): require admin to delete a product Deleting a product is at least as privileged as creating one, so gate it the same way. Follows DeleteDevice, which disables the button and wraps it in a tooltip naming the reason only when it is actually disabled. The product option menu's delete is disabled without the admin permission, with an "Admin permissions required" tooltip. Also gate the bulk delete in the products action bar. The select toggle is already hidden without admin, but /products/select can still be reached by typing the URL, which would otherwise leave a delete button that fails at the API. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018YfRBMvuTVpXniu6MNi5jo --- frontend/src/components/ProductOptionMenu.tsx | 58 ++++++++++++------- frontend/src/components/ProductsActionBar.tsx | 11 +++- frontend/src/i18n/locales/de/app.json | 2 + frontend/src/i18n/locales/en/app.json | 2 + frontend/src/i18n/locales/es/app.json | 2 + frontend/src/i18n/locales/ja/app.json | 2 + 6 files changed, 52 insertions(+), 25 deletions(-) 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 8566c1f35..40b2bb8fa 100644 --- a/frontend/src/components/ProductsActionBar.tsx +++ b/frontend/src/components/ProductsActionBar.tsx @@ -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,6 +20,7 @@ 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() @@ -67,10 +69,14 @@ 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 09ed02eee..c06c5eb33 100644 --- a/frontend/src/i18n/locales/de/app.json +++ b/frontend/src/i18n/locales/de/app.json @@ -1435,6 +1435,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?", @@ -1452,6 +1453,7 @@ "unknown": "Unbekannt" }, "productsActionBar": { + "adminRequired": "", "clearSelection": "Auswahl aufheben", "confirmAction_one": "{{count}} Produkt löschen", "confirmAction_other": "{{count}} Produkte löschen", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index 8eacf6617..cb2f17249 100644 --- a/frontend/src/i18n/locales/en/app.json +++ b/frontend/src/i18n/locales/en/app.json @@ -1481,6 +1481,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?", @@ -1498,6 +1499,7 @@ "unknown": "Unknown" }, "productsActionBar": { + "adminRequired": "Admin permissions required", "clearSelection": "Clear selection", "confirmAction_one": "Delete {{count}} product", "confirmAction_other": "Delete {{count}} products", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index f30283bc6..b3c3e2a8e 100644 --- a/frontend/src/i18n/locales/es/app.json +++ b/frontend/src/i18n/locales/es/app.json @@ -1455,6 +1455,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?", @@ -1472,6 +1473,7 @@ "unknown": "Desconocido" }, "productsActionBar": { + "adminRequired": "", "clearSelection": "Borrar selección", "confirmAction_one": "Eliminar {{count}} producto", "confirmAction_many": "Eliminar {{count}} productos", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index e58f6f35e..7ad02641d 100644 --- a/frontend/src/i18n/locales/ja/app.json +++ b/frontend/src/i18n/locales/ja/app.json @@ -1415,6 +1415,7 @@ "title": "プロダクトを作成" }, "productOptionMenu": { + "adminRequired": "", "cannotBeUndone": "この操作は元に戻せません。", "deleteConfirmPrefix": "次のプロダクト", "deleteConfirmSuffix": "とそのすべてのサービスを完全に削除してもよろしいですか?", @@ -1432,6 +1433,7 @@ "unknown": "不明" }, "productsActionBar": { + "adminRequired": "", "clearSelection": "選択を解除", "confirmAction_other": "{{count}}件のプロダクトを削除", "confirmBody_other": "{{count}}件のプロダクトを削除してもよろしいですか?", From 127bdde1d7654331558bc892383c687a516d52f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 22:26:48 +0000 Subject: [PATCH 5/6] feat(devices): require admin to make a product from a device Make Product creates a product, so it needs the same admin gate the products pages now use. It was still enabled for non-admins, who would get an API error on click. Disabled with an "Admin permissions required" tooltip, matching how the adjacent Delete Device explains why it is unavailable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018YfRBMvuTVpXniu6MNi5jo --- frontend/src/components/DeviceOptionMenu.tsx | 35 +++++++++++++++----- frontend/src/i18n/locales/de/app.json | 1 + frontend/src/i18n/locales/en/app.json | 1 + frontend/src/i18n/locales/es/app.json | 1 + frontend/src/i18n/locales/ja/app.json | 1 + 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/DeviceOptionMenu.tsx b/frontend/src/components/DeviceOptionMenu.tsx index 85508cccc..bed0c8646 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 @@ -123,12 +125,21 @@ export const DeviceOptionMenu: React.FC = ({ device, service }) => { , - - - - - - , + + + + + + + + + + , ]} {device.permissions.includes('MANAGE') && devicesSection && @@ -140,7 +151,13 @@ export const DeviceOptionMenu: React.FC = ({ device, service }) => { service && devicesSection && [ , - , + , ]} diff --git a/frontend/src/i18n/locales/de/app.json b/frontend/src/i18n/locales/de/app.json index c06c5eb33..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", diff --git a/frontend/src/i18n/locales/en/app.json b/frontend/src/i18n/locales/en/app.json index cb2f17249..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", diff --git a/frontend/src/i18n/locales/es/app.json b/frontend/src/i18n/locales/es/app.json index b3c3e2a8e..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", diff --git a/frontend/src/i18n/locales/ja/app.json b/frontend/src/i18n/locales/ja/app.json index 7ad02641d..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": "プロダクトを作成", From 083641a9259bc50082fdcb4a69fa56424ca403d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 22:43:38 +0000 Subject: [PATCH 6/6] fix(products): make the new permission affordances actually reachable Three follow-ups from review of the admin gating: The Make Product tooltip wrapper was applied even for admins, where the title is empty and no tooltip renders anyway. MenuList arrow-key traversal skips a span-wrapped child, so that cost admins keyboard access to the item for nothing. Wrap only in the disabled branch, as the product option menu and DeleteDevice already do. The "Admin permissions required" title on the bulk delete never appeared: IconButton drops the tooltip entirely when disabled unless forceTitle is set, so a non-admin saw a greyed trash icon with no explanation. The create form kept `required` on the platform Select, whose native input is visually hidden. Browser validation blocked submit before handleCreate ran, making the platformRequired notice unreachable and anchoring the native bubble to an invisible control. Mark the form noValidate so the in-code checks report through the Notice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018YfRBMvuTVpXniu6MNi5jo --- frontend/src/components/DeviceOptionMenu.tsx | 38 +++++++++++-------- frontend/src/components/ProductsActionBar.tsx | 1 + .../src/pages/ProductsPage/ProductAddPage.tsx | 5 ++- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/DeviceOptionMenu.tsx b/frontend/src/components/DeviceOptionMenu.tsx index bed0c8646..21dbe2ac6 100644 --- a/frontend/src/components/DeviceOptionMenu.tsx +++ b/frontend/src/components/DeviceOptionMenu.tsx @@ -47,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 && } @@ -125,21 +136,18 @@ export const DeviceOptionMenu: React.FC = ({ device, service }) => { , - - - - - - - - - - , + admin ? ( + makeProductItem + ) : ( + + {makeProductItem} + + ), ]} {device.permissions.includes('MANAGE') && devicesSection && diff --git a/frontend/src/components/ProductsActionBar.tsx b/frontend/src/components/ProductsActionBar.tsx index 40b2bb8fa..a416948eb 100644 --- a/frontend/src/components/ProductsActionBar.tsx +++ b/frontend/src/components/ProductsActionBar.tsx @@ -76,6 +76,7 @@ export const ProductsActionBar: React.FC = ({ select }) => { } color="alwaysWhite" placement="bottom" + forceTitle disabled={!admin || !selected.length} loading={deleting} onClick={handleDelete} diff --git a/frontend/src/pages/ProductsPage/ProductAddPage.tsx b/frontend/src/pages/ProductsPage/ProductAddPage.tsx index 66c48f523..1bf925dc6 100644 --- a/frontend/src/pages/ProductsPage/ProductAddPage.tsx +++ b/frontend/src/pages/ProductsPage/ProductAddPage.tsx @@ -91,7 +91,10 @@ export const ProductAddPage: React.FC = () => { )} -
+ {/* 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. */} +