App permissions request flow (#3962)

* WIP - app permissions request flow

* Add copy to permissions view

* Add better ui

* Handle scenario when app already has permissions

* refactor urls

* tests

* Extract translations

* fix strict ts

* extract mesages

* Fix linter

* CR Fixes + handle case when user cant update app

* Extract messages
This commit is contained in:
Lukasz Ostrowski 2023-07-25 10:45:34 +02:00 committed by GitHub
parent ad01400ab1
commit fa6e469792
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 511 additions and 15 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-dashboard": minor
---
Now App can use app-sdk (with 0.43.0 version) to request new permissions from the dashboard user

View file

@ -1750,6 +1750,9 @@
"context": "notification",
"string": "Published pages"
},
"B+Ba0R": {
"string": "Requested Permissions"
},
"B/y6LC": {
"context": "section header",
"string": "Unfulfilled Products"
@ -4152,6 +4155,9 @@
"context": "total price",
"string": "Total"
},
"S1p0Ja": {
"string": "The app will have access to new permissions. From now on it will be able to use them to perform operations these permissions allow. You should ensure you trust the app before you approve."
},
"S22jIs": {
"context": "button",
"string": "Set new password"
@ -4187,6 +4193,9 @@
"SHm7ee": {
"string": "Search by product name, attribute, product type etc..."
},
"SI3/nl": {
"string": "Nothing will change in terms of permissions. The Dashboard will redirect to the app and inform it that you denied the request."
},
"SKFr04": {
"string": "Attribute not found."
},
@ -4695,6 +4704,9 @@
"context": "weight units type",
"string": "Weight"
},
"VkYZQ8": {
"string": "Current Permissions"
},
"VmMDLN": {
"context": "permission list item description",
"string": "This group is last source of that permission"
@ -4746,6 +4758,9 @@
"context": "product field",
"string": "Name"
},
"WCaf5C": {
"string": "Approve"
},
"WCg2GZ": {
"context": "change warehouse dialog search placeholder",
"string": "Search warehouses"
@ -5161,6 +5176,9 @@
"Yo2kC+": {
"string": "Couldn't process image"
},
"Yo4h/D": {
"string": "Learn more about permissions"
},
"YpLVVc": {
"context": "action",
"string": "Exclude postal codes"
@ -5677,6 +5695,9 @@
"context": "button",
"string": "Unassign and save"
},
"cOki0G": {
"string": "What happens if I deny?"
},
"cPAc45": {
"context": "column picker search no results message",
"string": "No results found"
@ -6318,6 +6339,9 @@
"context": "table head",
"string": "Collection Name"
},
"htvX+Z": {
"string": "Deny"
},
"hw9Fah": {
"context": "button",
"string": "Send invite"
@ -6517,6 +6541,10 @@
"context": "description",
"string": "No members found"
},
"jVjsVq": {
"context": "App by Author",
"string": "by"
},
"jWna9Q": {
"string": "Content Type Name"
},
@ -6524,6 +6552,9 @@
"context": "product availability",
"string": "Hide in product listings"
},
"ja+tNj": {
"string": "requests access to new permissions."
},
"jd/LWa": {
"string": "Voucher applies to all countries"
},
@ -8024,6 +8055,10 @@
"context": "number of postal code ranges",
"string": "{number} postal code ranges"
},
"udJUSa": {
"context": "Authorize {app name}",
"string": "Authorize"
},
"ufD5Jr": {
"string": "Content type"
},
@ -8271,6 +8306,9 @@
"context": "section header",
"string": "Plugin Information and Status"
},
"w6kcxY": {
"string": "What happens if I approve?"
},
"w9xgN9": {
"context": "see error log label in notification",
"string": "See error log"

14
package-lock.json generated
View file

@ -116,7 +116,7 @@
"@graphql-codegen/typescript-react-apollo": "^3.2.5",
"@percy/cli": "^1.21.0",
"@percy/cypress": "^3.1.2",
"@saleor/app-sdk": "0.41.0",
"@saleor/app-sdk": "0.43.0",
"@swc/jest": "^0.2.26",
"@types/apollo-upload-client": "^17.0.2",
"@types/color-convert": "^2.0.0",
@ -8758,9 +8758,9 @@
}
},
"node_modules/@saleor/app-sdk": {
"version": "0.41.0",
"resolved": "https://registry.npmjs.org/@saleor/app-sdk/-/app-sdk-0.41.0.tgz",
"integrity": "sha512-9Yc73N8QvqfHlhsPzuWqwlbl+o/gq/VJ+HB2OUro68zTF+rapEssb79OUGLp2JmnTlM4xovCiPwPlDsW8nVSqw==",
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/@saleor/app-sdk/-/app-sdk-0.43.0.tgz",
"integrity": "sha512-hDZ/VNgz6vxeA1h4GtKYs2la8j8E2OocKWDK6HZYbsqDg83K+9LzFOIOHf7U/ZEmBgXR0Ezd0vuhmJBPKLC4jA==",
"dev": true,
"dependencies": {
"@changesets/cli": "^2.26.0",
@ -41238,9 +41238,9 @@
}
},
"@saleor/app-sdk": {
"version": "0.41.0",
"resolved": "https://registry.npmjs.org/@saleor/app-sdk/-/app-sdk-0.41.0.tgz",
"integrity": "sha512-9Yc73N8QvqfHlhsPzuWqwlbl+o/gq/VJ+HB2OUro68zTF+rapEssb79OUGLp2JmnTlM4xovCiPwPlDsW8nVSqw==",
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/@saleor/app-sdk/-/app-sdk-0.43.0.tgz",
"integrity": "sha512-hDZ/VNgz6vxeA1h4GtKYs2la8j8E2OocKWDK6HZYbsqDg83K+9LzFOIOHf7U/ZEmBgXR0Ezd0vuhmJBPKLC4jA==",
"dev": true,
"requires": {
"@changesets/cli": "^2.26.0",

View file

@ -123,7 +123,7 @@
"@graphql-codegen/typescript-react-apollo": "^3.2.5",
"@percy/cli": "^1.21.0",
"@percy/cypress": "^3.1.2",
"@saleor/app-sdk": "0.41.0",
"@saleor/app-sdk": "0.43.0",
"@swc/jest": "^0.2.26",
"@types/apollo-upload-client": "^17.0.2",
"@types/color-convert": "^2.0.0",

View file

@ -11,6 +11,7 @@ import {
AppInstallView,
AppListView,
AppManageView,
AppPermissionRequestView,
AppView,
} from "src/apps/views";
@ -64,6 +65,11 @@ export const AppsSectionRoot = () => {
path={AppPaths.resolveAppDetailsPath(":id")}
component={AppManageRoute}
/>
<Route
exact
path={AppPaths.resolveRequestPermissionsPath(":id")}
component={AppPermissionRequestView}
/>
<Route path={AppPaths.resolveAppPath(":id")} component={AppViewRoute} />
</Switch>
</>

View file

@ -269,4 +269,26 @@ describe("AppActionsHandler", function () {
});
});
});
describe("useHandlePermissionRequest", () => {
it("Redirects to a dedicated page with params from action", () => {
const hookRenderResult = renderHook(() =>
AppActionsHandler.useHandlePermissionRequest("XYZ"),
);
hookRenderResult.result.current.handle({
type: "requestPermissions",
payload: {
actionId: "123",
permissions: ["MANAGE_ORDERS", "MANAGE_CHANNELS"],
redirectPath: "/permissions-result",
},
});
expect(mockNavigate).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith(
"/apps/XYZ/permissions?redirectPath=%2Fpermissions-result&requestedPermissions=MANAGE_ORDERS%2CMANAGE_CHANNELS"
);
});
});
});

