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:
parent
ad01400ab1
commit
fa6e469792
23 changed files with 511 additions and 15 deletions
5
.changeset/seven-carrots-hope.md
Normal file
5
.changeset/seven-carrots-hope.md
Normal 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
|
|
@ -1750,6 +1750,9 @@
|
||||||
"context": "notification",
|
"context": "notification",
|
||||||
"string": "Published pages"
|
"string": "Published pages"
|
||||||
},
|
},
|
||||||
|
"B+Ba0R": {
|
||||||
|
"string": "Requested Permissions"
|
||||||
|
},
|
||||||
"B/y6LC": {
|
"B/y6LC": {
|
||||||
"context": "section header",
|
"context": "section header",
|
||||||
"string": "Unfulfilled Products"
|
"string": "Unfulfilled Products"
|
||||||
|
@ -4152,6 +4155,9 @@
|
||||||
"context": "total price",
|
"context": "total price",
|
||||||
"string": "Total"
|
"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": {
|
"S22jIs": {
|
||||||
"context": "button",
|
"context": "button",
|
||||||
"string": "Set new password"
|
"string": "Set new password"
|
||||||
|
@ -4187,6 +4193,9 @@
|
||||||
"SHm7ee": {
|
"SHm7ee": {
|
||||||
"string": "Search by product name, attribute, product type etc..."
|
"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": {
|
"SKFr04": {
|
||||||
"string": "Attribute not found."
|
"string": "Attribute not found."
|
||||||
},
|
},
|
||||||
|
@ -4695,6 +4704,9 @@
|
||||||
"context": "weight units type",
|
"context": "weight units type",
|
||||||
"string": "Weight"
|
"string": "Weight"
|
||||||
},
|
},
|
||||||
|
"VkYZQ8": {
|
||||||
|
"string": "Current Permissions"
|
||||||
|
},
|
||||||
"VmMDLN": {
|
"VmMDLN": {
|
||||||
"context": "permission list item description",
|
"context": "permission list item description",
|
||||||
"string": "This group is last source of that permission"
|
"string": "This group is last source of that permission"
|
||||||
|
@ -4746,6 +4758,9 @@
|
||||||
"context": "product field",
|
"context": "product field",
|
||||||
"string": "Name"
|
"string": "Name"
|
||||||
},
|
},
|
||||||
|
"WCaf5C": {
|
||||||
|
"string": "Approve"
|
||||||
|
},
|
||||||
"WCg2GZ": {
|
"WCg2GZ": {
|
||||||
"context": "change warehouse dialog search placeholder",
|
"context": "change warehouse dialog search placeholder",
|
||||||
"string": "Search warehouses"
|
"string": "Search warehouses"
|
||||||
|
@ -5161,6 +5176,9 @@
|
||||||
"Yo2kC+": {
|
"Yo2kC+": {
|
||||||
"string": "Couldn't process image"
|
"string": "Couldn't process image"
|
||||||
},
|
},
|
||||||
|
"Yo4h/D": {
|
||||||
|
"string": "Learn more about permissions"
|
||||||
|
},
|
||||||
"YpLVVc": {
|
"YpLVVc": {
|
||||||
"context": "action",
|
"context": "action",
|
||||||
"string": "Exclude postal codes"
|
"string": "Exclude postal codes"
|
||||||
|
@ -5677,6 +5695,9 @@
|
||||||
"context": "button",
|
"context": "button",
|
||||||
"string": "Unassign and save"
|
"string": "Unassign and save"
|
||||||
},
|
},
|
||||||
|
"cOki0G": {
|
||||||
|
"string": "What happens if I deny?"
|
||||||
|
},
|
||||||
"cPAc45": {
|
"cPAc45": {
|
||||||
"context": "column picker search no results message",
|
"context": "column picker search no results message",
|
||||||
"string": "No results found"
|
"string": "No results found"
|
||||||
|
@ -6318,6 +6339,9 @@
|
||||||
"context": "table head",
|
"context": "table head",
|
||||||
"string": "Collection Name"
|
"string": "Collection Name"
|
||||||
},
|
},
|
||||||
|
"htvX+Z": {
|
||||||
|
"string": "Deny"
|
||||||
|
},
|
||||||
"hw9Fah": {
|
"hw9Fah": {
|
||||||
"context": "button",
|
"context": "button",
|
||||||
"string": "Send invite"
|
"string": "Send invite"
|
||||||
|
@ -6517,6 +6541,10 @@
|
||||||
"context": "description",
|
"context": "description",
|
||||||
"string": "No members found"
|
"string": "No members found"
|
||||||
},
|
},
|
||||||
|
"jVjsVq": {
|
||||||
|
"context": "App by Author",
|
||||||
|
"string": "by"
|
||||||
|
},
|
||||||
"jWna9Q": {
|
"jWna9Q": {
|
||||||
"string": "Content Type Name"
|
"string": "Content Type Name"
|
||||||
},
|
},
|
||||||
|
@ -6524,6 +6552,9 @@
|
||||||
"context": "product availability",
|
"context": "product availability",
|
||||||
"string": "Hide in product listings"
|
"string": "Hide in product listings"
|
||||||
},
|
},
|
||||||
|
"ja+tNj": {
|
||||||
|
"string": "requests access to new permissions."
|
||||||
|
},
|
||||||
"jd/LWa": {
|
"jd/LWa": {
|
||||||
"string": "Voucher applies to all countries"
|
"string": "Voucher applies to all countries"
|
||||||
},
|
},
|
||||||
|
@ -8024,6 +8055,10 @@
|
||||||
"context": "number of postal code ranges",
|
"context": "number of postal code ranges",
|
||||||
"string": "{number} postal code ranges"
|
"string": "{number} postal code ranges"
|
||||||
},
|
},
|
||||||
|
"udJUSa": {
|
||||||
|
"context": "Authorize {app name}",
|
||||||
|
"string": "Authorize"
|
||||||
|
},
|
||||||
"ufD5Jr": {
|
"ufD5Jr": {
|
||||||
"string": "Content type"
|
"string": "Content type"
|
||||||
},
|
},
|
||||||
|
@ -8271,6 +8306,9 @@
|
||||||
"context": "section header",
|
"context": "section header",
|
||||||
"string": "Plugin Information and Status"
|
"string": "Plugin Information and Status"
|
||||||
},
|
},
|
||||||
|
"w6kcxY": {
|
||||||
|
"string": "What happens if I approve?"
|
||||||
|
},
|
||||||
"w9xgN9": {
|
"w9xgN9": {
|
||||||
"context": "see error log label in notification",
|
"context": "see error log label in notification",
|
||||||
"string": "See error log"
|
"string": "See error log"
|
||||||
|
|
14
package-lock.json
generated
14
package-lock.json
generated
|
@ -116,7 +116,7 @@
|
||||||
"@graphql-codegen/typescript-react-apollo": "^3.2.5",
|
"@graphql-codegen/typescript-react-apollo": "^3.2.5",
|
||||||
"@percy/cli": "^1.21.0",
|
"@percy/cli": "^1.21.0",
|
||||||
"@percy/cypress": "^3.1.2",
|
"@percy/cypress": "^3.1.2",
|
||||||
"@saleor/app-sdk": "0.41.0",
|
"@saleor/app-sdk": "0.43.0",
|
||||||
"@swc/jest": "^0.2.26",
|
"@swc/jest": "^0.2.26",
|
||||||
"@types/apollo-upload-client": "^17.0.2",
|
"@types/apollo-upload-client": "^17.0.2",
|
||||||
"@types/color-convert": "^2.0.0",
|
"@types/color-convert": "^2.0.0",
|
||||||
|
@ -8758,9 +8758,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@saleor/app-sdk": {
|
"node_modules/@saleor/app-sdk": {
|
||||||
"version": "0.41.0",
|
"version": "0.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/@saleor/app-sdk/-/app-sdk-0.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@saleor/app-sdk/-/app-sdk-0.43.0.tgz",
|
||||||
"integrity": "sha512-9Yc73N8QvqfHlhsPzuWqwlbl+o/gq/VJ+HB2OUro68zTF+rapEssb79OUGLp2JmnTlM4xovCiPwPlDsW8nVSqw==",
|
"integrity": "sha512-hDZ/VNgz6vxeA1h4GtKYs2la8j8E2OocKWDK6HZYbsqDg83K+9LzFOIOHf7U/ZEmBgXR0Ezd0vuhmJBPKLC4jA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@changesets/cli": "^2.26.0",
|
"@changesets/cli": "^2.26.0",
|
||||||
|
@ -41238,9 +41238,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@saleor/app-sdk": {
|
"@saleor/app-sdk": {
|
||||||
"version": "0.41.0",
|
"version": "0.43.0",
|
||||||
"resolved": "https://registry.npmjs.org/@saleor/app-sdk/-/app-sdk-0.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/@saleor/app-sdk/-/app-sdk-0.43.0.tgz",
|
||||||
"integrity": "sha512-9Yc73N8QvqfHlhsPzuWqwlbl+o/gq/VJ+HB2OUro68zTF+rapEssb79OUGLp2JmnTlM4xovCiPwPlDsW8nVSqw==",
|
"integrity": "sha512-hDZ/VNgz6vxeA1h4GtKYs2la8j8E2OocKWDK6HZYbsqDg83K+9LzFOIOHf7U/ZEmBgXR0Ezd0vuhmJBPKLC4jA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@changesets/cli": "^2.26.0",
|
"@changesets/cli": "^2.26.0",
|
||||||
|
|
|
@ -123,7 +123,7 @@
|
||||||
"@graphql-codegen/typescript-react-apollo": "^3.2.5",
|
"@graphql-codegen/typescript-react-apollo": "^3.2.5",
|
||||||
"@percy/cli": "^1.21.0",
|
"@percy/cli": "^1.21.0",
|
||||||
"@percy/cypress": "^3.1.2",
|
"@percy/cypress": "^3.1.2",
|
||||||
"@saleor/app-sdk": "0.41.0",
|
"@saleor/app-sdk": "0.43.0",
|
||||||
"@swc/jest": "^0.2.26",
|
"@swc/jest": "^0.2.26",
|
||||||
"@types/apollo-upload-client": "^17.0.2",
|
"@types/apollo-upload-client": "^17.0.2",
|
||||||
"@types/color-convert": "^2.0.0",
|
"@types/color-convert": "^2.0.0",
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
AppInstallView,
|
AppInstallView,
|
||||||
AppListView,
|
AppListView,
|
||||||
AppManageView,
|
AppManageView,
|
||||||
|
AppPermissionRequestView,
|
||||||
AppView,
|
AppView,
|
||||||
} from "src/apps/views";
|
} from "src/apps/views";
|
||||||
|
|
||||||
|
@ -64,6 +65,11 @@ export const AppsSectionRoot = () => {
|
||||||
path={AppPaths.resolveAppDetailsPath(":id")}
|
path={AppPaths.resolveAppDetailsPath(":id")}
|
||||||
component={AppManageRoute}
|
component={AppManageRoute}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
exact
|
||||||
|
path={AppPaths.resolveRequestPermissionsPath(":id")}
|
||||||
|
component={AppPermissionRequestView}
|
||||||
|
/>
|
||||||
<Route path={AppPaths.resolveAppPath(":id")} component={AppViewRoute} />
|
<Route path={AppPaths.resolveAppPath(":id")} component={AppViewRoute} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
NotificationAction,
|
NotificationAction,
|
||||||
NotifyReady,
|
NotifyReady,
|
||||||
RedirectAction,
|
RedirectAction,
|
||||||
|
RequestPermissions,
|
||||||
UpdateRouting,
|
UpdateRouting,
|
||||||
} from "@saleor/app-sdk/app-bridge";
|
} from "@saleor/app-sdk/app-bridge";
|
||||||
import { useIntl } from "react-intl";
|
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 = {
|
export const AppActionsHandler = {
|
||||||
useHandleNotificationAction,
|
useHandleNotificationAction,
|
||||||
useHandleUpdateRoutingAction,
|
useHandleUpdateRoutingAction,
|
||||||
useHandleRedirectAction,
|
useHandleRedirectAction,
|
||||||
useNotifyReadyAction,
|
useNotifyReadyAction,
|
||||||
createResponseStatus,
|
createResponseStatus,
|
||||||
|
useHandlePermissionRequest,
|
||||||
};
|
};
|
||||||
|
|
|
@ -30,6 +30,8 @@ export const useAppActions = (
|
||||||
appToken,
|
appToken,
|
||||||
versions,
|
versions,
|
||||||
);
|
);
|
||||||
|
const { handle: handlePermissionRequest } =
|
||||||
|
AppActionsHandler.useHandlePermissionRequest(appId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store if app has performed a handshake with Dashboard, to avoid sending events before that
|
* Store if app has performed a handshake with Dashboard, to avoid sending events before that
|
||||||
|
@ -57,6 +59,9 @@ export const useAppActions = (
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
case "requestPermissions": {
|
||||||
|
return handlePermissionRequest(action)
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error("Unknown action type");
|
throw new Error("Unknown action type");
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { AppPermissionsDialogConfirmation } from "@dashboard/apps/components/App
|
||||||
import { AppPermissionsDialogPermissionPicker } from "@dashboard/apps/components/AppPermissionsDialog/AppPermissionsDialogPermissionPicker";
|
import { AppPermissionsDialogPermissionPicker } from "@dashboard/apps/components/AppPermissionsDialog/AppPermissionsDialogPermissionPicker";
|
||||||
import { useAppPermissionsDialogState } from "@dashboard/apps/components/AppPermissionsDialog/AppPermissionsDialogState";
|
import { useAppPermissionsDialogState } from "@dashboard/apps/components/AppPermissionsDialog/AppPermissionsDialogState";
|
||||||
import { AppPermissionsDialogMessages } from "@dashboard/apps/components/AppPermissionsDialog/messages";
|
import { AppPermissionsDialogMessages } from "@dashboard/apps/components/AppPermissionsDialog/messages";
|
||||||
import { useGetAvailableAppPermissions } from "@dashboard/apps/components/AppPermissionsDialog/useGetAvailableAppPermissions";
|
import { useGetAvailableAppPermissions } from "@dashboard/apps/hooks/useGetAvailableAppPermissions";
|
||||||
import {
|
import {
|
||||||
PermissionEnum,
|
PermissionEnum,
|
||||||
useAppQuery,
|
useAppQuery,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { AppPermissionsDialogMessages } from "@dashboard/apps/components/AppPermissionsDialog/messages";
|
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 { PermissionEnum } from "@dashboard/graphql";
|
||||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { getPermissionsDiff } from "@dashboard/apps/components/AppPermissionsDialog/getPermissionsDiff";
|
import { getPermissionsDiff } from "@dashboard/apps/getPermissionsDiff";
|
||||||
import { PermissionEnum } from "@dashboard/graphql";
|
import { PermissionEnum } from "@dashboard/graphql";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { getPermissionsDiff } from "@dashboard/apps/components/AppPermissionsDialog/getPermissionsDiff";
|
|
||||||
import { PermissionEnum } from "@dashboard/graphql";
|
import { PermissionEnum } from "@dashboard/graphql";
|
||||||
|
|
||||||
|
import { getPermissionsDiff } from "./getPermissionsDiff";
|
||||||
|
|
||||||
describe("getPermissionsDiff", () => {
|
describe("getPermissionsDiff", () => {
|
||||||
describe("Correctly resolves added permissions", () => {
|
describe("Correctly resolves added permissions", () => {
|
||||||
test("From empty to one new", () => {
|
test("From empty to one new", () => {
|
|
@ -1,8 +1,9 @@
|
||||||
import { useGetAvailableAppPermissions } from "@dashboard/apps/components/AppPermissionsDialog/useGetAvailableAppPermissions";
|
|
||||||
import { PermissionEnum } from "@dashboard/graphql";
|
import { PermissionEnum } from "@dashboard/graphql";
|
||||||
import useShop from "@dashboard/hooks/useShop";
|
import useShop from "@dashboard/hooks/useShop";
|
||||||
import { renderHook } from "@testing-library/react-hooks";
|
import { renderHook } from "@testing-library/react-hooks";
|
||||||
|
|
||||||
|
import { useGetAvailableAppPermissions } from "./useGetAvailableAppPermissions";
|
||||||
|
|
||||||
type PermissionsFromApi = Array<{
|
type PermissionsFromApi = Array<{
|
||||||
__typename: "Permission";
|
__typename: "Permission";
|
||||||
code: PermissionEnum;
|
code: PermissionEnum;
|
|
@ -7,7 +7,7 @@ export const useGetAvailableAppPermissions = () => {
|
||||||
/**
|
/**
|
||||||
* App can't have MANAGE_APPS so filter it out
|
* App can't have MANAGE_APPS so filter it out
|
||||||
*/
|
*/
|
||||||
const availablePermissions = shopData.permissions
|
const availablePermissions = shopData?.permissions
|
||||||
.filter(perm => perm.code !== "MANAGE_APPS")
|
.filter(perm => perm.code !== "MANAGE_APPS")
|
||||||
.map(p => ({
|
.map(p => ({
|
||||||
code: p.code,
|
code: p.code,
|
||||||
|
@ -41,5 +41,6 @@ export const useGetAvailableAppPermissions = () => {
|
||||||
return {
|
return {
|
||||||
availablePermissions,
|
availablePermissions,
|
||||||
mapCodesToNames,
|
mapCodesToNames,
|
||||||
|
isReady: !!shopData,
|
||||||
};
|
};
|
||||||
};
|
};
|
|
@ -47,6 +47,8 @@ export const AppPaths = {
|
||||||
resolveAppDeepPath: (id: string, subPath: string) =>
|
resolveAppDeepPath: (id: string, subPath: string) =>
|
||||||
urlJoin(AppPaths.resolveAppPath(id), subPath),
|
urlJoin(AppPaths.resolveAppPath(id), subPath),
|
||||||
appInstallPath: urlJoin(AppSections.appsSection, "install"),
|
appInstallPath: urlJoin(AppSections.appsSection, "install"),
|
||||||
|
resolveRequestPermissionsPath: (id: string) =>
|
||||||
|
urlJoin(AppSections.appsSection, id, "permissions"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AppUrls = {
|
export const AppUrls = {
|
||||||
|
@ -140,4 +142,20 @@ export const AppUrls = {
|
||||||
|
|
||||||
return urlJoin(appUrl, window.location.search, iframeContextQueryString);
|
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(","),
|
||||||
|
})}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
1
src/apps/views/AppPermissionRequestView/index.ts
Normal file
1
src/apps/views/AppPermissionRequestView/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./AppPermissionRequestView";
|
54
src/apps/views/AppPermissionRequestView/messages.ts
Normal file
54
src/apps/views/AppPermissionRequestView/messages.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -26,6 +26,9 @@ export const AppView: React.FC<AppProps> = ({ id }) => {
|
||||||
const notify = useNotifier();
|
const notify = useNotifier();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams(location.search);
|
||||||
|
const appPath = queryParams.get("appPath");
|
||||||
|
|
||||||
const handleError = useCallback(
|
const handleError = useCallback(
|
||||||
() =>
|
() =>
|
||||||
notify({
|
notify({
|
||||||
|
@ -39,12 +42,16 @@ export const AppView: React.FC<AppProps> = ({ id }) => {
|
||||||
return <NotFoundPage onBack={() => navigate(AppPaths.appListPath)} />;
|
return <NotFoundPage onBack={() => navigate(AppPaths.appListPath)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appCompleteUrl = AppUrls.resolveAppCompleteUrlFromDashboardUrl(
|
let appCompleteUrl = AppUrls.resolveAppCompleteUrlFromDashboardUrl(
|
||||||
location.pathname,
|
location.pathname,
|
||||||
data?.app?.appUrl || "",
|
data?.app?.appUrl || "",
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if(appPath) {
|
||||||
|
appCompleteUrl = `${appCompleteUrl}/${appPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (!data || !appCompleteUrl) {
|
if (!data || !appCompleteUrl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
export * from "./AppInstallView";
|
export * from "./AppInstallView";
|
||||||
export * from "./AppListView";
|
export * from "./AppListView";
|
||||||
export * from "./AppManageView";
|
export * from "./AppManageView";
|
||||||
|
export * from "./AppPermissionRequestView";
|
||||||
export * from "./AppView";
|
export * from "./AppView";
|
||||||
|
|
Loading…
Reference in a new issue