Skip to content

Commit e41c5b5

Browse files
lkostrowskiclaude
andcommitted
fix: stop voucher pages requiring MANAGE_PRODUCTS
Staff who could only manage discounts hit a stack of "you need one of the following permissions: MANAGE_PRODUCTS" errors as soon as a voucher opened. Nothing changed on the API side; the dashboard was asking for a product-gated field on a discounts page. Two causes: - The voucher detail and create views mounted the assign-product and assign-variant searches on page load, every picker closed. SearchProduct selects Product.channelListings, which Core gates behind MANAGE_PRODUCTS. Long-standing, but error toasts became sticky in #6812 so it turned loud. - The Eligible products accordion requested the same field, and #6812 made the catalogue auto-open the first non-empty group instead of always defaulting to Categories, so vouchers with assigned products hit it on load. Gate the field with @include(if: $PERMISSION_MANAGE_PRODUCTS) — makeQuery already injects a PERMISSION_<CODE> variable per query from the signed-in user's permissions, as CustomerDetails does for MANAGE_ORDERS. The catalogue mutations get it passed explicitly since makeMutation does not inject. SearchProducts declares it with a default so makeSearch callers, which do not strip PERMISSION_* from their variable types, need no changes. Where availability is unknowable, drop the feature rather than fake a value: the assign picker stops filtering by voucher channels, disabling rows and showing the unavailable hint, and the Eligible products table drops its Availability column. Also fixes the same latent bug on shipping rates and collections, where a user without MANAGE_PRODUCTS saw an empty picker because every product looked channel-mismatched. Also skip the four picker searches until their dialog opens, so opening a voucher no longer fires four catalog queries, one asking for 100 products. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b6662d2 commit e41c5b5

16 files changed

Lines changed: 143 additions & 63 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"saleor-dashboard": patch
3+
---
4+
5+
Vouchers no longer demand the MANAGE_PRODUCTS permission. Staff who could only manage discounts were met with a stack of "you need one of the following permissions: MANAGE_PRODUCTS" errors as soon as a voucher opened.
6+
7+
Two things caused it. The voucher page ran the assign-product and assign-variant picker searches on page load, with every picker closed — those searches now wait until their dialog is opened. And product channel availability, which does require MANAGE_PRODUCTS, was requested unconditionally — it is now requested only when the signed-in user can actually read it.
8+
9+
Staff without MANAGE_PRODUCTS see the Eligible products list without its Availability column, and the assign-product picker no longer filters by voucher channels, since it cannot know which channels a product is in. Nothing changes for staff who do have the permission.
10+
11+
Opening a voucher is also lighter: four catalog searches no longer fire on every page load.

src/components/AssignProductDialog/AssignProductDialogSingle.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ import { ResponsiveTable } from "@dashboard/components/ResponsiveTable";
88
import TableCellAvatar from "@dashboard/components/TableCellAvatar";
99
import TableRowLink from "@dashboard/components/TableRowLink";
1010
import { SaleorThrobber } from "@dashboard/components/Throbber";
11-
import { type ProductWhereInput } from "@dashboard/graphql";
11+
import { PermissionEnum, type ProductWhereInput } from "@dashboard/graphql";
1212
import { useAssignPickerListDisplayState } from "@dashboard/hooks/useAssignPickerListDisplayState";
1313
import useModalDialogOpen from "@dashboard/hooks/useModalDialogOpen";
1414
import { useModalSearchWithFilters } from "@dashboard/hooks/useModalSearchWithFilters";
1515
import { useStalePickerList } from "@dashboard/hooks/useStalePickerList";
1616
import { maybe, renderCollection } from "@dashboard/misc";
17+
import { useHasPermission } from "@dashboard/search/useHasPermission";
1718
import { type Container, type FetchMoreProps } from "@dashboard/types";
1819
import { Radio, TableBody, TableCell, TextField } from "@material-ui/core";
1920
import { Text } from "@saleor/macaw-ui-next";
@@ -75,6 +76,7 @@ export const AssignProductDialogSingle = (props: AssignProductDialogSingleProps)
7576
const [selectedProductId, setSelectedProductId] = useState("");
7677
const [initialSelection, setInitialSelection] = useState("");
7778
const { combinedFilters, clearFilters } = useModalProductFilterContext();
79+
const canManageProducts = useHasPermission()(PermissionEnum.MANAGE_PRODUCTS);
7880
const selectedIdRef = useRef(selectedId);
7981

8082
selectedIdRef.current = selectedId;
@@ -199,10 +201,14 @@ export const AssignProductDialogSingle = (props: AssignProductDialogSingleProps)
199201
}
200202