View file

@ -11,6 +11,7 @@ import {
NotificationAction,
NotifyReady,
RedirectAction,
RequestPermissions,
UpdateRouting,
} from "@saleor/app-sdk/app-bridge";
import { useIntl } from "react-intl";
@ -208,10 +209,44 @@ const useNotifyReadyAction = (
};
};
const useHandlePermissionRequest = (appId: string) => {
const navigate = useNavigator();
return {
handle: (action: RequestPermissions) => {
const { actionId, permissions, redirectPath } = action.payload;
debug("Received RequestPermissions action");
if (permissions.length === 0) {
debug("Empty permissions array, skipping");
return createResponseStatus(actionId, false);
}
if (!redirectPath || redirectPath.length === 0) {
debug("Invalid path, skipping");
return createResponseStatus(actionId, false);
}
navigate(
AppUrls.resolveRequestPermissionsUrl(appId, {
redirectPath,
requestedPermissions: permissions,
}),
);
return createResponseStatus(actionId, true);
},
};
};
export const AppActionsHandler = {
useHandleNotificationAction,
useHandleUpdateRoutingAction,
useHandleRedirectAction,
useNotifyReadyAction,
createResponseStatus,
useHandlePermissionRequest,
};

View file

@ -30,6 +30,8 @@ export const useAppActions = (
appToken,
versions,
);
const { handle: handlePermissionRequest } =
AppActionsHandler.useHandlePermissionRequest(appId);
/**
* Store if app has performed a handshake with Dashboard, to avoid sending events before that
@ -57,6 +59,9 @@ export const useAppActions = (
return response;
}
case "requestPermissions": {
return handlePermissionRequest(action)
}
default: {
throw new Error("Unknown action type");
}

View file

@ -2,7 +2,7 @@ import { AppPermissionsDialogConfirmation } from "@dashboard/apps/components/App
import { AppPermissionsDialogPermissionPicker } from "@dashboard/apps/components/AppPermissionsDialog/AppPermissionsDialogPermissionPicker";
import { useAppPermissionsDialogState } from "@dashboard/apps/components/AppPermissionsDialog/AppPermissionsDialogState";
import { AppPermissionsDialogMessages } from "@dashboard/apps/components/AppPermissionsDialog/messages";
import { useGetAvailableAppPermissions } from "@dashboard/apps/components/AppPermissionsDialog/useGetAvailableAppPermissions";
import { useGetAvailableAppPermissions } from "@dashboard/apps/hooks/useGetAvailableAppPermissions";
import {
PermissionEnum,
useAppQuery,

View file

@ -1,5 +1,5 @@
import { AppPermissionsDialogMessages } from "@dashboard/apps/components/AppPermissionsDialog/messages";
import { useGetAvailableAppPermissions } from "@dashboard/apps/components/AppPermissionsDialog/useGetAvailableAppPermissions";
import { useGetAvailableAppPermissions } from "@dashboard/apps/hooks/useGetAvailableAppPermissions";
import { PermissionEnum } from "@dashboard/graphql";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React from "react";

View file

@ -1,4 +1,4 @@
import { getPermissionsDiff } from "@dashboard/apps/components/AppPermissionsDialog/getPermissionsDiff";
import { getPermissionsDiff } from "@dashboard/apps/getPermissionsDiff";
import { PermissionEnum } from "@dashboard/graphql";
import { useState } from "react";

View file

@ -1,6 +1,7 @@
import { getPermissionsDiff } from "@dashboard/apps/components/AppPermissionsDialog/getPermissionsDiff";
import { PermissionEnum } from "@dashboard/graphql";
import { getPermissionsDiff } from "./getPermissionsDiff";
describe("getPermissionsDiff", () => {
describe("Correctly resolves added permissions", () => {
test("From empty to one new", () => {

View file

@ -1,8 +1,9 @@
import { useGetAvailableAppPermissions } from "@dashboard/apps/components/AppPermissionsDialog/useGetAvailableAppPermissions";
import { PermissionEnum } from "@dashboard/graphql";
import useShop from "@dashboard/hooks/useShop";
import { renderHook } from "@testing-library/react-hooks";
import { useGetAvailableAppPermissions } from "./useGetAvailableAppPermissions";
type PermissionsFromApi = Array<{
__typename: "Permission";
code: PermissionEnum;

View file

@ -7,7 +7,7 @@ export const useGetAvailableAppPermissions = () => {
/**
* App can't have MANAGE_APPS so filter it out
*/
const availablePermissions = shopData.permissions
const availablePermissions = shopData?.permissions
.filter(perm => perm.code !== "MANAGE_APPS")
.map(p => ({
code: p.code,
@ -41,5 +41,6 @@ export const useGetAvailableAppPermissions = () => {
return {
availablePermissions,
mapCodesToNames,
isReady: !!shopData,
};
};

View file

@ -47,6 +47,8 @@ export const AppPaths = {
resolveAppDeepPath: (id: string, subPath: string) =>
urlJoin(AppPaths.resolveAppPath(id), subPath),
appInstallPath: urlJoin(AppSections.appsSection, "install"),
resolveRequestPermissionsPath: (id: string) =>
urlJoin(AppSections.appsSection, id, "permissions"),
};
export const AppUrls = {
@ -140,4 +142,20 @@ export const AppUrls = {
return urlJoin(appUrl, window.location.search, iframeContextQueryString);
},
resolveRequestPermissionsUrl: (
id: string,
params: {
requestedPermissions: string[];
redirectPath: string;
},
) =>
urlJoin(
AppSections.appsSection,
id,
"permissions",
`?${stringifyQs({
redirectPath: params.redirectPath,
requestedPermissions: params.requestedPermissions.join(","),
})}`,
),
};

View file

@ -0,0 +1,230 @@
import { createAppsDebug } from "@dashboard/apps/apps-debug";
import { getPermissionsDiff } from "@dashboard/apps/getPermissionsDiff";
import { useGetAvailableAppPermissions } from "@dashboard/apps/hooks/useGetAvailableAppPermissions";
import Link from "@dashboard/components/Link";
import {
PermissionEnum,
useAppQuery,
useAppUpdatePermissionsMutation,
} from "@dashboard/graphql";
import { Box, BoxProps, Button, Text, TextProps } from "@saleor/macaw-ui/next";
import React, { useEffect } from "react";
import { useIntl } from "react-intl";
import { useLocation, useParams } from "react-router";
import { appPermissionsRequestViewMessages } from "./messages";
import { usePermissionsRequestRedirects } from "./usePermissionsRequestRedirects";
const SmallText = (props: TextProps) => <Text size="small" {...props} />;
const SmallHeading = (props: TextProps) => (
<SmallText as="h2" variant="heading" {...props} />
);
const WrapperBox = (props: BoxProps) => (
<Box
marginX="auto"
marginY={12}
borderColor="neutralHighlight"
borderWidth={1}
borderStyle="solid"
padding={8}
__maxWidth={"600px"}
borderRadius={4}
{...props}
/>
);
function usePageQuery() {
const { search } = useLocation();
return React.useMemo(() => {
const params = new URLSearchParams(search);
const permissionsParams = params.get("requestedPermissions");
const requestedPermissions = permissionsParams
? (permissionsParams.split(",") as PermissionEnum[])
: [];
const redirectPath = params.get("redirectPath");
if (!redirectPath) throw new Error("Redirect path is required");
return {
requestedPermissions,
redirectPath,
};
}, [search]);
}
const debug = createAppsDebug("AppPermissionRequestView");
export const AppPermissionRequestView = () => {
const params = useParams<{ id: string }>();
const { redirectPath, requestedPermissions } = usePageQuery();
const { formatMessage } = useIntl();
const appId = params.id;
const { data } = useAppQuery({
variables: {
id: appId,
},
});
const [updatePermissions, { loading }] = useAppUpdatePermissionsMutation();
const { navigateToAppApproved, navigateToAppDenied } =
usePermissionsRequestRedirects({
appId,
redirectPath,
});
const { mapCodesToNames, isReady } = useGetAvailableAppPermissions();
useEffect(() => {
if (!data || !data.app || !isReady) return;
const diff = getPermissionsDiff(
(data.app.permissions ?? []).map(p => p.code),
requestedPermissions,
);
/**
* If app requests permissions that are already granted, redirect to app with success status
*/
if (diff.added.length === 0) navigateToAppApproved();
}, [data, requestedPermissions]);
if (!data || !isReady || !data.app) return null;
const onApprove = () => {
updatePermissions({
variables: {
id: appId,
permissions: [
...(data.app?.permissions ?? []).map(p => p.code),
...requestedPermissions,
],
},
})
.then(resp => {
const hasError = resp.data?.appUpdate?.errors?.length;
if (hasError) {
debug("Failed to update the app permissions");
return navigateToAppDenied("UPDATE_PERMISSIONS_FAILED");
}
return navigateToAppApproved();
})
.catch(err => {
debug("updatePermissions failed", err);
return navigateToAppDenied("UPDATE_PERMISSIONS_FAILED");
});
};
const onDeny = () => navigateToAppDenied("USER_DENIED_PERMISSIONS");
return (
<Box padding={12}>
<SmallText as="h1" variant="hero" textAlign="center">
{formatMessage(appPermissionsRequestViewMessages.headerAuthorize)}{" "}
{data.app.name}
</SmallText>
<WrapperBox>
<Box display="flex" gap={4}>
{data.app.brand?.logo.default && (
<Box as="img" __width={"50px"} src={data.app.brand.logo.default} />
)}
<Box>
<Text>
<Text variant="bodyStrong">{data.app.name}</Text>{" "}
{formatMessage(appPermissionsRequestViewMessages.by)}{" "}
{data.app.author}
</Text>
<Text as="p" color="textNeutralSubdued">
{formatMessage(
appPermissionsRequestViewMessages.requestsNewPermissions,
)}
</Text>
</Box>
</Box>
<Box marginY={8} display="grid" gridTemplateColumns={2} gap={6}>
<Box
borderRightStyle="solid"
borderWidth={1}
borderColor="neutralHighlight"
paddingRight={6}
>
<SmallHeading marginBottom={2}>
{formatMessage(
appPermissionsRequestViewMessages.currentPermissionsHeader,
)}
</SmallHeading>
{(data.app.permissions ?? []).map(permission => (
<Text as="p" key={permission.code}>
{permission.name}
</Text>
))}
</Box>
<Box>
<SmallHeading marginBottom={2}>
{formatMessage(
appPermissionsRequestViewMessages.requestedPermissionsHeader,
)}
</SmallHeading>
{mapCodesToNames(requestedPermissions).map(permissionName => (
<Text as="p" key={permissionName}>
{permissionName}
</Text>
))}
</Box>
</Box>
<Box
borderTopStyle="solid"
paddingTop={8}
borderWidth={1}
borderColor="neutralHighlight"
>
<SmallHeading marginBottom={2}>
{formatMessage(
appPermissionsRequestViewMessages.approveScenarioHelperHeader,
)}
</SmallHeading>
<SmallText>
{formatMessage(
appPermissionsRequestViewMessages.approveScenarioHelperBody,
)}
</SmallText>
<SmallText as="p">
<Link
target="__blank"
href="https://docs.saleor.io/docs/3.x/developer/permissions#app-permissions"
>
{formatMessage(
appPermissionsRequestViewMessages.permissionsDocsLink,
)}
</Link>
</SmallText>
<SmallHeading marginBottom={2} marginTop={4}>
{formatMessage(
appPermissionsRequestViewMessages.denyScenarioHelperHeader,
)}
</SmallHeading>
<SmallText>
{formatMessage(
appPermissionsRequestViewMessages.denyScenarioHelperBody,
)}
</SmallText>
</Box>
<Box display="flex" justifyContent="flex-end" gap={4} marginTop={12}>
<Button disabled={loading} onClick={onDeny} variant="secondary">
{formatMessage(appPermissionsRequestViewMessages.denyButton)}
</Button>
<Button disabled={loading} onClick={onApprove}>
{formatMessage(appPermissionsRequestViewMessages.approveButton)}
</Button>
</Box>
</WrapperBox>
</Box>
);
};

View file

@ -0,0 +1 @@
export * from "./AppPermissionRequestView";

View file

@ -0,0 +1,54 @@
import { defineMessages } from "react-intl";
export const appPermissionsRequestViewMessages = defineMessages({
headerAuthorize: {
defaultMessage: "Authorize",
description: "Authorize {app name}",
id: "udJUSa",
},
by: {
defaultMessage: "by",
description: "App by Author",
id: "jVjsVq",
},
requestsNewPermissions: {
defaultMessage: "requests access to new permissions.",
id: "ja+tNj",
},
currentPermissionsHeader: {
defaultMessage: "Current Permissions",
id: "VkYZQ8",
},
requestedPermissionsHeader: {
defaultMessage: "Requested Permissions",
id: 'B+Ba0R',
},
approveScenarioHelperHeader: {
defaultMessage: "What happens if I approve?",
id: "w6kcxY",
},
approveScenarioHelperBody: {
defaultMessage:
"The app will have access to new permissions. From now on it will be able to use them to perform operations these permissions allow. You should ensure you trust the app before you approve.",
id: 'S1p0Ja',
},
permissionsDocsLink: {
defaultMessage: "Learn more about permissions",
id: 'Yo4h/D'
},
denyScenarioHelperHeader: {
defaultMessage: "What happens if I deny?", id: 'cOki0G',
},
denyScenarioHelperBody: {
defaultMessage:
"Nothing will change in terms of permissions. The Dashboard will redirect to the app and inform it that you denied the request.", id: 'SI3/nl',
},
denyButton: {
defaultMessage: "Deny",
id: 'htvX+Z',
},
approveButton: {
defaultMessage: "Approve",
id: 'WCaf5C',
},
});

View file

@ -0,0 +1,38 @@
import { renderHook } from "@testing-library/react-hooks";
import { usePermissionsRequestRedirects } from "./usePermissionsRequestRedirects";
const mockNavigate = jest.fn();
jest.mock("@dashboard/hooks/useNavigator", () => () => mockNavigate);
describe("usePermissionsRequestRedirects", () => {
beforeEach(() => {
jest.resetAllMocks();
});
it("Navigates to redirect url provided by app - if approved", () => {
const { result } = renderHook(() =>
usePermissionsRequestRedirects({
appId: "XYZ",
redirectPath: "/permissions-request-result",
}),
);
result.current.navigateToAppApproved();
expect(mockNavigate).toHaveBeenCalledWith("/apps/XYZ/app?appPath=/permissions-request-result");
});
it("Navigates to redirect url provided by app and appends ?error - provided", () => {
const { result } = renderHook(() =>
usePermissionsRequestRedirects({
appId: "XYZ",
redirectPath: "/permissions-request-result",
}),
);
result.current.navigateToAppDenied('USER_DENIED_PERMISSIONS');
expect(mockNavigate).toHaveBeenCalledWith("/apps/XYZ/app?appPath=/permissions-request-result&error=USER_DENIED_PERMISSIONS");
});
});

View file

@ -0,0 +1,33 @@
import { AppPaths } from "@dashboard/apps/urls";
import useNavigator from "@dashboard/hooks/useNavigator";
type Errors = "USER_DENIED_PERMISSIONS" | "UPDATE_PERMISSIONS_FAILED";
export const usePermissionsRequestRedirects = ({
appId,
redirectPath,
}: {
appId: string;
redirectPath: string;
}) => {
const navigate = useNavigator();
const navigateToAppApproved = () => {
navigate(
AppPaths.resolveAppPath(encodeURIComponent(appId)) +
`?appPath=${redirectPath}`,
);
};
const navigateToAppDenied = (error: Errors) => {
navigate(
AppPaths.resolveAppPath(encodeURIComponent(appId)) +
`?appPath=${redirectPath}&error=${error}`,
);
};
return {
navigateToAppApproved,
navigateToAppDenied,
};
};

View file

@ -26,6 +26,9 @@ export const AppView: React.FC<AppProps> = ({ id }) => {
const notify = useNotifier();
const intl = useIntl();
const queryParams = new URLSearchParams(location.search);
const appPath = queryParams.get("appPath");
const handleError = useCallback(
() =>
notify({
@ -39,12 +42,16 @@ export const AppView: React.FC<AppProps> = ({ id }) => {
return <NotFoundPage onBack={() => navigate(AppPaths.appListPath)} />;
}
const appCompleteUrl = AppUrls.resolveAppCompleteUrlFromDashboardUrl(
let appCompleteUrl = AppUrls.resolveAppCompleteUrlFromDashboardUrl(
location.pathname,
data?.app?.appUrl || "",
id,
);
if(appPath) {
appCompleteUrl = `${appCompleteUrl}/${appPath}`;
}
if (!data || !appCompleteUrl) {
return null;
}

View file

@ -1,4 +1,5 @@
export * from "./AppInstallView";
export * from "./AppListView";
export * from "./AppManageView";
export * from "./AppPermissionRequestView";
export * from "./AppView";