From f94eb20e32d96efa07f278108ce242eae1582f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Chy=C5=82a?= Date: Wed, 27 Mar 2024 15:09:43 +0100 Subject: [PATCH] Allow user without app permission access apps view (#4738) * Allow user without app permission access apps view * Use css subgrid instead of multiple map loops * Add protection to queries and mutation that use App fragments * Allow user to see manage app view * Refactor app permission check to hook * Protect routes * shouldShowInstalledApps * Refactor AppListPage, write test for loading, empty state with checking manage_app perm * Change manage button label depend on manage_apps * Improve typing * Extract messages * ButtonWithTooltip * Refactor HeaderOptions, block action when no manage_apps * Improve buttons * Add changset * Adjust singlePermission tests * Remove required hasManagedAppsPermission when default * Improve action icons * Improve naming and refactor AppListCardInstallButton * Improve changset * Improve tests --- .changeset/thin-avocados-grow.md | 6 + locale/defaultMessages.json | 7 + .../channelsWebhooks.spec.ts | 4 +- .../singlePermissions/contentPage.spec.ts | 4 +- .../tests/singlePermissions/customer.spec.ts | 2 +- .../tests/singlePermissions/discount.spec.ts | 4 +- .../tests/singlePermissions/orders.spec.ts | 4 +- .../tests/singlePermissions/plugins.spec.ts | 2 +- .../tests/singlePermissions/product.spec.ts | 6 +- .../singlePermissions/productType.spec.ts | 2 +- .../settingsConfiguration.spec.ts | 2 +- .../tests/singlePermissions/shipping.spec.ts | 2 +- .../singlePermissions/staffMembers.spec.ts | 4 +- .../singlePermissions/translations.spec.ts | 2 +- src/apps/apps-routing.tsx | 8 +- src/apps/components/AllAppList/AllAppList.tsx | 19 +- .../AppDetailsPage/HeaderOptions.tsx | 46 ++-- .../AppDetailsPage/PermissionsCard.tsx | 18 +- src/apps/components/AppDetailsPage/styles.ts | 52 ---- .../components/AppListPage/AppListPage.tsx | 59 ++-- src/apps/components/AppListPage/messages.ts | 5 - src/apps/components/AppListPage/utils.test.ts | 7 - src/apps/components/AppListPage/utils.ts | 7 - .../AppListRow/AppListCardActions.tsx | 55 +--- .../AppListRow/AppListCardDescription.tsx | 10 +- .../AppListRow/AppListCardInstallButton.tsx | 57 ++++ .../AppListRow/AppListCardIntegrations.tsx | 6 - .../AppListRow/AppListCardLinks.tsx | 14 +- .../components/AppListRow/AppListRow.test.tsx | 25 +- src/apps/components/AppListRow/AppListRow.tsx | 90 +++---- src/apps/components/AppPage/AppPage.tsx | 1 + src/apps/components/AppPage/AppPageNav.tsx | 11 +- src/apps/components/AppPage/message.ts | 14 + .../AppPermissionsDialog.tsx | 5 +- .../AppWebhooksDisplay/AppWebhooksDisplay.tsx | 4 + .../InstalledAppList/InstalledAppList.tsx | 27 +- .../components/InstalledAppList/messages.ts | 5 + .../components/InstalledAppList/utils.test.ts | 252 ++++++++++++++++++ src/apps/components/InstalledAppList/utils.ts | 33 +++ src/apps/mutations.ts | 13 +- src/apps/queries.ts | 2 +- src/apps/views/AppListView/AppListView.tsx | 3 + .../views/AppManageView/AppManageView.tsx | 6 +- .../AppPermissionRequestView.tsx | 1 + src/apps/views/AppView/AppView.tsx | 5 +- .../ButtonWithTooltip/ButtonWithTooltip.tsx | 29 ++ src/components/ButtonWithTooltip/index.ts | 1 + .../Sidebar/menu/useMenuStructure.tsx | 2 +- .../CustomAppDetails/CustomAppDetails.tsx | 2 +- .../views/CustomAppWebhookCreate.tsx | 4 +- src/fragments/apps.ts | 8 +- src/graphql/hooks.generated.ts | 20 +- src/graphql/types.generated.ts | 14 +- src/hooks/useHasManagedAppsPermission.ts | 14 + src/index.tsx | 2 +- src/intl.ts | 4 + 56 files changed, 688 insertions(+), 323 deletions(-) create mode 100644 .changeset/thin-avocados-grow.md delete mode 100644 src/apps/components/AppDetailsPage/styles.ts create mode 100644 src/apps/components/AppListRow/AppListCardInstallButton.tsx create mode 100644 src/apps/components/AppPage/message.ts create mode 100644 src/apps/components/InstalledAppList/utils.test.ts create mode 100644 src/apps/components/InstalledAppList/utils.ts create mode 100644 src/components/ButtonWithTooltip/ButtonWithTooltip.tsx create mode 100644 src/components/ButtonWithTooltip/index.ts create mode 100644 src/hooks/useHasManagedAppsPermission.ts diff --git a/.changeset/thin-avocados-grow.md b/.changeset/thin-avocados-grow.md new file mode 100644 index 00000000000..05db5e03d2e --- /dev/null +++ b/.changeset/thin-avocados-grow.md @@ -0,0 +1,6 @@ +--- +"saleor-dashboard": patch +--- + +Allow all user to access to APPS tab without checking any permissions. User will be able to see installed app list and enter to each apps. +Each app will be responsible for checking user permissions. diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 3d4969c01c3..6a2f18688f9 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -3859,6 +3859,9 @@ "context": "header", "string": "{webhookName} Details" }, + "ORQvOg": { + "string": "You don't have permission to perform this action" + }, "OTDo9I": { "context": "tab name", "string": "All staff members" @@ -4404,6 +4407,10 @@ "S8kqP9": { "string": "Conditions" }, + "S90DJO": { + "context": "Button with app settings label", + "string": "App settings" + }, "SBb6Ej": { "context": "select a warehouse to fulfill product from", "string": "Select warehouse..." diff --git a/playwright/tests/singlePermissions/channelsWebhooks.spec.ts b/playwright/tests/singlePermissions/channelsWebhooks.spec.ts index 48b2970cb29..5c30c1bbcb7 100644 --- a/playwright/tests/singlePermissions/channelsWebhooks.spec.ts +++ b/playwright/tests/singlePermissions/channelsWebhooks.spec.ts @@ -16,7 +16,7 @@ test("TC: SALEOR_11 User should be able to navigate to channel list as a staff m await page.goto(URL_LIST.homePage); await mainMenuPage.openConfiguration(); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); await configurationPage.openChannels(); await expect(channelPage.createChannelButton).toBeVisible(); @@ -30,7 +30,7 @@ test("TC: SALEOR_12 User should be able to navigate to webhooks and events as a const configurationPage = new ConfigurationPage(page); await page.goto(URL_LIST.configuration); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); await configurationPage.openWebhooksAndEvents(); await expect(webhooksEventsPage.createAppButton).toBeVisible(); }); diff --git a/playwright/tests/singlePermissions/contentPage.spec.ts b/playwright/tests/singlePermissions/contentPage.spec.ts index 36fbab79d24..7d61706d000 100644 --- a/playwright/tests/singlePermissions/contentPage.spec.ts +++ b/playwright/tests/singlePermissions/contentPage.spec.ts @@ -18,7 +18,7 @@ test("TC: SALEOR_14 User should be able to navigate to content list as a staff m await page.goto(URL_LIST.homePage); await mainMenuPage.openContent(); await expect(contentPage.createContentButton).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(3); + await mainMenuPage.expectMenuItemsCount(4); await basePage.expectGridToBeAttached(); }); test("TC: SALEOR_15 User should be able to navigate to page types list as a staff member using CONTENT aka PAGE permission @e2e", async ({ @@ -36,5 +36,5 @@ test("TC: SALEOR_15 User should be able to navigate to page types list as a staf await configurationPage.openPageTypes(); await expect(pageTypesPage.createPageTypeButton).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(3); + await mainMenuPage.expectMenuItemsCount(4); }); diff --git a/playwright/tests/singlePermissions/customer.spec.ts b/playwright/tests/singlePermissions/customer.spec.ts index feff873ec17..65e4306e0b2 100644 --- a/playwright/tests/singlePermissions/customer.spec.ts +++ b/playwright/tests/singlePermissions/customer.spec.ts @@ -17,5 +17,5 @@ test("TC: SALEOR_13 User should be able to navigate to customer list as a staff await mainMenuPage.openCustomers(); await expect(customersPage.createCustomerButton).toBeVisible(); await basePage.expectGridToBeAttached(); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); }); diff --git a/playwright/tests/singlePermissions/discount.spec.ts b/playwright/tests/singlePermissions/discount.spec.ts index e217ba7be6b..95c9da29301 100644 --- a/playwright/tests/singlePermissions/discount.spec.ts +++ b/playwright/tests/singlePermissions/discount.spec.ts @@ -18,7 +18,7 @@ test("TC: SALEOR_6 User should be able to navigate to discount list as a staff m await mainMenuPage.openDiscounts(); await expect(discountsPage.createDiscountButton).toBeVisible(); await basePage.expectGridToBeAttached(); - await mainMenuPage.expectMenuItemsCount(3); + await mainMenuPage.expectMenuItemsCount(4); }); test("TC: SALEOR_7 User should be able to navigate to voucher list as a staff member using DISCOUNTS permission @e2e", async ({ @@ -32,5 +32,5 @@ test("TC: SALEOR_7 User should be able to navigate to voucher list as a staff me await mainMenuPage.openVouchers(); await expect(vouchersPage.createVoucherButton).toBeVisible(); await basePage.expectGridToBeAttached(); - await mainMenuPage.expectMenuItemsCount(3); + await mainMenuPage.expectMenuItemsCount(4); }); diff --git a/playwright/tests/singlePermissions/orders.spec.ts b/playwright/tests/singlePermissions/orders.spec.ts index 684c50a126b..c7f3771bfc0 100644 --- a/playwright/tests/singlePermissions/orders.spec.ts +++ b/playwright/tests/singlePermissions/orders.spec.ts @@ -18,7 +18,7 @@ test("TC: SALEOR_8 User should be able to navigate to order list as a staff memb await mainMenuPage.openOrders(); await expect(ordersPage.createOrderButton).toBeVisible(); await basePage.expectGridToBeAttached(); - await mainMenuPage.expectMenuItemsCount(3); + await mainMenuPage.expectMenuItemsCount(4); }); test("TC: SALEOR_9 User should be able to navigate to draft list as a staff member using ORDER permission @e2e", async ({ page, @@ -31,5 +31,5 @@ test("TC: SALEOR_9 User should be able to navigate to draft list as a staff memb await mainMenuPage.openDrafts(); await expect(draftOrdersPage.createDraftOrderButton).toBeVisible(); await basePage.expectGridToBeAttached(); - await mainMenuPage.expectMenuItemsCount(3); + await mainMenuPage.expectMenuItemsCount(4); }); diff --git a/playwright/tests/singlePermissions/plugins.spec.ts b/playwright/tests/singlePermissions/plugins.spec.ts index 127a59c0f4b..8ea00b1caec 100644 --- a/playwright/tests/singlePermissions/plugins.spec.ts +++ b/playwright/tests/singlePermissions/plugins.spec.ts @@ -16,5 +16,5 @@ test("TC: SALEOR_16 User should be able to navigate to plugin list as a staff me await page.goto(URL_LIST.configuration); await configurationPage.openPlugins(); await expect(pluginsPage.pluginRow.first()).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); }); diff --git a/playwright/tests/singlePermissions/product.spec.ts b/playwright/tests/singlePermissions/product.spec.ts index 848884bbc90..a2114a1c6b2 100644 --- a/playwright/tests/singlePermissions/product.spec.ts +++ b/playwright/tests/singlePermissions/product.spec.ts @@ -18,7 +18,7 @@ test("TC: SALEOR_23 User should be able to navigate to product list as a staff m await page.goto(URL_LIST.homePage); await mainMenuPage.openProducts(); await expect(productPage.addProductButton).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(5); + await mainMenuPage.expectMenuItemsCount(6); await basePage.expectGridToBeAttached(); }); test("TC: SALEOR_24 User should be able to navigate to collections list as a staff member using PRODUCT permission @e2e", async ({ @@ -31,7 +31,7 @@ test("TC: SALEOR_24 User should be able to navigate to collections list as a sta await page.goto(URL_LIST.homePage); await mainMenuPage.openCollections(); await expect(collectionsPage.createCollectionButton).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(5); + await mainMenuPage.expectMenuItemsCount(6); await basePage.expectGridToBeAttached(); }); test("TC: SALEOR_25 User should be able to navigate to categories list as a staff member using PRODUCT permission @e2e", async ({ @@ -44,6 +44,6 @@ test("TC: SALEOR_25 User should be able to navigate to categories list as a staf await page.goto(URL_LIST.homePage); await mainMenuPage.openCategories(); await expect(categoriesPage.createCategoryButton).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(5); + await mainMenuPage.expectMenuItemsCount(6); await basePage.expectGridToBeAttached(); }); diff --git a/playwright/tests/singlePermissions/productType.spec.ts b/playwright/tests/singlePermissions/productType.spec.ts index e337eda33a9..99afc05a7d2 100644 --- a/playwright/tests/singlePermissions/productType.spec.ts +++ b/playwright/tests/singlePermissions/productType.spec.ts @@ -16,5 +16,5 @@ test("TC: SALEOR_17 User should be able to navigate to product type list as a st await page.goto(URL_LIST.configuration); await configurationPage.openProductTypes(); await expect(productTypePage.addProductTypeButton).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); }); diff --git a/playwright/tests/singlePermissions/settingsConfiguration.spec.ts b/playwright/tests/singlePermissions/settingsConfiguration.spec.ts index 121aa400d35..ec30d4b6d46 100644 --- a/playwright/tests/singlePermissions/settingsConfiguration.spec.ts +++ b/playwright/tests/singlePermissions/settingsConfiguration.spec.ts @@ -17,5 +17,5 @@ test("TC: SALEOR_18 User should be able to navigate to configuration as a staff await mainMenuPage.openConfiguration(); await configurationPage.openSiteSettings(); await expect(siteSettingsPage.companyInfoSection).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); }); diff --git a/playwright/tests/singlePermissions/shipping.spec.ts b/playwright/tests/singlePermissions/shipping.spec.ts index 2988e9f5dec..6781edb933e 100644 --- a/playwright/tests/singlePermissions/shipping.spec.ts +++ b/playwright/tests/singlePermissions/shipping.spec.ts @@ -17,5 +17,5 @@ test("TC: SALEOR_21 User should be able to navigate to shipping zones page as a await page.waitForTimeout(8000); await configurationPage.openShippingMethods(); await expect(shippingMethodsPage.createShippingZoneButton).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); }); diff --git a/playwright/tests/singlePermissions/staffMembers.spec.ts b/playwright/tests/singlePermissions/staffMembers.spec.ts index de3e77b27fd..a5b035c2ac5 100644 --- a/playwright/tests/singlePermissions/staffMembers.spec.ts +++ b/playwright/tests/singlePermissions/staffMembers.spec.ts @@ -20,7 +20,7 @@ test("TC: SALEOR_19 User should be able to navigate to staff members list page a await page.goto(URL_LIST.configuration); await configurationPage.openStaffMembers(); await expect(staffMembersPage.inviteStaffMembersButton).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); await basePage.expectGridToBeAttached(); }); @@ -35,6 +35,6 @@ test("TC: SALEOR_20 User should be able to navigate to permission group list pag await page.goto(URL_LIST.configuration); await configurationPage.openPermissionGroups(); await expect(permissionGroupsPage.createPermissionGroupButton).toBeVisible(); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); await basePage.expectGridToBeAttached(); }); diff --git a/playwright/tests/singlePermissions/translations.spec.ts b/playwright/tests/singlePermissions/translations.spec.ts index 83054e565e7..61dfed0a0e4 100644 --- a/playwright/tests/singlePermissions/translations.spec.ts +++ b/playwright/tests/singlePermissions/translations.spec.ts @@ -14,5 +14,5 @@ test("TC: SALEOR_22 User should be able to navigate to translations page as a st await page.goto(URL_LIST.homePage); await mainMenuPage.openTranslations(); await expect(basePage.pageHeader).toHaveText("Languages"); - await mainMenuPage.expectMenuItemsCount(2); + await mainMenuPage.expectMenuItemsCount(3); }); diff --git a/src/apps/apps-routing.tsx b/src/apps/apps-routing.tsx index b4424e70d51..edb46949f4e 100644 --- a/src/apps/apps-routing.tsx +++ b/src/apps/apps-routing.tsx @@ -2,6 +2,8 @@ import { AppDetailsUrlQueryParams, AppInstallUrlQueryParams, } from "@dashboard/apps/urls"; +import SectionRoute from "@dashboard/auth/components/SectionRoute"; +import { PermissionEnum } from "@dashboard/graphql"; import { sectionNames } from "@dashboard/intl"; import { parse as parseQs } from "qs"; import React from "react"; @@ -55,8 +57,9 @@ export const AppsSectionRoot = () => { - @@ -65,8 +68,9 @@ export const AppsSectionRoot = () => { path={AppPaths.resolveAppDetailsPath(":id")} component={AppManageRoute} /> - diff --git a/src/apps/components/AllAppList/AllAppList.tsx b/src/apps/components/AllAppList/AllAppList.tsx index 636704746c5..9c349df91be 100644 --- a/src/apps/components/AllAppList/AllAppList.tsx +++ b/src/apps/components/AllAppList/AllAppList.tsx @@ -1,8 +1,7 @@ import { AppstoreApi } from "@dashboard/apps/appstore.types"; import { AppInstallationFragment } from "@dashboard/graphql"; import { Skeleton } from "@material-ui/lab"; -import { Box } from "@saleor/macaw-ui-next"; -import chunk from "lodash/chunk"; +import { Box, useTheme } from "@saleor/macaw-ui-next"; import React from "react"; import AppListRow from "../AppListRow"; @@ -20,18 +19,24 @@ const AllAppList: React.FC = ({ navigateToAppInstallPage, navigateToGithubForkPage, }) => { - const appsPairs = React.useMemo(() => chunk(appList, 2), [appList]); + const { themeValues } = useTheme(); if (!appList) { return ; } return ( - - {appsPairs.map(appPair => ( + + {appList.map(app => ( = ({ onAppDeactivateOpen, onAppDeleteOpen, }) => { - const classes = useStyles(); + const intl = useIntl(); + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); + + const tooltipContent = !hasManagedAppsPermission + ? intl.formatMessage(buttonMessages.noPermission) + : undefined; return ( = ({ borderColor="default1" borderBottomWidth={1} > -
- + - + {isActive ? ( ) : ( )} - - + + - + - -
+ +
); }; diff --git a/src/apps/components/AppDetailsPage/PermissionsCard.tsx b/src/apps/components/AppDetailsPage/PermissionsCard.tsx index 0a7199b53a8..fe0637ba3cb 100644 --- a/src/apps/components/AppDetailsPage/PermissionsCard.tsx +++ b/src/apps/components/AppDetailsPage/PermissionsCard.tsx @@ -1,7 +1,10 @@ import { AppPermissionsDialog } from "@dashboard/apps/components/AppPermissionsDialog"; +import { ButtonWithTooltip } from "@dashboard/components/ButtonWithTooltip"; import Skeleton from "@dashboard/components/Skeleton"; import { PermissionEnum } from "@dashboard/graphql"; -import { Box, BoxProps, Button, Text } from "@saleor/macaw-ui-next"; +import { useHasManagedAppsPermission } from "@dashboard/hooks/useHasManagedAppsPermission"; +import { buttonMessages } from "@dashboard/intl"; +import { Box, BoxProps, Text } from "@saleor/macaw-ui-next"; import React, { useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; @@ -25,14 +28,21 @@ export const PermissionsCard: React.FC = ({ const [editPermissionDialogOpen, setEditPermissionDialogOpen] = useState(false); const intl = useIntl(); + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); const editPermissionsButton = ( - + ); const renderContent = () => { diff --git a/src/apps/components/AppDetailsPage/styles.ts b/src/apps/components/AppDetailsPage/styles.ts deleted file mode 100644 index 00d8164af71..00000000000 --- a/src/apps/components/AppDetailsPage/styles.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { makeStyles } from "@saleor/macaw-ui"; - -export const useStyles = makeStyles( - theme => ({ - appHeader: { - marginBottom: theme.spacing(3), - }, - appHeaderLinks: { - "& img": { - marginRight: theme.spacing(1), - }, - alignItems: "center", - display: "flex", - padding: theme.spacing(2, 0), - }, - headerLinkContainer: { - "& svg": { - marginRight: theme.spacing(), - }, - "& span": { - fontWeight: 500, - }, - alignItems: "center", - color: theme.palette.text.primary, - display: "flex", - fontSize: theme.spacing(2), - fontWeight: 500, - lineHeight: 1.2, - marginRight: theme.spacing(3), - padding: 0, - textTransform: "none", - }, - hr: { - border: "none", - borderTop: `1px solid ${theme.palette.divider}`, - height: 0, - marginBottom: 0, - marginTop: 0, - width: "100%", - }, - marketplaceContent: { - "& button": { - marginTop: theme.spacing(1), - }, - "&:last-child": { - padding: theme.spacing(2, 3, 2, 3), - }, - padding: theme.spacing(1), - }, - }), - { name: "AppDetailsPage" }, -); diff --git a/src/apps/components/AppListPage/AppListPage.tsx b/src/apps/components/AppListPage/AppListPage.tsx index 92751616753..3424c85a2e6 100644 --- a/src/apps/components/AppListPage/AppListPage.tsx +++ b/src/apps/components/AppListPage/AppListPage.tsx @@ -1,5 +1,6 @@ import { AppUrls } from "@dashboard/apps/urls"; import { TopNav } from "@dashboard/components/AppLayout/TopNav"; +import { useHasManagedAppsPermission } from "@dashboard/hooks/useHasManagedAppsPermission"; import useNavigator from "@dashboard/hooks/useNavigator"; import { sectionNames } from "@dashboard/intl"; import { ListProps } from "@dashboard/types"; @@ -38,27 +39,30 @@ export const AppListPage: React.FC = props => { } = props; const intl = useIntl(); const classes = useStyles(); + const navigate = useNavigator(); + + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); + const verifiedInstalledApps = getVerifiedInstalledApps( installedApps, installableMarketplaceApps, ); + const verifiedAppsInstallations = getVerifiedAppsInstallations( appsInstallations, installableMarketplaceApps, ); + const verifiedInstallableMarketplaceApps = getVerifiedInstallableMarketplaceApps( installedApps, installableMarketplaceApps, ); + const sectionsAvailability = resolveSectionsAvailability({ ...props, installableMarketplaceApps: verifiedInstallableMarketplaceApps, }); - const navigate = useNavigator(); - - const nothingInstalled = - appsInstallations?.length === 0 && installedApps?.length === 0; const navigateToAppInstallPage = useCallback( (manifestUrl: string) => { @@ -74,7 +78,11 @@ export const AppListPage: React.FC = props => { return ( <> - + {hasManagedAppsPermission && ( + + )} = props => { marginY={5} > - {nothingInstalled && ( - - - {intl.formatMessage(messages.installedApps)} - - - - {intl.formatMessage(messages.nothingInstalledPlaceholder)} - - - - )} - {sectionsAvailability.installed && ( - <> - - - {intl.formatMessage(messages.installedApps)} - - - - - )} + + + {intl.formatMessage(messages.installedApps)} + + + + {sectionsAvailability.all && !marketplaceError && ( diff --git a/src/apps/components/AppListPage/messages.ts b/src/apps/components/AppListPage/messages.ts index da6fff36866..ce5dba3d14f 100644 --- a/src/apps/components/AppListPage/messages.ts +++ b/src/apps/components/AppListPage/messages.ts @@ -27,9 +27,4 @@ export const messages = defineMessages({ defaultMessage: "Upcoming Apps", description: "section header", }, - nothingInstalledPlaceholder: { - defaultMessage: "Nothing installed yet.", - description: "placeholder", - id: "9g6Y7a", - }, }); diff --git a/src/apps/components/AppListPage/utils.test.ts b/src/apps/components/AppListPage/utils.test.ts index 783b53e9ed0..82235268dfb 100644 --- a/src/apps/components/AppListPage/utils.test.ts +++ b/src/apps/components/AppListPage/utils.test.ts @@ -33,7 +33,6 @@ describe("App List available sections util", () => { // Assert const expectedSectionsAvailability = { - installed: true, all: true, comingSoon: true, }; @@ -54,7 +53,6 @@ describe("App List available sections util", () => { // Assert const expectedSectionsAvailability = { - installed: false, all: false, comingSoon: false, }; @@ -75,7 +73,6 @@ describe("App List available sections util", () => { // Assert const expectedSectionsAvailability = { - installed: true, all: false, comingSoon: false, }; @@ -96,7 +93,6 @@ describe("App List available sections util", () => { // Assert const expectedSectionsAvailability = { - installed: true, all: false, comingSoon: false, }; @@ -117,7 +113,6 @@ describe("App List available sections util", () => { // Assert const expectedSectionsAvailability = { - installed: true, all: true, comingSoon: true, }; @@ -138,7 +133,6 @@ describe("App List available sections util", () => { // Assert const expectedSectionsAvailability = { - installed: true, all: false, comingSoon: false, }; @@ -159,7 +153,6 @@ describe("App List available sections util", () => { // Assert const expectedSectionsAvailability = { - installed: true, all: false, comingSoon: false, }; diff --git a/src/apps/components/AppListPage/utils.ts b/src/apps/components/AppListPage/utils.ts index 1e3d2221e6c..b970a08404c 100644 --- a/src/apps/components/AppListPage/utils.ts +++ b/src/apps/components/AppListPage/utils.ts @@ -8,16 +8,9 @@ import { import { AppListPageSections } from "./types"; export const resolveSectionsAvailability = ({ - appsInstallations, - installedApps, installableMarketplaceApps, comingSoonMarketplaceApps, }: AppListPageSections) => ({ - installed: - !installedApps || - !!installedApps.length || - !appsInstallations || - !!appsInstallations.length, all: !installableMarketplaceApps || !!installableMarketplaceApps.length, comingSoon: !comingSoonMarketplaceApps || !!comingSoonMarketplaceApps.length, }); diff --git a/src/apps/components/AppListRow/AppListCardActions.tsx b/src/apps/components/AppListRow/AppListCardActions.tsx index c27742839e1..957dbe92754 100644 --- a/src/apps/components/AppListRow/AppListCardActions.tsx +++ b/src/apps/components/AppListRow/AppListCardActions.tsx @@ -1,11 +1,10 @@ import { appInstallationStatusMessages } from "@dashboard/apps/messages"; -import { IS_CLOUD_INSTANCE } from "@dashboard/config"; import { AppInstallationFragment } from "@dashboard/graphql"; -import { buttonMessages } from "@dashboard/intl"; -import { Box, Button, Text, Tooltip } from "@saleor/macaw-ui-next"; +import { Box, Button, Text } from "@saleor/macaw-ui-next"; import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { FormattedMessage } from "react-intl"; +import { AppListCardInstallButton } from "./AppListCardInstallButton"; import InstallErrorAction from "./ErrorInstallAction"; import { messages } from "./messages"; @@ -28,8 +27,6 @@ const AppListCardActions: React.FC = ({ retryInstallHandler, removeInstallHandler, }) => { - const intl = useIntl(); - if ( !installHandler && !githubForkHandler && @@ -42,18 +39,7 @@ const AppListCardActions: React.FC = ({ } return ( - + {githubForkHandler && ( )} - {installHandler && IS_CLOUD_INSTANCE && ( - - )} - {installHandler && !IS_CLOUD_INSTANCE && ( - - - - - - - - - {intl.formatMessage(messages.installationCloudOnly)} - - + + {installHandler && ( + )} + {installationPending && ( = ({ app, }) => ( - + void; +} + +export const AppListCardInstallButton = ({ + installHandler, +}: AppListCardInstallButtonProps) => { + const intl = useIntl(); + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); + + if (!hasManagedAppsPermission) { + return ( + + + + ); + } + + if (IS_CLOUD_INSTANCE) { + return ( + + ); + } + + return ( + + + + + + ); +}; diff --git a/src/apps/components/AppListRow/AppListCardIntegrations.tsx b/src/apps/components/AppListRow/AppListCardIntegrations.tsx index e318e1df374..75150416470 100644 --- a/src/apps/components/AppListRow/AppListCardIntegrations.tsx +++ b/src/apps/components/AppListRow/AppListCardIntegrations.tsx @@ -24,12 +24,6 @@ const AppListCardIntegrations: React.FC = ({ flexWrap="wrap" gap={5} margin={0} - borderColor="default1" - borderLeftStyle="solid" - borderRightStyle="solid" - borderWidth={1} - paddingY={2} - paddingX={5} alignItems="start" > {integrations.map(integration => ( diff --git a/src/apps/components/AppListRow/AppListCardLinks.tsx b/src/apps/components/AppListRow/AppListCardLinks.tsx index 4db6d3fa22d..af176fd9a6c 100644 --- a/src/apps/components/AppListRow/AppListCardLinks.tsx +++ b/src/apps/components/AppListRow/AppListCardLinks.tsx @@ -13,19 +13,7 @@ const AppListCardLinks: React.FC = ({ links }) => { } return ( - + {links?.map(link => ( diff --git a/src/apps/components/AppListRow/AppListRow.test.tsx b/src/apps/components/AppListRow/AppListRow.test.tsx index 1fb9fa2f024..d232a322cbe 100644 --- a/src/apps/components/AppListRow/AppListRow.test.tsx +++ b/src/apps/components/AppListRow/AppListRow.test.tsx @@ -22,6 +22,12 @@ jest.mock("@dashboard/apps/context", () => ({ })), })); +jest.mock("@dashboard/hooks/useHasManagedAppsPermission", () => ({ + useHasManagedAppsPermission: jest.fn(() => ({ + hasManagedAppsPermission: true, + })), +})); + jest.mock("@dashboard/config", () => { const original = jest.requireActual("@dashboard/config"); @@ -32,9 +38,6 @@ jest.mock("@dashboard/config", () => { }; }); -const releasedAppPair = [releasedApp, releasedApp]; -const comingSoonAppPair = [comingSoonApp, comingSoonApp]; - describe("Apps AppListRow", () => { it("displays released app details when released app data passed", () => { // Arrange @@ -43,7 +46,7 @@ describe("Apps AppListRow", () => { ); render( - + , ); const name = screen.queryAllByText(releasedApp.name.en); @@ -75,7 +78,7 @@ describe("Apps AppListRow", () => { render( @@ -103,7 +106,7 @@ describe("Apps AppListRow", () => { ); render( - + , ); const name = screen.queryAllByText(comingSoonApp.name.en); @@ -136,7 +139,7 @@ describe("Apps AppListRow", () => { }; render( - + , ); const logo = screen.getAllByTestId("app-logo"); @@ -162,7 +165,7 @@ describe("Apps AppListRow", () => { }; render( - + , ); const logo = screen.getAllByTestId("app-logo"); @@ -182,7 +185,7 @@ describe("Apps AppListRow", () => { render( , @@ -201,7 +204,7 @@ describe("Apps AppListRow", () => { render( , @@ -228,7 +231,7 @@ describe("Apps AppListRow", () => { render( , diff --git a/src/apps/components/AppListRow/AppListRow.tsx b/src/apps/components/AppListRow/AppListRow.tsx index 944c406076e..98a186c5635 100644 --- a/src/apps/components/AppListRow/AppListRow.tsx +++ b/src/apps/components/AppListRow/AppListRow.tsx @@ -15,14 +15,14 @@ import AppListCardIntegrations from "./AppListCardIntegrations"; import AppListCardLinks from "./AppListCardLinks"; interface AppListRowProps { - appPair: AppstoreApi.SaleorApp[]; + app: AppstoreApi.SaleorApp; appInstallationList?: AppInstallationFragment[]; navigateToAppInstallPage?: (manifestUrl: string) => void; navigateToGithubForkPage?: (githubForkUrl: string) => void; } const AppListRow: React.FC = ({ - appPair, + app, appInstallationList, navigateToAppInstallPage, navigateToGithubForkPage, @@ -30,8 +30,6 @@ const AppListRow: React.FC = ({ const intl = useIntl(); const { retryAppInstallation, removeAppInstallation } = useAppListContext(); - const isSingleApp = appPair.length === 1; - const appDetails = React.useCallback( (app: AppstoreApi.SaleorApp) => getAppDetails({ @@ -56,56 +54,48 @@ const AppListRow: React.FC = ({ ], ); + const { + releaseDate, + installationPending, + installHandler, + githubForkHandler, + retryInstallHandler, + removeInstallHandler, + } = appDetails(app); + return ( - {appPair.map(app => ( - - ))} - {appPair.map(app => ( - - ))} - {appPair.map(app => { - if (appPair.every(app => !app.integrations?.length)) { - return null; - } - return ( - - ); - })} - {appPair.map(app => { - const { - releaseDate, - installationPending, - installHandler, - githubForkHandler, - retryInstallHandler, - removeInstallHandler, - } = appDetails(app); - return ( - - ); - })} + + + + + + + ); }; diff --git a/src/apps/components/AppPage/AppPage.tsx b/src/apps/components/AppPage/AppPage.tsx index 2d8bc7d935c..14aefdc59c2 100644 --- a/src/apps/components/AppPage/AppPage.tsx +++ b/src/apps/components/AppPage/AppPage.tsx @@ -43,6 +43,7 @@ export const AppPage: React.FC = ({ homepageUrl={data?.homepageUrl} author={data?.author} appLogoUrl={data?.brand?.logo.default} + showMangeAppButton={true} /> = ({ showMangeAppButton = true, }) => { const navigate = useNavigator(); + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); const navigateToManageAppScreen = () => { navigate(AppUrls.resolveAppDetailsUrl(appId)); @@ -92,9 +97,9 @@ export const AppPageNav: React.FC = ({ data-test-id="app-settings-button" > )} diff --git a/src/apps/components/AppPage/message.ts b/src/apps/components/AppPage/message.ts new file mode 100644 index 00000000000..12c31f3f9c9 --- /dev/null +++ b/src/apps/components/AppPage/message.ts @@ -0,0 +1,14 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + manageApp: { + id: "LwX0Ug", + defaultMessage: "Manage app", + description: "Button with Manage app label", + }, + appSettings: { + id: "S90DJO", + defaultMessage: "App settings", + description: "Button with app settings label", + }, +}); diff --git a/src/apps/components/AppPermissionsDialog/AppPermissionsDialog.tsx b/src/apps/components/AppPermissionsDialog/AppPermissionsDialog.tsx index 3b64b4c3d5e..ede98fed6f7 100644 --- a/src/apps/components/AppPermissionsDialog/AppPermissionsDialog.tsx +++ b/src/apps/components/AppPermissionsDialog/AppPermissionsDialog.tsx @@ -40,7 +40,10 @@ export const AppPermissionsDialog = ({ onApprove, } = useAppPermissionsDialogState(assignedPermissions); - const { refetch } = useAppQuery({ variables: { id: appId }, skip: true }); + const { refetch } = useAppQuery({ + variables: { id: appId, hasManagedAppsPermission: true }, + skip: true, + }); const notify = useNotifier(); diff --git a/src/apps/components/AppWebhooksDisplay/AppWebhooksDisplay.tsx b/src/apps/components/AppWebhooksDisplay/AppWebhooksDisplay.tsx index 36008ec7e2f..3572132b6a5 100644 --- a/src/apps/components/AppWebhooksDisplay/AppWebhooksDisplay.tsx +++ b/src/apps/components/AppWebhooksDisplay/AppWebhooksDisplay.tsx @@ -3,6 +3,7 @@ import { EventDeliveryStatusEnum, useAppWebhookDeliveriesQuery, } from "@dashboard/graphql"; +import { useHasManagedAppsPermission } from "@dashboard/hooks/useHasManagedAppsPermission"; import { Accordion, Box, @@ -123,8 +124,11 @@ export const AppWebhooksDisplay = ({ }: AppWebhooksDisplayProps) => { const { formatMessage } = useIntl(); + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); + const { data: webhooksData, loading } = useAppWebhookDeliveriesQuery({ variables: { appId }, + skip: !hasManagedAppsPermission, pollInterval: REFRESH_INTERVAL, }); diff --git a/src/apps/components/InstalledAppList/InstalledAppList.tsx b/src/apps/components/InstalledAppList/InstalledAppList.tsx index 4489b8e7485..bb4c6c53569 100644 --- a/src/apps/components/InstalledAppList/InstalledAppList.tsx +++ b/src/apps/components/InstalledAppList/InstalledAppList.tsx @@ -1,11 +1,15 @@ import { AppInstallation, InstalledApp } from "@dashboard/apps/types"; +import { useHasManagedAppsPermission } from "@dashboard/hooks/useHasManagedAppsPermission"; import { ListProps } from "@dashboard/types"; import { Skeleton } from "@material-ui/lab"; -import { List } from "@saleor/macaw-ui-next"; +import { Box, List, Text } from "@saleor/macaw-ui-next"; import React from "react"; +import { useIntl } from "react-intl"; import InstalledAppListRow from "../InstalledAppListRow"; import NotInstalledAppListRow from "../NotInstalledAppListRow"; +import { messages } from "./messages"; +import { appsAreLoading, hasEmptyAppList } from "./utils"; interface InstalledAppListProps extends ListProps { appList?: InstalledApp[]; @@ -16,10 +20,27 @@ const InstalledAppList: React.FC = ({ appList, appInstallationList, }) => { - if (!appList || !appInstallationList) { + const intl = useIntl(); + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); + + if ( + appsAreLoading({ appList, appInstallationList, hasManagedAppsPermission }) + ) { return ; } + if ( + hasEmptyAppList({ appList, appInstallationList, hasManagedAppsPermission }) + ) { + return ( + + + {intl.formatMessage(messages.nothingInstalledPlaceholder)} + + + ); + } + return ( {appInstallationList?.map(({ appInstallation, logo, isExternal }) => ( @@ -34,7 +55,7 @@ const InstalledAppList: React.FC = ({ } /> ))} - {appList.map(({ app, isExternal, logo }) => ( + {appList?.map(({ app, isExternal, logo }) => ( { + describe("appsAreLoading", () => { + describe("has MANAGE_APPS permission", () => { + const hasManagedAppsPermission = true; + + it("should return true when has apps installed and no app installations", () => { + // Arrange + const appList = [ + { + isExternal: false, + }, + ] as InstalledApp[]; + const appInstallationList = undefined; + + // Act + const isLoading = appsAreLoading({ + appList, + appInstallationList, + hasManagedAppsPermission, + }); + + // Assert + expect(isLoading).toBe(true); + }); + + it("should return true when has apps installations and no app installed", () => { + // Arrange + const appList = undefined; + const appInstallationList = [ + { + isExternal: false, + }, + ] as AppInstallation[]; + + // Act + const isLoading = appsAreLoading({ + appList, + appInstallationList, + hasManagedAppsPermission, + }); + + // Assert + expect(isLoading).toBe(true); + }); + + it("should return true when has no apps installed and no app installations", () => { + // Arrange + const appList = undefined; + const appInstallationList = undefined; + + // Act + const isLoading = appsAreLoading({ + appList, + appInstallationList, + hasManagedAppsPermission, + }); + + // Assert + expect(isLoading).toBe(true); + }); + + it("should return false when has apps installations and app installed", () => { + // Arrange + const appList: InstalledApp[] = []; + const appInstallationList: AppInstallation[] = []; + + // Act + const isLoading = appsAreLoading({ + appList, + appInstallationList, + hasManagedAppsPermission, + }); + + // Assert + expect(isLoading).toBe(false); + }); + }); + + describe("has no MANAGE_APPS permission", () => { + const hasManagedAppsPermission = false; + + it("should return true when has apps installed and ignore app installation", () => { + // Arrange + const appList = undefined; + const appInstallationList: AppInstallation[] = []; + + // Act + const isLoading = appsAreLoading({ + appList, + appInstallationList, + hasManagedAppsPermission, + }); + + // Assert + expect(isLoading).toBe(true); + }); + + it("should return false when has apps installed and ignore app installtion", () => { + // Arrange + const appList: InstalledApp[] = []; + const appInstallationList = undefined; + + // Act + const isLoading = appsAreLoading({ + appList, + appInstallationList, + hasManagedAppsPermission, + }); + + // Assert + expect(isLoading).toBe(false); + }); + }); + }); + + describe("appNotInstalled", () => { + describe("has MANAGE_APPS permission", () => { + const hasManagedAppsPermission = true; + it("should return true when has empty apps installed and empty app installations", () => { + // Arrange + const appList: InstalledApp[] = []; + const appInstallationList: AppInstallation[] = []; + + // Act + const showInstalledApps = hasEmptyAppList({ + appInstallationList, + appList, + hasManagedAppsPermission, + }); + + // Assert + expect(showInstalledApps).toBe(true); + }); + + it("should return false when has no apps installed and no app installations", () => { + // Arrange + const appList = undefined; + const appInstallationList = undefined; + + // Act + const showInstalledApps = hasEmptyAppList({ + appInstallationList, + appList, + hasManagedAppsPermission, + }); + + // Assert + expect(showInstalledApps).toBe(false); + }); + + it("should return false when has apps installations and no app installed", () => { + // Arrange + const appList: InstalledApp[] = []; + const appInstallationList = [ + { + isExternal: false, + }, + ] as AppInstallation[]; + + // Act + const showInstalledApps = hasEmptyAppList({ + appInstallationList, + appList, + hasManagedAppsPermission, + }); + + // Assert + expect(showInstalledApps).toBe(false); + }); + + it("should return false when has apps installed and no app installations", () => { + // Arrange + const appList = [ + { + isExternal: false, + }, + ] as InstalledApp[]; + const appInstallationList: AppInstallation[] = []; + + // Act + const showInstalledApps = hasEmptyAppList({ + appInstallationList, + appList, + hasManagedAppsPermission, + }); + + // Assert + expect(showInstalledApps).toBe(false); + }); + }); + + describe("has no MANAGE_APPS permission", () => { + const hasManagedAppsPermission = false; + + it("should return true when has empty apps installed", () => { + // Arrange + const appList: InstalledApp[] = []; + const appInstallationList = undefined; + + // Act + const showInstalledApps = hasEmptyAppList({ + appInstallationList, + appList, + hasManagedAppsPermission, + }); + + // Assert + expect(showInstalledApps).toBe(true); + }); + + it("should return false when has no apps installed and no app installations", () => { + // Arrange + const appList = undefined; + const appInstallationList = undefined; + + // Act + const showInstalledApps = hasEmptyAppList({ + appInstallationList, + appList, + hasManagedAppsPermission, + }); + + // Assert + expect(showInstalledApps).toBe(false); + }); + + it("should return false when has apps installed and no app installations", () => { + // Arrange + const appList = [ + { + isExternal: false, + }, + ] as InstalledApp[]; + const appInstallationList: AppInstallation[] = []; + + // Act + const showInstalledApps = hasEmptyAppList({ + appInstallationList, + appList, + hasManagedAppsPermission, + }); + + // Assert + expect(showInstalledApps).toBe(false); + }); + }); + }); +}); diff --git a/src/apps/components/InstalledAppList/utils.ts b/src/apps/components/InstalledAppList/utils.ts new file mode 100644 index 00000000000..65bc84a7566 --- /dev/null +++ b/src/apps/components/InstalledAppList/utils.ts @@ -0,0 +1,33 @@ +import { AppInstallation, InstalledApp } from "@dashboard/apps/types"; + +export function appsAreLoading({ + appInstallationList, + appList, + hasManagedAppsPermission, +}: { + appList?: InstalledApp[]; + appInstallationList?: AppInstallation[]; + hasManagedAppsPermission?: boolean; +}): boolean { + if (!hasManagedAppsPermission) { + return !appList; + } + + return !appList || !appInstallationList; +} + +export function hasEmptyAppList({ + appInstallationList, + hasManagedAppsPermission, + appList, +}: { + appList?: InstalledApp[]; + appInstallationList?: AppInstallation[]; + hasManagedAppsPermission?: boolean; +}): boolean { + if (!hasManagedAppsPermission) { + return appList?.length === 0; + } + + return appInstallationList?.length === 0 && appList?.length === 0; +} diff --git a/src/apps/mutations.ts b/src/apps/mutations.ts index 598774d8919..39d16157777 100644 --- a/src/apps/mutations.ts +++ b/src/apps/mutations.ts @@ -1,7 +1,10 @@ import { gql } from "@apollo/client"; export const appCreateMutation = gql` - mutation AppCreate($input: AppInput!) { + mutation AppCreate( + $input: AppInput! + $hasManagedAppsPermission: Boolean = true + ) { appCreate(input: $input) { authToken app { @@ -15,7 +18,7 @@ export const appCreateMutation = gql` `; export const appDeleteMutation = gql` - mutation AppDelete($id: ID!) { + mutation AppDelete($id: ID!, $hasManagedAppsPermission: Boolean = true) { appDelete(id: $id) { app { ...App @@ -89,7 +92,11 @@ export const appRetryInstallMutation = gql` `; export const appUpdateMutation = gql` - mutation AppUpdate($id: ID!, $input: AppInput!) { + mutation AppUpdate( + $id: ID! + $input: AppInput! + $hasManagedAppsPermission: Boolean = true + ) { appUpdate(id: $id, input: $input) { app { ...App diff --git a/src/apps/queries.ts b/src/apps/queries.ts index 8fbd58b715e..4bbc4ebc18a 100644 --- a/src/apps/queries.ts +++ b/src/apps/queries.ts @@ -42,7 +42,7 @@ export const appsInProgressList = gql` `; export const appDetails = gql` - query App($id: ID!) { + query App($id: ID!, $hasManagedAppsPermission: Boolean!) { app(id: $id) { ...App aboutApp diff --git a/src/apps/views/AppListView/AppListView.tsx b/src/apps/views/AppListView/AppListView.tsx index f5294bf87fd..bcaa8cb53f6 100644 --- a/src/apps/views/AppListView/AppListView.tsx +++ b/src/apps/views/AppListView/AppListView.tsx @@ -21,6 +21,7 @@ import { useAppsInstallationsQuery, useAppsListQuery, } from "@dashboard/graphql"; +import { useHasManagedAppsPermission } from "@dashboard/hooks/useHasManagedAppsPermission"; import useListSettings from "@dashboard/hooks/useListSettings"; import useLocalPaginator, { useLocalPaginationState, @@ -44,6 +45,7 @@ export const AppListView: React.FC = ({ params }) => { const navigate = useNavigator(); const notify = useNotifier(); const intl = useIntl(); + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); const [openModal, closeModal] = createDialogActionHandlers< AppListUrlDialog, @@ -86,6 +88,7 @@ export const AppListView: React.FC = ({ params }) => { const { data: appsInProgressData, refetch: appsInProgressRefetch } = useAppsInstallationsQuery({ displayLoader: false, + skip: !hasManagedAppsPermission, }); const installedAppNotify = (name: string) => { diff --git a/src/apps/views/AppManageView/AppManageView.tsx b/src/apps/views/AppManageView/AppManageView.tsx index 59f122aae1e..28327eca3c6 100644 --- a/src/apps/views/AppManageView/AppManageView.tsx +++ b/src/apps/views/AppManageView/AppManageView.tsx @@ -9,6 +9,7 @@ import { useAppDeleteMutation, useAppQuery, } from "@dashboard/graphql"; +import { useHasManagedAppsPermission } from "@dashboard/hooks/useHasManagedAppsPermission"; import useNavigator from "@dashboard/hooks/useNavigator"; import useNotifier from "@dashboard/hooks/useNotifier"; import getAppErrorMessage from "@dashboard/utils/errors/app"; @@ -34,9 +35,10 @@ interface Props { export const AppManageView: React.FC = ({ id, params }) => { const client = useApolloClient(); + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); const { data, loading, refetch } = useAppQuery({ displayLoader: true, - variables: { id }, + variables: { id, hasManagedAppsPermission }, }); const appExists = data?.app !== null; @@ -121,7 +123,7 @@ export const AppManageView: React.FC = ({ id, params }) => { const handleActivateConfirm = () => activateApp(mutationOpts); const handleDeactivateConfirm = () => deactivateApp(mutationOpts); - const handleRemoveConfirm = () => deleteApp(mutationOpts); + const handleRemoveConfirm = () => deleteApp({ ...mutationOpts }); if (!appExists) { return ; diff --git a/src/apps/views/AppPermissionRequestView/AppPermissionRequestView.tsx b/src/apps/views/AppPermissionRequestView/AppPermissionRequestView.tsx index 8401d140695..67c9cf8bf0a 100644 --- a/src/apps/views/AppPermissionRequestView/AppPermissionRequestView.tsx +++ b/src/apps/views/AppPermissionRequestView/AppPermissionRequestView.tsx @@ -66,6 +66,7 @@ export const AppPermissionRequestView = () => { const { data } = useAppQuery({ variables: { id: appId, + hasManagedAppsPermission: true, }, }); const [updatePermissions, { loading }] = useAppUpdatePermissionsMutation(); diff --git a/src/apps/views/AppView/AppView.tsx b/src/apps/views/AppView/AppView.tsx index 8137762cc37..29e5e210df4 100644 --- a/src/apps/views/AppView/AppView.tsx +++ b/src/apps/views/AppView/AppView.tsx @@ -3,6 +3,7 @@ import { appMessages } from "@dashboard/apps/messages"; import { AppPaths, AppUrls } from "@dashboard/apps/urls"; import NotFoundPage from "@dashboard/components/NotFoundPage"; import { useAppQuery } from "@dashboard/graphql"; +import { useHasManagedAppsPermission } from "@dashboard/hooks/useHasManagedAppsPermission"; import useNavigator from "@dashboard/hooks/useNavigator"; import useNotifier from "@dashboard/hooks/useNotifier"; import React, { useCallback } from "react"; @@ -15,9 +16,11 @@ interface AppProps { export const AppView: React.FC = ({ id }) => { const location = useLocation(); + const { hasManagedAppsPermission } = useHasManagedAppsPermission(); + const { data, refetch } = useAppQuery({ displayLoader: true, - variables: { id }, + variables: { id, hasManagedAppsPermission }, }); const appExists = data?.app !== null; diff --git a/src/components/ButtonWithTooltip/ButtonWithTooltip.tsx b/src/components/ButtonWithTooltip/ButtonWithTooltip.tsx new file mode 100644 index 00000000000..58ea974cb44 --- /dev/null +++ b/src/components/ButtonWithTooltip/ButtonWithTooltip.tsx @@ -0,0 +1,29 @@ +import { Button, ButtonProps, Tooltip } from "@saleor/macaw-ui-next"; +import React from "react"; + +interface ButtonWithTooltipProps extends ButtonProps { + tooltip?: React.ReactNode; + children: React.ReactNode; +} + +export const ButtonWithTooltip = ({ + tooltip, + children, + ...props +}: ButtonWithTooltipProps) => { + if (!tooltip) { + return ; + } + + return ( + + + + + + + {tooltip} + + + ); +}; diff --git a/src/components/ButtonWithTooltip/index.ts b/src/components/ButtonWithTooltip/index.ts new file mode 100644 index 00000000000..b93f8f0c654 --- /dev/null +++ b/src/components/ButtonWithTooltip/index.ts @@ -0,0 +1 @@ +export * from "./ButtonWithTooltip"; diff --git a/src/components/Sidebar/menu/useMenuStructure.tsx b/src/components/Sidebar/menu/useMenuStructure.tsx index 5918b8af57f..92f8695947f 100644 --- a/src/components/Sidebar/menu/useMenuStructure.tsx +++ b/src/components/Sidebar/menu/useMenuStructure.tsx @@ -56,7 +56,7 @@ export function useMenuStructure() { const getAppSection = (): SidebarMenuItem => ({ icon: , label: intl.formatMessage(sectionNames.apps), - permissions: [PermissionEnum.MANAGE_APPS], + permissions: [], id: "apps", url: AppPaths.appListPath, type: "item", diff --git a/src/custom-apps/views/CustomAppDetails/CustomAppDetails.tsx b/src/custom-apps/views/CustomAppDetails/CustomAppDetails.tsx index 33ae8939cad..4d7da09324d 100644 --- a/src/custom-apps/views/CustomAppDetails/CustomAppDetails.tsx +++ b/src/custom-apps/views/CustomAppDetails/CustomAppDetails.tsx @@ -67,7 +67,7 @@ export const CustomAppDetails: React.FC = ({ const { data, loading, refetch } = useAppQuery({ displayLoader: true, - variables: { id }, + variables: { id, hasManagedAppsPermission: true }, }); const [activateApp, activateAppResult] = useAppActivateMutation({ onCompleted: data => { diff --git a/src/custom-apps/views/CustomAppWebhookCreate.tsx b/src/custom-apps/views/CustomAppWebhookCreate.tsx index 879bc44124d..926a4d8448b 100644 --- a/src/custom-apps/views/CustomAppWebhookCreate.tsx +++ b/src/custom-apps/views/CustomAppWebhookCreate.tsx @@ -28,7 +28,9 @@ export const CustomAppWebhookCreate: React.FC = ({ const notify = useNotifier(); const intl = useIntl(); - const { data } = useAppQuery({ variables: { id: appId } }); + const { data } = useAppQuery({ + variables: { id: appId, hasManagedAppsPermission: true }, + }); const availableEvents = useAvailableEvents(); diff --git a/src/fragments/apps.ts b/src/fragments/apps.ts index da2a4616c41..c993035eb02 100644 --- a/src/fragments/apps.ts +++ b/src/fragments/apps.ts @@ -44,20 +44,20 @@ export const appFragment = gql` default(format: WEBP, size: 64) } } - privateMetadata { + privateMetadata @include(if: $hasManagedAppsPermission) { key value } - metadata { + metadata @include(if: $hasManagedAppsPermission) { key value } - tokens { + tokens @include(if: $hasManagedAppsPermission) { authToken id name } - webhooks { + webhooks @include(if: $hasManagedAppsPermission) { ...Webhook } } diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 37da5e2f845..fdfbcc2024e 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -59,20 +59,20 @@ export const AppFragmentDoc = gql` default(format: WEBP, size: 64) } } - privateMetadata { + privateMetadata @include(if: $hasManagedAppsPermission) { key value } - metadata { + metadata @include(if: $hasManagedAppsPermission) { key value } - tokens { + tokens @include(if: $hasManagedAppsPermission) { authToken id name } - webhooks { + webhooks @include(if: $hasManagedAppsPermission) { ...Webhook } } @@ -3371,7 +3371,7 @@ export const WebhookDetailsFragmentDoc = gql` } ${WebhookFragmentDoc}`; export const AppCreateDocument = gql` - mutation AppCreate($input: AppInput!) { + mutation AppCreate($input: AppInput!, $hasManagedAppsPermission: Boolean = true) { appCreate(input: $input) { authToken app { @@ -3400,6 +3400,7 @@ export type AppCreateMutationFn = Apollo.MutationFunction; export type AppCreateMutationOptions = Apollo.BaseMutationOptions; export const AppDeleteDocument = gql` - mutation AppDelete($id: ID!) { + mutation AppDelete($id: ID!, $hasManagedAppsPermission: Boolean = true) { appDelete(id: $id) { app { ...App @@ -3439,6 +3440,7 @@ export type AppDeleteMutationFn = Apollo.MutationFunction; export type AppRetryInstallMutationOptions = Apollo.BaseMutationOptions; export const AppUpdateDocument = gql` - mutation AppUpdate($id: ID!, $input: AppInput!) { + mutation AppUpdate($id: ID!, $input: AppInput!, $hasManagedAppsPermission: Boolean = true) { appUpdate(id: $id, input: $input) { app { ...App @@ -3647,6 +3649,7 @@ export type AppUpdateMutationFn = Apollo.MutationFunction; export type AppsInstallationsQueryResult = Apollo.QueryResult; export const AppDocument = gql` - query App($id: ID!) { + query App($id: ID!, $hasManagedAppsPermission: Boolean!) { app(id: $id) { ...App aboutApp @@ -3976,6 +3979,7 @@ export const AppDocument = gql` * const { data, loading, error } = useAppQuery({ * variables: { * id: // value for 'id' + * hasManagedAppsPermission: // value for 'hasManagedAppsPermission' * }, * }); */ diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index 4d66fb5e734..6dc67e35dd9 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -8861,17 +8861,19 @@ export enum WeightUnitsEnum { export type AppCreateMutationVariables = Exact<{ input: AppInput; + hasManagedAppsPermission?: InputMaybe; }>; -export type AppCreateMutation = { __typename: 'Mutation', appCreate: { __typename: 'AppCreate', authToken: string | null, app: { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null } | null, errors: Array<{ __typename: 'AppError', field: string | null, message: string | null, code: AppErrorCode, permissions: Array | null }> } | null }; +export type AppCreateMutation = { __typename: 'Mutation', appCreate: { __typename: 'AppCreate', authToken: string | null, app: { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens?: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks?: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null } | null, errors: Array<{ __typename: 'AppError', field: string | null, message: string | null, code: AppErrorCode, permissions: Array | null }> } | null }; export type AppDeleteMutationVariables = Exact<{ id: Scalars['ID']; + hasManagedAppsPermission?: InputMaybe; }>; -export type AppDeleteMutation = { __typename: 'Mutation', appDelete: { __typename: 'AppDelete', app: { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null } | null, errors: Array<{ __typename: 'AppError', field: string | null, message: string | null, code: AppErrorCode, permissions: Array | null }> } | null }; +export type AppDeleteMutation = { __typename: 'Mutation', appDelete: { __typename: 'AppDelete', app: { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens?: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks?: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null } | null, errors: Array<{ __typename: 'AppError', field: string | null, message: string | null, code: AppErrorCode, permissions: Array | null }> } | null }; export type AppDeleteFailedInstallationMutationVariables = Exact<{ id: Scalars['ID']; @@ -8904,10 +8906,11 @@ export type AppRetryInstallMutation = { __typename: 'Mutation', appRetryInstall: export type AppUpdateMutationVariables = Exact<{ id: Scalars['ID']; input: AppInput; + hasManagedAppsPermission?: InputMaybe; }>; -export type AppUpdateMutation = { __typename: 'Mutation', appUpdate: { __typename: 'AppUpdate', app: { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, permissions: Array<{ __typename: 'Permission', code: PermissionEnum, name: string }> | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null } | null, errors: Array<{ __typename: 'AppError', message: string | null, permissions: Array | null, field: string | null, code: AppErrorCode }> } | null }; +export type AppUpdateMutation = { __typename: 'Mutation', appUpdate: { __typename: 'AppUpdate', app: { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, permissions: Array<{ __typename: 'Permission', code: PermissionEnum, name: string }> | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens?: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks?: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null } | null, errors: Array<{ __typename: 'AppError', message: string | null, permissions: Array | null, field: string | null, code: AppErrorCode }> } | null }; export type AppTokenCreateMutationVariables = Exact<{ input: AppTokenInput; @@ -8964,10 +8967,11 @@ export type AppsInstallationsQuery = { __typename: 'Query', appsInstallations: A export type AppQueryVariables = Exact<{ id: Scalars['ID']; + hasManagedAppsPermission: Scalars['Boolean']; }>; -export type AppQuery = { __typename: 'Query', app: { __typename: 'App', aboutApp: string | null, author: string | null, dataPrivacy: string | null, dataPrivacyUrl: string | null, id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, permissions: Array<{ __typename: 'Permission', code: PermissionEnum, name: string }> | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null } | null }; +export type AppQuery = { __typename: 'Query', app: { __typename: 'App', aboutApp: string | null, author: string | null, dataPrivacy: string | null, dataPrivacyUrl: string | null, id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, permissions: Array<{ __typename: 'Permission', code: PermissionEnum, name: string }> | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens?: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks?: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null } | null }; export type ExtensionListQueryVariables = Exact<{ filter: AppExtensionFilterInput; @@ -9887,7 +9891,7 @@ export type AddressFragment = { __typename: 'Address', city: string, cityArea: s export type AppManifestFragment = { __typename: 'Manifest', identifier: string, version: string, about: string | null, name: string, appUrl: string | null, configurationUrl: string | null, tokenTargetUrl: string | null, dataPrivacy: string | null, dataPrivacyUrl: string | null, homepageUrl: string | null, supportUrl: string | null, permissions: Array<{ __typename: 'Permission', code: PermissionEnum, name: string }> | null, brand: { __typename: 'AppManifestBrand', logo: { __typename: 'AppManifestBrandLogo', default: string } } | null }; -export type AppFragment = { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null }; +export type AppFragment = { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null, privateMetadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata?: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens?: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks?: Array<{ __typename: 'Webhook', id: string, name: string | null, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null }; export type AppInstallationFragment = { __typename: 'AppInstallation', status: JobStatusEnum, message: string | null, appName: string, manifestUrl: string, id: string, brand: { __typename: 'AppBrand', logo: { __typename: 'AppBrandLogo', default: string } } | null }; diff --git a/src/hooks/useHasManagedAppsPermission.ts b/src/hooks/useHasManagedAppsPermission.ts new file mode 100644 index 00000000000..e407d73311b --- /dev/null +++ b/src/hooks/useHasManagedAppsPermission.ts @@ -0,0 +1,14 @@ +import { useUserPermissions } from "@dashboard/auth/hooks/useUserPermissions"; +import { PermissionEnum } from "@dashboard/graphql"; + +export const useHasManagedAppsPermission = () => { + const permissions = useUserPermissions(); + + const hasManagedAppsPermission: boolean = !!permissions?.find( + ({ code }) => code === PermissionEnum.MANAGE_APPS, + ); + + return { + hasManagedAppsPermission, + }; +}; diff --git a/src/index.tsx b/src/index.tsx index 06d9590f400..22f380dc926 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -284,7 +284,7 @@ const Routes: React.FC = () => { matchPermission="any" /> diff --git a/src/intl.ts b/src/intl.ts index 88c927b3955..7e5d740885e 100644 --- a/src/intl.ts +++ b/src/intl.ts @@ -411,6 +411,10 @@ export const buttonMessages = defineMessages({ id: "rbrahO", defaultMessage: "Close", }, + noPermission: { + id: "ORQvOg", + defaultMessage: "You don't have permission to perform this action", + }, }); export const sectionNames = defineMessages({