Manual app permissions setting by the user (#3829)

* Remove react-markdown, render app about section as string

* Add modal with permissions - WIP

* Extract components

* add permissions diff

* add mutation

* add notification

* fix dialog scrolling

* Extract messages in permissions dialog

* test for useGetAvailableAppPermissions.ts

* add test to state

* fix enums

* add changeset and extract root messages

* Update wicked-berries-watch.md

* fix linter

* fix ts

* cr fixes
This commit is contained in:
Lukasz Ostrowski 2023-07-03 14:50:53 +02:00 committed by GitHub
parent 158b22d1ed
commit 1cb6e8b5fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1057 additions and 565 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-dashboard": minor
---
Add possibility to manually edit permissions of the app. Now every user with MANAGE_APPS permission can grant any permission to the app via App -> Manage App view or remove permissions previously assigned.

View file

@ -0,0 +1,5 @@
---
"saleor-dashboard": minor
---
App's "about" section will be rendered as a plain text, instead of a markdown

View file

@ -770,6 +770,9 @@
"3PVGWj": {
"string": "Filter preset"
},
"3SVI5p": {
"string": "Warning"
},
"3Sz1/t": {
"context": "dialog header",
"string": "Delete Pages"
@ -870,6 +873,9 @@
"context": "voucher discount",
"string": "Specific products"
},
"47hJzu": {
"string": "Updated app permissions"
},
"483Xnh": {
"context": "open full-screen",
"string": "Open"
@ -1250,6 +1256,9 @@
"6udlH+": {
"string": "Order draft successfully created"
},
"6uy2gU": {
"string": "Manually change permission for the app."
},
"6xC/Ls": {
"context": "informations about product seo, header",
"string": "SEO Information"
@ -3820,6 +3829,9 @@
"context": "description",
"string": "No results found"
},
"PlAdWI": {
"string": "You are going to"
},
"Pnj+JH": {
"context": "key-value field input",
"string": "Value"
@ -4234,6 +4246,9 @@
"context": "export filtered items to csv file",
"string": "Current search ({number})"
},
"SceSNp": {
"string": "Remove following permissions:"
},
"Sjd7wm": {
"context": "product filter label",
"string": "Product"
@ -5400,6 +5415,9 @@
"context": "error message",
"string": "Email address is not set"
},
"abpvEI": {
"string": "Removing permissions may cause app to break."
},
"ac+Y98": {
"context": "app settings error",
"string": "Failed to fetch app settings"
@ -5441,6 +5459,9 @@
"context": "range input label",
"string": "Postal codes (end)"
},
"azj0kR": {
"string": "Adding permission allows app to have more access to your data."
},
"b+jcaN": {
"string": "There are still fulfillments created for this order. Cancel the fulfillments first before you cancel the order."
},
@ -5666,6 +5687,9 @@
"context": "column picker search no results message",
"string": "No results found"
},
"cS1wAx": {
"string": "I know what I'm doing - confirm"
},
"cVjewM": {
"context": "label for radio button",
"string": "Product prices are entered with tax"
@ -6201,6 +6225,9 @@
"context": "voucher",
"string": "Times used"
},
"hAoqp6": {
"string": "Failed to save permissions. Refresh the page and try again."
},
"hHOI7D": {
"context": "product type name",
"string": "Type Name"
@ -6535,6 +6562,9 @@
"jvKNMP": {
"string": "Discount Code"
},
"jvo0vs": {
"string": "Save"
},
"jxoMLL": {
"context": "product field",
"string": "Collections"
@ -7190,6 +7220,9 @@
"oYGfnY": {
"string": "ZIP / Postal code"
},
"oboeOT": {
"string": "Add following permissions"
},
"of/+iV": {
"context": "transaction event type, refund was reversed, funds are back to store account",
"string": "Refund reversed"
@ -7206,6 +7239,9 @@
"context": "unassign product from sale and save, button",
"string": "Unassign and save"
},
"orvpWh": {
"string": "Go back"
},
"osPBn1": {
"context": "currency filter label",
"string": "Currency"
@ -7324,6 +7360,9 @@
"context": "checkbox label description",
"string": "Expiration date will be automatically set, once gift card is issued"
},
"psmnv9": {
"string": "Edit permissions"
},
"ptPPVk": {
"string": "No languages found"
},
@ -7550,6 +7589,9 @@
"context": "button",
"string": "Delete variant"
},
"rbrahO": {
"string": "Close"
},
"reP5Uf": {
"context": "global config plugin status popup description",
"string": "Global plugins are set across all channels in your ecommerce. Only status is shown for those types of plugins"
@ -8421,6 +8463,9 @@
"context": "section header",
"string": "Eligible Products"
},
"xrKHS6": {
"string": "Success"
},
"xrPv2K": {
"context": "by preposition",
"string": "by"

578
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -83,7 +83,6 @@
"react-inlinesvg": "^3.0.2",
"react-intl": "^5.21.2",
"react-jss": "^10.0.0",
"react-markdown": "^4.3.1",
"react-moment": "^1.0.0",
"react-responsive-carousel": "^3.2.23",
"react-router": "^5.0.1",

View file

@ -2,12 +2,11 @@ import Skeleton from "@dashboard/components/Skeleton";
import { Box, BoxProps, Text } from "@saleor/macaw-ui/next";
import React from "react";
import { useIntl } from "react-intl";
import ReactMarkdown from "react-markdown";
import messages from "./messages";
type AboutCardProps = {
aboutApp?: string | null;
aboutApp: string | null;
loading: boolean;
} & BoxProps;
@ -24,7 +23,7 @@ export const AboutCard: React.FC<AboutCardProps> = ({
}
if (aboutApp) {
return <ReactMarkdown source={aboutApp} />;
return <Text>{aboutApp}</Text>;
}
if (!aboutApp) {

View file

@ -45,6 +45,7 @@ export const AppDetailsPage: React.FC<AppDetailsPageProps> = ({
/>
<AboutCard margin={6} aboutApp={data?.aboutApp} loading={loading} />
<PermissionsCard
appId={data.id}
margin={6}
permissions={data?.permissions}
loading={loading}

View file

@ -1,7 +1,8 @@
import { AppPermissionsDialog } from "@dashboard/apps/components/AppPermissionsDialog";
import Skeleton from "@dashboard/components/Skeleton";
import { PermissionEnum } from "@dashboard/graphql";
import { Box, BoxProps, Text } from "@saleor/macaw-ui/next";
import React from "react";
import { Box, BoxProps, Button, Text } from "@saleor/macaw-ui/next";
import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import messages from "./messages";
@ -12,22 +13,42 @@ type PermissionsCardProps = {
code: PermissionEnum;
}> | null;
loading: boolean;
appId: string; // todo wrap with App Context
} & BoxProps;
export const PermissionsCard: React.FC<PermissionsCardProps> = ({
permissions,
loading,
appId,
...boxProps
}) => {
const [editPermissionDialogOpen, setEditPermissionDialogOpen] =
useState(false);
const intl = useIntl();
const editPermissionsButton = (
<Button
variant={"secondary"}
onClick={() => setEditPermissionDialogOpen(true)}
>
{intl.formatMessage(messages.editPermissionsButton)}
</Button>
);
const renderContent = () => {
if (loading) {
return <Skeleton />;
}
if (permissions && permissions.length === 0) {
return <Text>{intl.formatMessage(messages.appNoPermissions)}</Text>;
return (
<>
<Text marginBottom={4} as={"p"}>
{intl.formatMessage(messages.appNoPermissions)}
</Text>
{editPermissionsButton}
</>
);
}
if (permissions && permissions.length > 0) {
@ -43,6 +64,7 @@ export const PermissionsCard: React.FC<PermissionsCardProps> = ({
</Box>
))}
</Box>
{editPermissionsButton}
</>
);
}
@ -51,11 +73,20 @@ export const PermissionsCard: React.FC<PermissionsCardProps> = ({
};
return (
<>
{editPermissionDialogOpen && (
<AppPermissionsDialog
appId={appId}
onClose={() => setEditPermissionDialogOpen(false)}
assignedPermissions={permissions?.map(p => p.code) ?? []}
/>
)}
<Box {...boxProps}>
<Text variant={"heading"} marginBottom={4} as={"h2"}>
{intl.formatMessage(messages.appPermissionsTitle)}
</Text>
<Box>{renderContent()}</Box>
</Box>
</>
);
};

View file

@ -43,4 +43,8 @@ export default defineMessages({
id: "b088Xv",
defaultMessage: "App doesn't provide a description.",
},
editPermissionsButton: {
defaultMessage: "Edit permissions",
id: "psmnv9",
},
});

View file

@ -0,0 +1,149 @@
import { AppPermissionsDialogConfirmation } from "@dashboard/apps/components/AppPermissionsDialog/AppPermissionsDialogConfirmation";
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 {
PermissionEnum,
useAppQuery,
useAppUpdatePermissionsMutation,
} from "@dashboard/graphql";
import useNotifier from "@dashboard/hooks/useNotifier";
import { Dialog, DialogContent, DialogTitle } from "@material-ui/core";
import { Skeleton } from "@material-ui/lab";
import { Box, Text } from "@saleor/macaw-ui/next";
import React, { useEffect } from "react";
import { useIntl } from "react-intl";
const messages = AppPermissionsDialogMessages.dialogRoot;
interface AppPermissionsDialogProps {
onClose: () => void;
assignedPermissions: PermissionEnum[];
appId: string;
}
export const AppPermissionsDialog = ({
assignedPermissions,
onClose,
appId,
}: AppPermissionsDialogProps) => {
const { availablePermissions } = useGetAvailableAppPermissions();
const { formatMessage } = useIntl();
const {
updateSelected,
onConfirmSelection,
state,
onBackFromConfirmation,
selectedPermissions,
onMutationError,
onApprove,
} = useAppPermissionsDialogState(assignedPermissions);
const { refetch } = useAppQuery({ variables: { id: appId }, skip: true });
const notify = useNotifier();
const [mutate] = useAppUpdatePermissionsMutation({
onError(err) {
onMutationError(err.message);
},
onCompleted(data) {
if (data.appUpdate?.errors.length) {
onMutationError(
data.appUpdate?.errors[0].message ??
formatMessage(messages.fallbackErrorText),
);
return;
}
refetch().then(onClose);
notify({
status: "success",
title: formatMessage(messages.successNotificationTitle),
autohide: 1000,
text: formatMessage(messages.successNotificationBody),
});
},
});
useEffect(() => {
if (state.type === "saving") {
mutate({
variables: {
permissions: state.selected,
id: appId,
},
});
}
}, [state.type, appId]);
const renderDialogContent = () => {
switch (state.type) {
case "pick-permissions":
return (
<AppPermissionsDialogPermissionPicker
onClose={onClose}
onChange={updateSelected}
onSubmit={onConfirmSelection}
allPermissions={availablePermissions}
selected={selectedPermissions}
/>
);
case "confirm-permissions":
return (
<AppPermissionsDialogConfirmation
addedPermissions={state.addedPermissions}
removedPermissions={state.removedPermissions}
onApprove={onApprove}
onBack={onBackFromConfirmation}
/>
);
case "saving":
return <Skeleton />;
case "error":
return (
<Box padding={4}>
<Text as={"p"} color={"textCriticalDefault"}>
{state.error}
</Text>
</Box>
);
}
};
return (
<Dialog open={true} onClose={onClose} fullWidth maxWidth={"sm"}>
<DialogTitle disableTypography>
{formatMessage(messages.heading)}
</DialogTitle>
<DialogContent>
<Box display={"grid"} gridAutoFlow={"row"}>
<Text as={"p"}>{formatMessage(messages.info)}</Text>
<Box
borderRadius={2}
marginBottom={6}
marginTop={4}
padding={4}
backgroundColor={"surfaceCriticalSubdued"}
>
<Text
marginBottom={2}
as={"p"}
color={"textCriticalDefault"}
variant={"bodyStrong"}
>
{formatMessage(messages.warningHeading)}
</Text>
<Text as={"p"}>{formatMessage(messages.warningParagraph1)}</Text>
<Text as={"p"}>{formatMessage(messages.warningParagraph2)}</Text>
</Box>
{renderDialogContent()}
</Box>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,77 @@
import { AppPermissionsDialogMessages } from "@dashboard/apps/components/AppPermissionsDialog/messages";
import { useGetAvailableAppPermissions } from "@dashboard/apps/components/AppPermissionsDialog/useGetAvailableAppPermissions";
import { PermissionEnum } from "@dashboard/graphql";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React from "react";
import { useIntl } from "react-intl";
const messages = AppPermissionsDialogMessages.confirmation;
interface Props {
removedPermissions: PermissionEnum[];
addedPermissions: PermissionEnum[];
onBack(): void;
onApprove(): void;
}
export const AppPermissionsDialogConfirmation = ({
removedPermissions,
addedPermissions,
onBack,
onApprove,
}: Props) => {
const isPermissionsAdded = addedPermissions.length > 0;
const isPermissionsRemoved = removedPermissions.length > 0;
const intl = useIntl();
const { mapCodesToNames } = useGetAvailableAppPermissions();
return (
<Box>
<Text marginBottom={2} as={"p"}>
{intl.formatMessage(messages.summaryText)}
</Text>
{isPermissionsRemoved && (
<Box marginBottom={4}>
<Text variant={"bodyStrong"}>
{intl.formatMessage(messages.removePermissions)}
</Text>
{mapCodesToNames(removedPermissions).map(perm => (
<Text as={"p"} key={perm}>
{perm}
</Text>
))}
</Box>
)}
{isPermissionsAdded && (
<Box>
<Text variant={"bodyStrong"}>
{intl.formatMessage(messages.addPermissions)}
</Text>
{mapCodesToNames(addedPermissions).map(perm => (
<Text as={"p"} key={perm}>
{perm}
</Text>
))}
</Box>
)}
<Box display={"flex"} justifyContent={"flex-end"} gap={2} marginTop={6}>
<Button
variant={"tertiary"}
onClick={() => {
onBack();
}}
>
{intl.formatMessage(messages.backButton)}
</Button>
<Button
onClick={() => {
onApprove();
}}
>
{intl.formatMessage(messages.confirmButton)}
</Button>
</Box>
</Box>
);
};

View file

@ -0,0 +1,80 @@
import { AppPermissionsDialogMessages } from "@dashboard/apps/components/AppPermissionsDialog/messages";
import { AppPermission } from "@dashboard/apps/components/AppPermissionsDialog/types";
import { PermissionEnum } from "@dashboard/graphql";
import { Box, Button, Checkbox, List, Text } from "@saleor/macaw-ui/next";
import React from "react";
import { useIntl } from "react-intl";
const messages = AppPermissionsDialogMessages.permissionsPicker;
interface AppPermissionsDialogPermissionPickerProps {
allPermissions: AppPermission[];
selected: PermissionEnum[];
onSubmit(): void;
onChange(codes: PermissionEnum[]): void;
onClose(): void;
}
export const AppPermissionsDialogPermissionPicker = ({
onSubmit,
onChange,
allPermissions,
selected,
onClose,
}: AppPermissionsDialogPermissionPickerProps) => {
const intl = useIntl();
return (
<form
onSubmit={e => {
e.preventDefault();
onSubmit();
}}
onChange={e => {
const formdata = new FormData(e.currentTarget);
// @ts-expect-error - for some reason TS doesnt see keys, values, entries methods on formdata. TODO
const values = Array.from(formdata.keys()) as PermissionEnum[];
onChange(values);
}}
>
<List>
{allPermissions.map(perm => {
const isAssigned = Boolean(selected.find(p => p === perm.code));
return (
<List.Item
key={perm.code}
paddingY={1}
paddingX={2}
display={"flex"}
alignItems={"center"}
as={"label"}
backgroundColor={
isAssigned ? "decorativeSurfaceSubdued3" : undefined
}
>
<Checkbox
name={perm.code}
defaultChecked={isAssigned}
marginRight={4}
/>
<Text variant={isAssigned ? "bodyStrong" : "body"}>
{perm.name}
</Text>
</List.Item>
);
})}
</List>
<Box display={"flex"} justifyContent={"flex-end"} gap={2}>
<Button onClick={onClose} type={"button"} variant={"tertiary"}>
{intl.formatMessage(messages.closeButton)}
</Button>
<Button type={"submit"}>
{intl.formatMessage(messages.saveButton)}
</Button>
</Box>
</form>
);
};

View file

@ -0,0 +1,121 @@
import { useAppPermissionsDialogState } from "@dashboard/apps/components/AppPermissionsDialog/AppPermissionsDialogState";
import { PermissionEnum } from "@dashboard/graphql";
import { renderHook } from "@testing-library/react-hooks";
describe("useAppPermissionsDialogState", () => {
it("Creates state with initial permissions - empty", () => {
const {
result: { current },
} = renderHook(() => useAppPermissionsDialogState([]));
expect(current.state.type).toEqual("pick-permissions");
expect(current.state.selected).toEqual([]);
});
it("Creates state with initial permissions", () => {
const {
result: { current },
} = renderHook(() =>
useAppPermissionsDialogState([
PermissionEnum.MANAGE_CHANNELS,
PermissionEnum.MANAGE_ORDERS,
]),
);
expect(current.state.type).toEqual("pick-permissions");
expect(current.state.selected).toEqual([
"MANAGE_CHANNELS",
"MANAGE_ORDERS",
]);
});
describe("Transitions to confirmation screen with proper diff", () => {
test("One added permission", async () => {
const {
result: { current },
waitFor,
} = renderHook(() =>
useAppPermissionsDialogState([
PermissionEnum.MANAGE_CHANNELS,
PermissionEnum.MANAGE_ORDERS,
]),
);
current.updateSelected([
PermissionEnum.MANAGE_CHANNELS,
PermissionEnum.MANAGE_ORDERS,
PermissionEnum.HANDLE_CHECKOUTS,
]);
current.onConfirmSelection();
waitFor(() => {
expect(current.state.type).toEqual("confirm-permissions");
if (current.state.type === "confirm-permissions") {
expect(current.state.removedPermissions).toEqual([]);
expect(current.state.addedPermissions).toEqual(["HANDLE_CHECKOUTS"]);
} else {
throw new Error();
}
});
});
test("One removed permission", async () => {
const {
result: { current },
waitFor,
} = renderHook(() =>
useAppPermissionsDialogState([
PermissionEnum.MANAGE_CHANNELS,
PermissionEnum.MANAGE_ORDERS,
]),
);
current.updateSelected([PermissionEnum.MANAGE_CHANNELS]);
current.onConfirmSelection();
waitFor(() => {
expect(current.state.type).toEqual("confirm-permissions");
if (current.state.type === "confirm-permissions") {
expect(current.state.removedPermissions).toEqual(["MANAGE_ORDERS"]);
expect(current.state.addedPermissions).toEqual([""]);
} else {
throw new Error();
}
});
});
test("One added and one removed permission", async () => {
const {
result: { current },
waitFor,
} = renderHook(() =>
useAppPermissionsDialogState([
PermissionEnum.MANAGE_CHANNELS,
PermissionEnum.MANAGE_ORDERS,
]),
);
current.updateSelected([
PermissionEnum.MANAGE_CHANNELS,
PermissionEnum.MANAGE_CHECKOUTS,
]);
current.onConfirmSelection();
waitFor(() => {
expect(current.state.type).toEqual("confirm-permissions");
if (current.state.type === "confirm-permissions") {
expect(current.state.removedPermissions).toEqual(["MANAGE_ORDERS"]);
expect(current.state.addedPermissions).toEqual(["HANDLE_CHECKOUTS"]);
} else {
throw new Error();
}
});
});
});
});

View file

@ -0,0 +1,94 @@
import { getPermissionsDiff } from "@dashboard/apps/components/AppPermissionsDialog/getPermissionsDiff";
import { PermissionEnum } from "@dashboard/graphql";
import { useState } from "react";
type State =
| {
type: "pick-permissions";
selected: PermissionEnum[];
}
| {
type: "confirm-permissions";
selected: PermissionEnum[];
addedPermissions: PermissionEnum[];
removedPermissions: PermissionEnum[];
}
| {
selected: PermissionEnum[];
type: "saving";
}
| {
selected: PermissionEnum[];
type: "error";
error: string;
};
export const useAppPermissionsDialogState = (
initialPermissions: PermissionEnum[],
) => {
const [state, setState] = useState<State>({
type: "pick-permissions",
selected: initialPermissions,
});
return {
state,
stateType: state.type,
selectedPermissions: state.selected,
updateSelected(newPermissions: PermissionEnum[]) {
if (state.type !== "pick-permissions") {
throw new Error("Invalid state");
}
setState({
type: "pick-permissions",
selected: newPermissions,
});
},
onConfirmSelection() {
if (state.type !== "pick-permissions") {
throw new Error("Invalid state");
}
const diff = getPermissionsDiff(initialPermissions, state.selected);
setState({
type: "confirm-permissions",
selected: state.selected,
addedPermissions: diff.added,
removedPermissions: diff.removed,
});
},
onApprove() {
if (state.type !== "confirm-permissions") {
throw new Error("Invalid state");
}
setState({
type: "saving",
selected: state.selected,
});
},
onBackFromConfirmation() {
if (state.type !== "confirm-permissions") {
throw new Error("Invalid state");
}
setState({
type: "pick-permissions",
selected: state.selected,
});
},
onMutationError(message: string) {
if (state.type !== "saving") {
throw new Error("Invalid state");
}
setState({
type: "error",
error: message,
selected: state.selected,
});
},
};
};

View file

@ -0,0 +1,83 @@
import { getPermissionsDiff } from "@dashboard/apps/components/AppPermissionsDialog/getPermissionsDiff";
import { PermissionEnum } from "@dashboard/graphql";
describe("getPermissionsDiff", () => {
describe("Correctly resolves added permissions", () => {
test("From empty to one new", () => {
const { added, removed } = getPermissionsDiff(
[],
[PermissionEnum.HANDLE_CHECKOUTS],
);
expect(added).toEqual([PermissionEnum.HANDLE_CHECKOUTS]);
expect(removed).toEqual([]);
});
test("From 0 to 3 new", () => {
const { added, removed } = getPermissionsDiff(
[],
[
PermissionEnum.IMPERSONATE_USER,
PermissionEnum.HANDLE_PAYMENTS,
PermissionEnum.MANAGE_APPS,
],
);
expect(added).toEqual([
PermissionEnum.IMPERSONATE_USER,
PermissionEnum.HANDLE_PAYMENTS,
PermissionEnum.MANAGE_APPS,
]);
expect(removed).toEqual([]);
});
test("From 1 to 2 new and 1 existing", () => {
const { added, removed } = getPermissionsDiff(
[PermissionEnum.HANDLE_CHECKOUTS],
[
PermissionEnum.HANDLE_CHECKOUTS,
PermissionEnum.HANDLE_PAYMENTS,
PermissionEnum.MANAGE_APPS,
],
);
expect(added).toEqual([
PermissionEnum.HANDLE_PAYMENTS,
PermissionEnum.MANAGE_APPS,
]);
expect(removed).toEqual([]);
});
});
describe("Correctly resolves removed permissions", () => {
test("Doesnt remove anything if the same", () => {
const { added, removed } = getPermissionsDiff(
[PermissionEnum.HANDLE_CHECKOUTS],
[PermissionEnum.HANDLE_CHECKOUTS],
);
expect(added).toEqual([]);
expect(removed).toEqual([]);
});
test("Removes one", () => {
const { added, removed } = getPermissionsDiff(
[PermissionEnum.HANDLE_CHECKOUTS],
[],
);
expect(added).toEqual([]);
expect(removed).toEqual([PermissionEnum.HANDLE_CHECKOUTS]);
});
});
test("Removes one and adds ", () => {
const { added, removed } = getPermissionsDiff(
[PermissionEnum.HANDLE_CHECKOUTS, PermissionEnum.HANDLE_PAYMENTS],
[PermissionEnum.HANDLE_CHECKOUTS, PermissionEnum.HANDLE_TAXES],
);
expect(added).toEqual([PermissionEnum.HANDLE_TAXES]);
expect(removed).toEqual([PermissionEnum.HANDLE_PAYMENTS]);
});
});

View file

@ -0,0 +1,18 @@
import { PermissionEnum } from "@dashboard/graphql";
import difference from "lodash/difference";
export const getPermissionsDiff = (
initialPermissionsCodes: PermissionEnum[],
newPermissionsCodes: PermissionEnum[],
): {
added: PermissionEnum[];
removed: PermissionEnum[];
} => {
const removed = difference(initialPermissionsCodes, newPermissionsCodes);
const added = difference(newPermissionsCodes, initialPermissionsCodes);
return {
added,
removed,
};
};

View file

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

View file

@ -0,0 +1,78 @@
import { defineMessages } from "react-intl";
const confirmation = defineMessages({
summaryText: {
defaultMessage: "You are going to",
id: "PlAdWI",
},
addPermissions: {
defaultMessage: "Add following permissions",
id: "oboeOT",
},
removePermissions: {
defaultMessage: "Remove following permissions:",
id: "SceSNp",
},
backButton: {
defaultMessage: "Go back",
id: "orvpWh",
},
confirmButton: {
defaultMessage: "I know what I'm doing - confirm",
id: "cS1wAx",
},
});
const permissionsPicker = defineMessages({
closeButton: {
defaultMessage: "Close",
id: "rbrahO",
},
saveButton: {
defaultMessage: "Save",
id: "jvo0vs",
},
});
const dialogRoot = defineMessages({
heading: {
defaultMessage: "Edit permissions",
id: "psmnv9",
},
info: {
defaultMessage: "Manually change permission for the app.",
id: "6uy2gU",
},
warningHeading: {
defaultMessage: "Warning",
id: "3SVI5p",
},
warningParagraph1: {
defaultMessage:
"Adding permission allows app to have more access to your data.",
id: "azj0kR",
},
warningParagraph2: {
defaultMessage: "Removing permissions may cause app to break.",
id: "abpvEI",
},
successNotificationTitle: {
defaultMessage: "Success",
id: "xrKHS6",
},
successNotificationBody: {
defaultMessage: "Updated app permissions",
id: "47hJzu",
},
fallbackErrorText: {
defaultMessage:
"Failed to save permissions. Refresh the page and try again.",
id: "hAoqp6",
},
});
export const AppPermissionsDialogMessages = {
confirmation,
permissionsPicker,
dialogRoot,
};

View file

@ -0,0 +1,6 @@
import { PermissionEnum } from "@dashboard/graphql";
export interface AppPermission {
code: PermissionEnum;
name: string;
}

View file

@ -0,0 +1,103 @@
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";
type PermissionsFromApi = Array<{
__typename: "Permission";
code: PermissionEnum;
name: string;
}>;
const getMockUseShopHookResult = () => {
const permissions: PermissionsFromApi = [
{
__typename: "Permission",
name: "Manage Orders",
code: PermissionEnum.MANAGE_ORDERS,
},
{
__typename: "Permission",
code: PermissionEnum.HANDLE_TAXES,
name: "Handle Taxes",
},
{
__typename: "Permission",
code: PermissionEnum.MANAGE_CHANNELS,
name: "Manage Channels",
},
{
__typename: "Permission",
code: PermissionEnum.MANAGE_APPS,
name: "Manage Apps",
},
];
return {
permissions,
};
};
jest.mock("@dashboard/hooks/useShop");
describe("useGetAvailableAppPermissions", () => {
beforeEach(() => {
(useShop as jest.Mock).mockImplementationOnce(getMockUseShopHookResult);
});
it("Exposes permissons provided from useShop hook", () => {
const hookResult = renderHook(() => useGetAvailableAppPermissions());
expect(hookResult.result.current.availablePermissions).toEqual([
{
name: "Manage Orders",
code: PermissionEnum.MANAGE_ORDERS,
},
{
name: "Handle Taxes",
code: PermissionEnum.HANDLE_TAXES,
},
{
code: PermissionEnum.MANAGE_CHANNELS,
name: "Manage Channels",
},
]);
});
it("Filters out MANAGE_APPS permission, because app should not have one", () => {
const hookResult = renderHook(() => useGetAvailableAppPermissions());
const resultPermissions = hookResult.result.current.availablePermissions;
const manageAppsPermission = resultPermissions.find(
perm => perm.code === PermissionEnum.MANAGE_APPS,
);
expect(manageAppsPermission).toBeUndefined();
});
describe("mapCodesToNames method", () => {
it("Maps provided code enums and returns its names from the API", () => {
const hookResult = renderHook(() => useGetAvailableAppPermissions());
const resultNames = hookResult.result.current.mapCodesToNames([
PermissionEnum.MANAGE_ORDERS,
PermissionEnum.HANDLE_TAXES,
]);
expect(resultNames).toEqual(["Manage Orders", "Handle Taxes"]);
});
it("Throws if useShop is not available", () => {
jest.resetAllMocks();
(useShop as jest.Mock).mockImplementationOnce(() => undefined);
const hookResult = renderHook(() => useGetAvailableAppPermissions());
expect(() =>
hookResult.result.current.mapCodesToNames([
PermissionEnum.MANAGE_ORDERS,
]),
).toThrow();
});
});
});

View file

@ -0,0 +1,45 @@
import { PermissionEnum } from "@dashboard/graphql";
import useShop from "@dashboard/hooks/useShop";
export const useGetAvailableAppPermissions = () => {
const shopData = useShop();
/**
* App can't have MANAGE_APPS so filter it out
*/
const availablePermissions = shopData.permissions
.filter(perm => perm.code !== "MANAGE_APPS")
.map(p => ({
code: p.code,
name: p.name,
}));
const mapCodesToNames = (codes: PermissionEnum[]) => {
const permissions = shopData?.permissions;
if (!permissions) {
throw new Error(
"Shop data from useShop hook is not available. mapCodesToNames method must be used after query resolves",
);
}
return codes.map(c => {
const relatedPermission = permissions.find(p => {
return p.code === c;
});
if (!relatedPermission) {
throw new Error(
"Trying to match permission enum from app that doesnt match available permissions from API",
);
}
return relatedPermission.name;
});
};
return {
availablePermissions,
mapCodesToNames,
};
};

View file

@ -8,13 +8,13 @@ import { FormattedMessage, useIntl } from "react-intl";
import { messages } from "./messages";
import { useStyles } from "./styles";
interface Props {
interface InstallWithManifestFormButtonProps {
onSubmitted(manifestUrl: string): void;
}
export const InstallWithManifestFormButton: React.FC<Props> = ({
onSubmitted,
}) => {
export const InstallWithManifestFormButton: React.FC<
InstallWithManifestFormButtonProps
> = ({ onSubmitted }) => {
const styles = useStyles();
const intl = useIntl();

View file

@ -157,3 +157,19 @@ export const appDeactivateMutation = gql`
}
}
`;
export const appUpdatePermissions = gql`
mutation AppUpdatePermissions($id: ID!, $permissions: [PermissionEnum!]!) {
appUpdate(id: $id, input: { permissions: $permissions }) {
app {
permissions {
code
name
}
}
errors {
message
}
}
}
`;

View file

@ -3585,6 +3585,48 @@ export function useAppDeactivateMutation(baseOptions?: ApolloReactHooks.Mutation
export type AppDeactivateMutationHookResult = ReturnType<typeof useAppDeactivateMutation>;
export type AppDeactivateMutationResult = Apollo.MutationResult<Types.AppDeactivateMutation>;
export type AppDeactivateMutationOptions = Apollo.BaseMutationOptions<Types.AppDeactivateMutation, Types.AppDeactivateMutationVariables>;
export const AppUpdatePermissionsDocument = gql`
mutation AppUpdatePermissions($id: ID!, $permissions: [PermissionEnum!]!) {
appUpdate(id: $id, input: {permissions: $permissions}) {
app {
permissions {
code
name
}
}
errors {
message
}
}
}
`;
export type AppUpdatePermissionsMutationFn = Apollo.MutationFunction<Types.AppUpdatePermissionsMutation, Types.AppUpdatePermissionsMutationVariables>;
/**
* __useAppUpdatePermissionsMutation__
*
* To run a mutation, you first call `useAppUpdatePermissionsMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useAppUpdatePermissionsMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [appUpdatePermissionsMutation, { data, loading, error }] = useAppUpdatePermissionsMutation({
* variables: {
* id: // value for 'id'
* permissions: // value for 'permissions'
* },
* });
*/
export function useAppUpdatePermissionsMutation(baseOptions?: ApolloReactHooks.MutationHookOptions<Types.AppUpdatePermissionsMutation, Types.AppUpdatePermissionsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return ApolloReactHooks.useMutation<Types.AppUpdatePermissionsMutation, Types.AppUpdatePermissionsMutationVariables>(AppUpdatePermissionsDocument, options);
}
export type AppUpdatePermissionsMutationHookResult = ReturnType<typeof useAppUpdatePermissionsMutation>;
export type AppUpdatePermissionsMutationResult = Apollo.MutationResult<Types.AppUpdatePermissionsMutation>;
export type AppUpdatePermissionsMutationOptions = Apollo.BaseMutationOptions<Types.AppUpdatePermissionsMutation, Types.AppUpdatePermissionsMutationVariables>;
export const AppsListDocument = gql`
query AppsList($before: String, $after: String, $first: Int, $last: Int, $sort: AppSortingInput, $filter: AppFilterInput) {
apps(

View file

@ -7968,6 +7968,14 @@ export type AppDeactivateMutationVariables = Exact<{
export type AppDeactivateMutation = { __typename: 'Mutation', appDeactivate: { __typename: 'AppDeactivate', errors: Array<{ __typename: 'AppError', field: string | null, message: string | null, code: AppErrorCode, permissions: Array<PermissionEnum> | null }> } | null };
export type AppUpdatePermissionsMutationVariables = Exact<{
id: Scalars['ID'];
permissions: Array<PermissionEnum> | PermissionEnum;
}>;
export type AppUpdatePermissionsMutation = { __typename: 'Mutation', appUpdate: { __typename: 'AppUpdate', app: { __typename: 'App', permissions: Array<{ __typename: 'Permission', code: PermissionEnum, name: string }> | null } | null, errors: Array<{ __typename: 'AppError', message: string | null }> } | null };
export type AppsListQueryVariables = Exact<{
before?: InputMaybe<Scalars['String']>;
after?: InputMaybe<Scalars['String']>;