201203
const isSelected = selectedProductId === product.id;
202-
const isProductAvailable = isProductAvailableInVoucherChannels(
203-
product.channelListings ?? [],
204-
selectedChannels,
205-
);
204+
// Without MANAGE_PRODUCTS the search omits channelListings, so availability
205+
// is unknowable — hide the restriction instead of disabling every row.
206+
const isProductAvailable =
207+
!canManageProducts ||
208+
isProductAvailableInVoucherChannels(
209+
product.channelListings ?? [],
210+
selectedChannels,
211+
);
206212

207213
return (
208214
<TableRowLink

src/components/AssignProductDialog/useAssignProductPicker.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
// @ts-strict-ignore
22
import { type ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton";
3-
import { type ProductWhereInput } from "@dashboard/graphql";
3+
import { PermissionEnum, type ProductWhereInput } from "@dashboard/graphql";
44
import { useAssignPickerListDisplayState } from "@dashboard/hooks/useAssignPickerListDisplayState";
55
import useModalDialogOpen from "@dashboard/hooks/useModalDialogOpen";
66
import { useModalSearchWithFilters } from "@dashboard/hooks/useModalSearchWithFilters";
77
import { usePickerBackfill } from "@dashboard/hooks/usePickerBackfill";
88
import { useStalePickerList } from "@dashboard/hooks/useStalePickerList";
9+
import { useHasPermission } from "@dashboard/search/useHasPermission";
910
import { type Container, type FetchMoreProps } from "@dashboard/types";
1011
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1112

@@ -85,7 +86,11 @@ export const useAssignProductPicker = ({
8586
pruneUnavailableSelection = false,
8687
onMaxSelectionReached,
8788
}: UseAssignProductPickerProps) => {
88-
const hasChannelRestriction = Boolean(selectedChannels?.length);
89+
// Product.channelListings is behind MANAGE_PRODUCTS, so without it we cannot tell whether a
90+
// product is in the voucher's channels. Drop the availability feature rather than guess:
91+
// no filtering, no disabled rows, no "unavailable" hint.
92+
const canManageProducts = useHasPermission()(PermissionEnum.MANAGE_PRODUCTS);
93+
const hasChannelRestriction = Boolean(selectedChannels?.length) && canManageProducts;
8994

9095
const products = useMemo(
9196
() =>
@@ -272,9 +277,13 @@ export const useAssignProductPicker = ({
272277
return false;
273278
}
274279

280+
if (!canManageProducts) {
281+
return true;
282+
}
283+
275284
return isProductAvailableInVoucherChannels(product.channelListings, selectedChannels);
276285
},
277-
[excludeProduct, selectedChannels],
286+
[canManageProducts, excludeProduct, selectedChannels],
278287
);
279288

280289
useEffect(
@@ -384,7 +393,7 @@ export const useAssignProductPicker = ({
384393
loading,
385394
onFetchMore,
386395
onQueryChange,
387-
productUnavailableText,
396+
productUnavailableText: canManageProducts ? productUnavailableText : undefined,
388397
productsDict,
389398
query,
390399
resumeBackfill: backfill.resumeBackfill,

src/discounts/components/DiscountProducts/DiscountProducts.tsx

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,34 @@ import {
66
} from "@dashboard/components/AssignableListTable/AssignableListTable";
77
import { DashboardCard } from "@dashboard/components/Card";
88
import { ProductChannelsAvailability } from "@dashboard/components/ChannelsAvailabilityDropdown";
9+
import { type ProductChannelListing } from "@dashboard/components/ChannelsAvailabilityDropdown/productUtils";
910
import { EmptyImage } from "@dashboard/components/EmptyImage";
1011
import { PAGINATE_BY } from "@dashboard/config";
11-
import { type SearchProductFragment } from "@dashboard/graphql";
12+
import { PermissionEnum } from "@dashboard/graphql";
1213
import { maybe } from "@dashboard/misc";
1314
import { productUrl } from "@dashboard/products/urls";
15+
import { useHasPermission } from "@dashboard/search/useHasPermission";
1416
import { type ListActions, type ListProps } from "@dashboard/types";
1517
import { Box, Button, Text } from "@saleor/macaw-ui-next";
1618
import { FormattedMessage, useIntl } from "react-intl";
1719

1820
import { messages } from "./messages";
1921

22+
/**
23+
* Structural row rather than SearchProductFragment — the voucher catalogue query selects a
24+
* narrower product. `channelListings` is absent (not empty) when the viewer lacks
25+
* MANAGE_PRODUCTS, since it is behind `@include(if: $PERMISSION_MANAGE_PRODUCTS)`.
26+
*/
27+
export type DiscountProductRow = {
28+
id: string;
29+
name: string;
30+
thumbnail?: { url: string } | null;
31+
productType: { name: string };
32+
channelListings?: ProductChannelListing[] | null;
33+
};
34+
2035
interface SaleProductsProps extends Omit<ListProps, "onUpdateListSettings">, ListActions {
21-
products: SearchProductFragment[];
36+
products: DiscountProductRow[];
2237
onProductAssign: () => void;
2338
onProductUnassign: (id: string) => void;
2439
numberOfRows?: number;
@@ -42,10 +57,13 @@ export const DiscountProducts = ({
4257
embedded = false,
4358
}: SaleProductsProps): JSX.Element => {
4459
const intl = useIntl();
60+
// Product.channelListings is behind MANAGE_PRODUCTS and simply absent otherwise, so drop the
61+
// column instead of filling every row with a placeholder.
62+
const canManageProducts = useHasPermission()(PermissionEnum.MANAGE_PRODUCTS);
4563

4664
const body = (
4765
<>
48-
<AssignableListTable<SearchProductFragment>
66+
<AssignableListTable<DiscountProductRow>
4967
data-test-id="assigned-specific-products-table"
5068
rowTestId="assigned-specific-product"
5169
items={products}
@@ -62,21 +80,27 @@ export const DiscountProducts = ({
6280
{
6381
id: "name",
6482
// Slightly prefer name, but keep type/availability readable in the catalogue panel.
65-
width: "42%",
83+
width: canManageProducts ? "42%" : "60%",
6684
header: <FormattedMessage {...messages.discountProductsTableProductHeader} />,
6785
},
6886
{
6987
id: "type",
70-
width: "24%",
88+
width: canManageProducts ? "24%" : "32%",
7189
header: <FormattedMessage {...messages.discountProductsTableTypeHeader} />,
7290
hideHeaderWhenSelected: true,
7391
},
74-
{
75-
id: "availability",
76-
width: "26%",
77-
header: <FormattedMessage {...messages.discountProductsTableAvailabilityHeader} />,
78-
hideHeaderWhenSelected: true,
79-
},
92+
...(canManageProducts
93+
? [
94+
{
95+
id: "availability",
96+
width: "26%",
97+
header: (
98+
<FormattedMessage {...messages.discountProductsTableAvailabilityHeader} />
99+
),
100+
hideHeaderWhenSelected: true,
101+
},
102+
]
103+
: []),
80104
]}
81105
renderCells={product => (
82106
<>
@@ -114,13 +138,15 @@ export const DiscountProducts = ({
114138
{maybe(() => product.productType.name)}
115139
</Text>
116140
</AssignableListCell>
117-
<AssignableListCell truncate>
118-
{product.channelListings?.length ? (
119-
<ProductChannelsAvailability channels={product.channelListings} />
120-
) : (
121-
"-"
122-
)}
123-
</AssignableListCell>
141+
{canManageProducts && (
142+
<AssignableListCell truncate>
143+
{product.channelListings?.length ? (
144+
<ProductChannelsAvailability channels={product.channelListings} />
145+
) : (
146+
"-"
147+
)}
148+
</AssignableListCell>
149+
)}
124150
</>
125151
)}
126152
/>

src/discounts/components/VoucherCatalogueSection/VoucherCatalogueSection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Callout } from "@dashboard/components/Callout/Callout";
22
import { DetailSettingsCard } from "@dashboard/components/DetailSettingsCard/DetailSettingsCard";
33
import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons";
4-
import { type DiscountErrorFragment, type SearchProductFragment } from "@dashboard/graphql";
4+
import { type DiscountErrorFragment } from "@dashboard/graphql";
55
import { buttonMessages } from "@dashboard/intl";
66
import { type Node } from "@dashboard/types";
77
import { Box, Button, Text } from "@saleor/macaw-ui-next";
@@ -31,7 +31,7 @@ interface VoucherCatalogueSectionProps {
3131
tabItemsCount: VoucherTabItemsCount;
3232
categories: Parameters<typeof DiscountCategories>[0]["categories"];
3333
collections: Parameters<typeof DiscountCollections>[0]["collections"];
34-
products: SearchProductFragment[];
34+
products: Parameters<typeof DiscountProducts>[0]["products"];
3535
variants: Parameters<typeof DiscountVariants>[0]["variants"];
3636
numberOfRows: number;
3737
onUpdateListSettings: (key: "rowNumber", value: number) => void;

src/discounts/components/VoucherDetailsPage/VoucherDetailsPageFormContent.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,7 @@ import { Savebar } from "@dashboard/components/Savebar";
1515
import { createChannelsChangeHandler } from "@dashboard/discounts/handlers";
1616
import { AppWidgets } from "@dashboard/extensions/components/AppWidgets/AppWidgets";
1717
import { type ExtensionWithParams } from "@dashboard/extensions/types";
18-
import {
19-
type DiscountErrorFragment,
20-
PermissionEnum,
21-
type SearchProductFragment,
22-
} from "@dashboard/graphql";
18+
import { type DiscountErrorFragment, PermissionEnum } from "@dashboard/graphql";
2319
import { type UseFormResult } from "@dashboard/hooks/useForm";
2420
import { type UseListSettings } from "@dashboard/hooks/useListSettings";
2521
import { type LocalPagination } from "@dashboard/hooks/useLocalPaginator";
@@ -360,7 +356,7 @@ export const VoucherDetailsPageFormContent = ({
360356
onTabClick={onTabClick}
361357
categories={mapEdgesToItems(voucher?.categories)}
362358
collections={mapEdgesToItems(voucher?.collections)}
363-
products={mapEdgesToItems(voucher?.products) as unknown as SearchProductFragment[]}
359+
products={mapEdgesToItems(voucher?.products) ?? []}
364360
variants={voucher?.variants}
365361
onCategoryAssign={onCategoryAssign}
366362
onCategoryUnassign={onCategoryUnassign}

src/discounts/mutations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const voucherCataloguesAdd = gql`
3939
$includeCollections: Boolean!
4040
$includeCategories: Boolean!
4141
$includeVariants: Boolean!
42+
$PERMISSION_MANAGE_PRODUCTS: Boolean!
4243
) {
4344
voucherCataloguesAdd(id: $id, input: $input) {
4445
errors {
@@ -64,6 +65,7 @@ export const voucherCataloguesRemove = gql`
6465
$includeCollections: Boolean!
6566
$includeCategories: Boolean!
6667
$includeVariants: Boolean!
68+
$PERMISSION_MANAGE_PRODUCTS: Boolean!
6769
) {
6870
voucherCataloguesRemove(id: $id, input: $input) {
6971
errors {

src/discounts/queries.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export const voucherCatalogue = gql`
128128
$includeCollections: Boolean!
129129
$includeCategories: Boolean!
130130
$includeVariants: Boolean!
131+
$PERMISSION_MANAGE_PRODUCTS: Boolean!
131132
) {
132133
voucher(id: $id) {
133134
id

src/discounts/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { type PromotionRuleDetailsFragment, type SaleDetailsQuery } from "@dashboard/graphql";
1+
import { type PromotionRuleDetailsFragment } from "@dashboard/graphql";
22
import { sortAlphabetically } from "@dashboard/utils/sort";
33

44
import { type Rule } from "./models";
55

66
export type PromotionStatus = "scheduled" | "active" | "finished";
77

88
export function getAssignedVariantIds(
9-
variants: NonNullable<SaleDetailsQuery["sale"]>["variants"] | null | undefined,
9+
variants: { edges: Array<{ node: { id: string } }> } | null | undefined,
1010
): string[] {
1111
return variants?.edges.map(variant => variant.node.id) ?? [];
1212
}

src/discounts/views/VoucherCreate/VoucherCreate.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,19 +117,27 @@ const VoucherCreateView = ({ params }: VoucherCreateProps) => {
117117
useState<SearchCategoriesWithTotalProductsQueryVariables>(categorySearchInitialVariables);
118118
const [collectionSearchVariables, setCollectionSearchVariables] =
119119
useState<SearchCollectionsWithTotalProductsQueryVariables>(collectionSearchInitialVariables);
120+
// Assign-picker searches only feed their dialog, so keep them off until it opens.
121+
// SearchProducts pulls Product/ProductVariant.channelListings, which Core gates behind
122+
// MANAGE_PRODUCTS — running it on mount buried MANAGE_DISCOUNTS-only staff in permission
123+
// errors just for opening the create page.
120124
const categoriesSearch = useCategoryWithTotalProductsSearch({
125+
skip: params.action !== "assign-category",
121126
variables: categorySearchVariables,
122127
});
123128
const collectionsSearch = useCollectionWithTotalProductsSearch({
129+
skip: params.action !== "assign-collection",
124130
variables: collectionSearchVariables,
125131
});
126132
// Products already on the voucher are dropped client-side, so a page of 20 can arrive empty
127133
// on a large catalog. Ask for more per request so the picker stays useful without leaning on
128134
// backfill for every page.
129135
const productsSearch = useProductSearch({
136+
skip: params.action !== "assign-product",
130137
variables: productSearchVariables,
131138
});
132139
const variantsSearch = useProductSearch({
140+
skip: params.action !== "assign-variant",
133141
variables: variantSearchVariables,
134142
});
135143

0 commit comments

Comments
 (0)