Parcourir la source

feat(core): Add support for permissions on custom fields

Relates to #2671
Michael Bromley il y a 1 an
Parent
commit
1c9f8f97f6

+ 10 - 0
packages/common/src/generated-shop-types.ts

@@ -119,6 +119,7 @@ export type BooleanCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -723,6 +724,7 @@ export type CustomField = {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -842,6 +844,7 @@ export type DateTimeCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Int']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -1078,6 +1081,7 @@ export type FloatCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Float']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -1260,6 +1264,7 @@ export type IntCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Int']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -1610,6 +1615,7 @@ export type LocaleStringCustomFieldConfig = CustomField & {
   nullable?: Maybe<Scalars['Boolean']['output']>;
   pattern?: Maybe<Scalars['String']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -1623,6 +1629,7 @@ export type LocaleTextCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -2973,6 +2980,7 @@ export type RelationCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   scalarFields: Array<Scalars['String']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -3160,6 +3168,7 @@ export type StringCustomFieldConfig = CustomField & {
   options?: Maybe<Array<StringFieldOption>>;
   pattern?: Maybe<Scalars['String']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -3265,6 +3274,7 @@ export type TextCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };

+ 10 - 0
packages/common/src/generated-types.ts

@@ -316,6 +316,7 @@ export type BooleanCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -1305,6 +1306,7 @@ export type CustomField = {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -1503,6 +1505,7 @@ export type DateTimeCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Int']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -1808,6 +1811,7 @@ export type FloatCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Float']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -2013,6 +2017,7 @@ export type IntCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Int']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -2477,6 +2482,7 @@ export type LocaleStringCustomFieldConfig = CustomField & {
   nullable?: Maybe<Scalars['Boolean']['output']>;
   pattern?: Maybe<Scalars['String']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -2490,6 +2496,7 @@ export type LocaleTextCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -5411,6 +5418,7 @@ export type RelationCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   scalarFields: Array<Scalars['String']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -5922,6 +5930,7 @@ export type StringCustomFieldConfig = CustomField & {
   options?: Maybe<Array<StringFieldOption>>;
   pattern?: Maybe<Scalars['String']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -6163,6 +6172,7 @@ export type TextCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };

+ 303 - 0
packages/core/e2e/custom-field-permissions.e2e-spec.ts

@@ -0,0 +1,303 @@
+import { mergeConfig } from '@vendure/core';
+import { createTestEnvironment, SimpleGraphQLClient } from '@vendure/testing';
+import { fail } from 'assert';
+import gql from 'graphql-tag';
+import path from 'path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import * as Codegen from './graphql/generated-e2e-admin-types';
+import { Permission } from './graphql/generated-e2e-shop-types';
+import { CREATE_ADMINISTRATOR, CREATE_ROLE, UPDATE_PRODUCT } from './graphql/shared-definitions';
+
+describe('Custom field permissions', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            customFields: {
+                Product: [
+                    {
+                        name: 'publicField',
+                        type: 'string',
+                        defaultValue: 'publicField Value',
+                    },
+                    {
+                        name: 'authenticatedField',
+                        type: 'string',
+                        defaultValue: 'authenticatedField Value',
+                        requiresPermission: Permission.Authenticated,
+                    },
+                    {
+                        name: 'updateProductField',
+                        type: 'string',
+                        defaultValue: 'updateProductField Value',
+                        requiresPermission: Permission.UpdateProduct,
+                    },
+                    {
+                        name: 'updateProductOrCustomerField',
+                        type: 'string',
+                        defaultValue: 'updateProductOrCustomerField Value',
+                        requiresPermission: [Permission.UpdateProduct, Permission.UpdateCustomer],
+                    },
+                    {
+                        name: 'superadminField',
+                        type: 'string',
+                        defaultValue: 'superadminField Value',
+                        requiresPermission: Permission.SuperAdmin,
+                    },
+                ],
+            },
+        }),
+    );
+
+    let readProductUpdateProductAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+    let readProductUpdateCustomerAdmin: Codegen.CreateAdministratorMutation['createAdministrator'];
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 3,
+        });
+        await adminClient.asSuperAdmin();
+
+        readProductUpdateProductAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'ReadProductUpdateProduct',
+            permissions: [Permission.ReadProduct, Permission.UpdateProduct],
+        });
+        readProductUpdateCustomerAdmin = await createAdminWithPermissions({
+            adminClient,
+            name: 'ReadProductUpdateCustomer',
+            permissions: [Permission.ReadProduct, Permission.UpdateCustomer],
+        });
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    const GET_PRODUCT_WITH_CUSTOM_FIELDS = gql(`
+            query {
+                product(id: "T_1") {
+                    id
+                    customFields {
+                        publicField
+                        authenticatedField
+                        updateProductField
+                        updateProductOrCustomerField
+                        superadminField
+                    }
+                }
+            }
+        `);
+
+    it('anonymous user can only read public custom field', async () => {
+        await shopClient.asAnonymousUser();
+
+        const { product } = await shopClient.query(GET_PRODUCT_WITH_CUSTOM_FIELDS, {
+            id: 'T_1',
+        });
+
+        expect(product.customFields).toEqual({
+            publicField: 'publicField Value',
+            authenticatedField: null,
+            updateProductField: null,
+            updateProductOrCustomerField: null,
+            superadminField: null,
+        });
+    });
+
+    it('authenticated user can read public and authenticated custom fields', async () => {
+        await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test');
+
+        const { product } = await shopClient.query(GET_PRODUCT_WITH_CUSTOM_FIELDS, {
+            id: 'T_1',
+        });
+
+        expect(product.customFields).toEqual({
+            publicField: 'publicField Value',
+            authenticatedField: 'authenticatedField Value',
+            updateProductField: null,
+            updateProductOrCustomerField: null,
+            superadminField: null,
+        });
+    });
+
+    it('readProductUpdateProductAdmin can read public and updateProduct custom fields', async () => {
+        await adminClient.asUserWithCredentials(readProductUpdateProductAdmin.emailAddress, 'test');
+
+        const { product } = await adminClient.query(GET_PRODUCT_WITH_CUSTOM_FIELDS, {
+            id: 'T_1',
+        });
+
+        expect(product.customFields).toEqual({
+            publicField: 'publicField Value',
+            authenticatedField: 'authenticatedField Value',
+            updateProductField: 'updateProductField Value',
+            updateProductOrCustomerField: 'updateProductOrCustomerField Value',
+            superadminField: null,
+        });
+    });
+
+    it('readProductUpdateCustomerAdmin can read public and updateCustomer custom fields', async () => {
+        await adminClient.asUserWithCredentials(readProductUpdateCustomerAdmin.emailAddress, 'test');
+
+        const { product } = await adminClient.query(GET_PRODUCT_WITH_CUSTOM_FIELDS, {
+            id: 'T_1',
+        });
+
+        expect(product.customFields).toEqual({
+            publicField: 'publicField Value',
+            authenticatedField: 'authenticatedField Value',
+            updateProductField: null,
+            updateProductOrCustomerField: 'updateProductOrCustomerField Value',
+            superadminField: null,
+        });
+    });
+
+    it('superadmin can read all custom fields', async () => {
+        await adminClient.asSuperAdmin();
+
+        const { product } = await adminClient.query(GET_PRODUCT_WITH_CUSTOM_FIELDS, {
+            id: 'T_1',
+        });
+
+        expect(product.customFields).toEqual({
+            publicField: 'publicField Value',
+            authenticatedField: 'authenticatedField Value',
+            updateProductField: 'updateProductField Value',
+            updateProductOrCustomerField: 'updateProductOrCustomerField Value',
+            superadminField: 'superadminField Value',
+        });
+    });
+
+    it('superadmin can update all custom fields', async () => {
+        await adminClient.asSuperAdmin();
+        await adminClient.query<Codegen.UpdateProductMutation, Codegen.UpdateProductMutationVariables>(
+            UPDATE_PRODUCT,
+            {
+                input: {
+                    id: 'T_1',
+                    customFields: {
+                        publicField: 'new publicField Value',
+                        authenticatedField: 'new authenticatedField Value',
+                        updateProductField: 'new updateProductField Value',
+                        updateProductOrCustomerField: 'new updateProductOrCustomerField Value',
+                        superadminField: 'new superadminField Value',
+                    },
+                },
+            },
+        );
+
+        const { product } = await adminClient.query(GET_PRODUCT_WITH_CUSTOM_FIELDS, {
+            id: 'T_1',
+        });
+
+        expect(product.customFields).toEqual({
+            publicField: 'new publicField Value',
+            authenticatedField: 'new authenticatedField Value',
+            updateProductField: 'new updateProductField Value',
+            updateProductOrCustomerField: 'new updateProductOrCustomerField Value',
+            superadminField: 'new superadminField Value',
+        });
+    });
+
+    it('readProductUpdateProductAdmin can update updateProduct custom field', async () => {
+        await adminClient.asUserWithCredentials(readProductUpdateProductAdmin.emailAddress, 'test');
+        await adminClient.query<Codegen.UpdateProductMutation, Codegen.UpdateProductMutationVariables>(
+            UPDATE_PRODUCT,
+            {
+                input: {
+                    id: 'T_1',
+                    customFields: {
+                        updateProductField: 'new updateProductField Value 2',
+                    },
+                },
+            },
+        );
+
+        const { product } = await adminClient.query(GET_PRODUCT_WITH_CUSTOM_FIELDS, {
+            id: 'T_1',
+        });
+
+        expect(product.customFields.updateProductField).toBe('new updateProductField Value 2');
+    });
+
+    it('readProductUpdateProductAdmin cannot update superadminField', async () => {
+        await adminClient.asUserWithCredentials(readProductUpdateProductAdmin.emailAddress, 'test');
+        try {
+            const result = await adminClient.query<
+                Codegen.UpdateProductMutation,
+                Codegen.UpdateProductMutationVariables
+            >(UPDATE_PRODUCT, {
+                input: {
+                    id: 'T_1',
+                    customFields: {
+                        superadminField: 'new superadminField Value 2',
+                    },
+                },
+            });
+            fail('Should have thrown');
+        } catch (e: any) {
+            expect(e.message).toBe(
+                'You do not have the required permissions to update the "superadminField" field',
+            );
+        }
+    });
+
+    // This will throw anyway because the user does not have permission to even
+    // update the Product at all.
+    it('readProductUpdateCustomerAdmin cannot update updateProductField', async () => {
+        await adminClient.asUserWithCredentials(readProductUpdateCustomerAdmin.emailAddress, 'test');
+        try {
+            const result = await adminClient.query<
+                Codegen.UpdateProductMutation,
+                Codegen.UpdateProductMutationVariables
+            >(UPDATE_PRODUCT, {
+                input: {
+                    id: 'T_1',
+                    customFields: {
+                        updateProductField: 'new updateProductField Value 2',
+                    },
+                },
+            });
+            fail('Should have thrown');
+        } catch (e: any) {
+            expect(e.message).toBe('You are not currently authorized to perform this action');
+        }
+    });
+});
+
+async function createAdminWithPermissions(input: {
+    adminClient: SimpleGraphQLClient;
+    name: string;
+    permissions: Permission[];
+}) {
+    const { adminClient, name, permissions } = input;
+    const { createRole } = await adminClient.query<
+        Codegen.CreateRoleMutation,
+        Codegen.CreateRoleMutationVariables
+    >(CREATE_ROLE, {
+        input: {
+            code: name,
+            description: name,
+            permissions,
+        },
+    });
+
+    const { createAdministrator } = await adminClient.query<
+        Codegen.CreateAdministratorMutation,
+        Codegen.CreateAdministratorMutationVariables
+    >(CREATE_ADMINISTRATOR, {
+        input: {
+            firstName: name,
+            lastName: 'LastName',
+            emailAddress: `${name}@test.com`,
+            roleIds: [createRole.id],
+            password: 'test',
+        },
+    });
+    return createAdministrator;
+}

+ 10 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -307,6 +307,7 @@ export type BooleanCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -1272,6 +1273,7 @@ export type CustomField = {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -1464,6 +1466,7 @@ export type DateTimeCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Int']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -1755,6 +1758,7 @@ export type FloatCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Float']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -1948,6 +1952,7 @@ export type IntCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Int']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -2403,6 +2408,7 @@ export type LocaleStringCustomFieldConfig = CustomField & {
   nullable?: Maybe<Scalars['Boolean']['output']>;
   pattern?: Maybe<Scalars['String']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -2415,6 +2421,7 @@ export type LocaleTextCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -5274,6 +5281,7 @@ export type RelationCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   scalarFields: Array<Scalars['String']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -5760,6 +5768,7 @@ export type StringCustomFieldConfig = CustomField & {
   options?: Maybe<Array<StringFieldOption>>;
   pattern?: Maybe<Scalars['String']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -5988,6 +5997,7 @@ export type TextCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };

+ 10 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -113,6 +113,7 @@ export type BooleanCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -699,6 +700,7 @@ export type CustomField = {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -814,6 +816,7 @@ export type DateTimeCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Int']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -1039,6 +1042,7 @@ export type FloatCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Float']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -1210,6 +1214,7 @@ export type IntCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   step?: Maybe<Scalars['Int']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -1558,6 +1563,7 @@ export type LocaleStringCustomFieldConfig = CustomField & {
   nullable?: Maybe<Scalars['Boolean']['output']>;
   pattern?: Maybe<Scalars['String']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -1570,6 +1576,7 @@ export type LocaleTextCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -2873,6 +2880,7 @@ export type RelationCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   scalarFields: Array<Scalars['String']['output']>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
@@ -3046,6 +3054,7 @@ export type StringCustomFieldConfig = CustomField & {
   options?: Maybe<Array<StringFieldOption>>;
   pattern?: Maybe<Scalars['String']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };
@@ -3141,6 +3150,7 @@ export type TextCustomFieldConfig = CustomField & {
   name: Scalars['String']['output'];
   nullable?: Maybe<Scalars['Boolean']['output']>;
   readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
   type: Scalars['String']['output'];
   ui?: Maybe<Scalars['JSON']['output']>;
 };

+ 14 - 0
packages/core/src/api/common/user-has-permissions-on-custom-field.ts

@@ -0,0 +1,14 @@
+import { Permission } from '@vendure/common/lib/generated-types';
+
+import { CustomFieldConfig } from '../../config/index';
+
+import { RequestContext } from './request-context';
+
+export function userHasPermissionsOnCustomField(ctx: RequestContext, fieldDef: CustomFieldConfig) {
+    const requiresPermission = (fieldDef.requiresPermission as Permission[]) ?? [];
+    const permissionsArray = Array.isArray(requiresPermission) ? requiresPermission : [requiresPermission];
+    if (permissionsArray.length === 0) {
+        return true;
+    }
+    return ctx.userHasPermissions(permissionsArray);
+}

+ 7 - 1
packages/core/src/api/common/validate-custom-field-value.ts

@@ -1,4 +1,3 @@
-import { LanguageCode } from '@vendure/common/lib/generated-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
 
 import { UserInputError } from '../../common/error/errors';
@@ -12,7 +11,9 @@ import {
     StringCustomFieldConfig,
     TypedCustomFieldConfig,
 } from '../../config/custom-field/custom-field-types';
+
 import { RequestContext } from './request-context';
+import { userHasPermissionsOnCustomField } from './user-has-permissions-on-custom-field';
 
 /**
  * Validates the value of a custom field input against any configured constraints.
@@ -34,6 +35,11 @@ export async function validateCustomFieldValue(
             });
         }
     }
+    if (config.requiresPermission) {
+        if (!userHasPermissionsOnCustomField(ctx, config)) {
+            throw new UserInputError('error.field-invalid-no-permission', { name: config.name });
+        }
+    }
     if (config.list === true && Array.isArray(value)) {
         for (const singleValue of value) {
             validateSingleValue(config, singleValue);

+ 37 - 27
packages/core/src/api/config/generate-resolvers.ts

@@ -1,7 +1,7 @@
 import { IFieldResolver, IResolvers } from '@graphql-tools/utils';
 import { StockMovementType } from '@vendure/common/lib/generated-types';
-import { GraphQLFloat, GraphQLSchema } from 'graphql';
-import { GraphQLDateTime, GraphQLJSON, GraphQLSafeInt } from 'graphql-scalars';
+import { GraphQLSchema } from 'graphql';
+import { GraphQLDateTime, GraphQLJSON } from 'graphql-scalars';
 
 import { REQUEST_CONTEXT_KEY } from '../../common/constants';
 import {
@@ -18,6 +18,7 @@ import { getPluginAPIExtensions } from '../../plugin/plugin-metadata';
 import { CustomFieldRelationResolverService } from '../common/custom-field-relation-resolver.service';
 import { ApiType } from '../common/get-api-type';
 import { RequestContext } from '../common/request-context';
+import { userHasPermissionsOnCustomField } from '../common/user-has-permissions-on-custom-field';
 
 import { getCustomFieldsConfigWithoutInterfaces } from './get-custom-fields-config-without-interfaces';
 import { GraphQLMoney } from './money-scalar';
@@ -165,8 +166,7 @@ function generateCustomFieldRelationResolvers(
 
     const customFieldsConfig = getCustomFieldsConfigWithoutInterfaces(configService.customFields, schema);
     for (const [entityName, customFields] of customFieldsConfig) {
-        const relationCustomFields = customFields.filter(isRelationalType);
-        if (relationCustomFields.length === 0 || !schema.getType(entityName)) {
+        if (!schema.getType(entityName)) {
             continue;
         }
         const customFieldTypeName = `${entityName}CustomFields`;
@@ -176,7 +176,7 @@ function generateCustomFieldRelationResolvers(
         const excludeFromShopApi = ['GlobalSettings'].includes(entityName);
 
         // In order to resolve the relations in the CustomFields type, we need
-        // access to the entity id. Therefore we attach it to the resolved value
+        // access to the entity id. Therefore, we attach it to the resolved value
         // so that it is available to the `relationResolver` below.
         const customFieldResolver: IFieldResolver<any, any> = (source: any) => {
             return {
@@ -197,40 +197,50 @@ function generateCustomFieldRelationResolvers(
                 shopResolvers.PaymentMethodQuote = resolverObject;
             }
         }
-        for (const fieldDef of relationCustomFields) {
+        for (const fieldDef of customFields) {
             if (fieldDef.internal === true) {
                 // Do not create any resolvers for internal relations
                 continue;
             }
-            const relationResolver: IFieldResolver<any, any> = async (
-                source: any,
-                args: any,
-                context: any,
-            ) => {
-                const ctx: RequestContext = context.req[REQUEST_CONTEXT_KEY];
-                const eagerEntity = source[fieldDef.name];
-                // If the relation is eager-loaded, we can simply try to translate this relation entity if they have translations
-                if (eagerEntity != null) {
-                    return customFieldRelationResolverService.translateEntity(ctx, eagerEntity, fieldDef);
-                }
-                const entityId = source[ENTITY_ID_KEY];
-                return customFieldRelationResolverService.resolveRelation({
-                    ctx,
-                    fieldDef,
-                    entityName,
-                    entityId,
-                });
-            };
+            let resolver: IFieldResolver<any, any>;
+            if (isRelationalType(fieldDef)) {
+                resolver = async (source: any, args: any, context: any) => {
+                    const ctx: RequestContext = context.req[REQUEST_CONTEXT_KEY];
+                    if (!userHasPermissionsOnCustomField(ctx, fieldDef)) {
+                        return null;
+                    }
+                    const eagerEntity = source[fieldDef.name];
+                    // If the relation is eager-loaded, we can simply try to translate this relation entity if they have translations
+                    if (eagerEntity != null) {
+                        return customFieldRelationResolverService.translateEntity(ctx, eagerEntity, fieldDef);
+                    }
+                    const entityId = source[ENTITY_ID_KEY];
+                    return customFieldRelationResolverService.resolveRelation({
+                        ctx,
+                        fieldDef,
+                        entityName,
+                        entityId,
+                    });
+                };
+            } else {
+                resolver = async (source: any, args: any, context: any) => {
+                    const ctx: RequestContext = context.req[REQUEST_CONTEXT_KEY];
+                    if (!userHasPermissionsOnCustomField(ctx, fieldDef)) {
+                        return null;
+                    }
+                    return source[fieldDef.name];
+                };
+            }
 
             adminResolvers[customFieldTypeName] = {
                 ...adminResolvers[customFieldTypeName],
-                [fieldDef.name]: relationResolver,
+                [fieldDef.name]: resolver,
             } as any;
 
             if (fieldDef.public !== false && !excludeFromShopApi) {
                 shopResolvers[customFieldTypeName] = {
                     ...shopResolvers[customFieldTypeName],
-                    [fieldDef.name]: relationResolver,
+                    [fieldDef.name]: resolver,
                 } as any;
             }
         }

+ 35 - 6
packages/core/src/api/resolvers/admin/global-settings.resolver.ts

@@ -1,6 +1,8 @@
 import { Args, Info, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
 import {
     CustomFields as GraphQLCustomFields,
+    CustomFieldConfig as GraphQLCustomFieldConfig,
+    RelationCustomFieldConfig as GraphQLRelationCustomFieldConfig,
     EntityCustomFields,
     MutationUpdateGlobalSettingsArgs,
     Permission,
@@ -23,7 +25,11 @@ import { getAllPermissionsMetadata } from '../../../common/constants';
 import { ErrorResultUnion } from '../../../common/error/error-result';
 import { ChannelDefaultLanguageError } from '../../../common/error/generated-graphql-admin-errors';
 import { ConfigService } from '../../../config/config.service';
-import { CustomFields } from '../../../config/custom-field/custom-field-types';
+import {
+    CustomFieldConfig,
+    CustomFields,
+    RelationCustomFieldConfig,
+} from '../../../config/custom-field/custom-field-types';
 import { GlobalSettings } from '../../../entity/global-settings/global-settings.entity';
 import { ChannelService } from '../../../service/services/channel.service';
 import { GlobalSettingsService } from '../../../service/services/global-settings.service';
@@ -123,20 +129,43 @@ export class GlobalSettingsResolver {
                     // Do not expose custom fields marked as "internal".
                     .filter(c => !c.internal)
                     .map(c => ({ ...c, list: !!c.list as any }))
-                    .map((c: any) => {
+                    .map(c => {
+                        const { requiresPermission } = c;
+                        c.requiresPermission = Array.isArray(requiresPermission)
+                            ? requiresPermission
+                            : !!requiresPermission
+                            ? [requiresPermission]
+                            : [];
+                        return c;
+                    })
+                    .map(c => {
                         // In the VendureConfig, the relation entity is specified
                         // as the class, but the GraphQL API exposes it as a string.
-                        if (c.type === 'relation') {
-                            c.entity = c.entity.name;
-                            c.scalarFields = this.getScalarFieldsOfType(info, c.graphQLType || c.entity);
+                        const customFieldConfig: GraphQLCustomFieldConfig = c as any;
+                        if (this.isRelationGraphQLType(customFieldConfig) && this.isRelationConfigType(c)) {
+                            customFieldConfig.entity = c.entity.name;
+                            customFieldConfig.scalarFields = this.getScalarFieldsOfType(
+                                info,
+                                c.graphQLType || c.entity.name,
+                            );
                         }
-                        return c;
+                        return customFieldConfig;
                     });
                 return { entityName: entityType, customFields: customFieldsConfig };
             })
             .filter(notNullOrUndefined);
     }
 
+    private isRelationGraphQLType(
+        config: GraphQLCustomFieldConfig,
+    ): config is GraphQLRelationCustomFieldConfig {
+        return config.type === 'relation';
+    }
+
+    private isRelationConfigType(config: CustomFieldConfig): config is RelationCustomFieldConfig {
+        return config.type === 'relation';
+    }
+
     private getScalarFieldsOfType(info: GraphQLResolveInfo, typeName: string): string[] {
         const type = info.schema.getType(typeName);
 

+ 10 - 0
packages/core/src/api/schema/common/custom-field-types.graphql

@@ -7,6 +7,7 @@ interface CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     ui: JSON
 }
 
@@ -20,6 +21,7 @@ type StringCustomFieldConfig implements CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     pattern: String
     options: [StringFieldOption!]
     ui: JSON
@@ -40,6 +42,7 @@ type LocaleStringCustomFieldConfig implements CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     pattern: String
     ui: JSON
 }
@@ -52,6 +55,7 @@ type IntCustomFieldConfig implements CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     min: Int
     max: Int
     step: Int
@@ -66,6 +70,7 @@ type FloatCustomFieldConfig implements CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     min: Float
     max: Float
     step: Float
@@ -80,6 +85,7 @@ type BooleanCustomFieldConfig implements CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     ui: JSON
 }
 """
@@ -95,6 +101,7 @@ type DateTimeCustomFieldConfig implements CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     min: String
     max: String
     step: Int
@@ -110,6 +117,7 @@ type RelationCustomFieldConfig implements CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     entity: String!
     scalarFields: [String!]!
     ui: JSON
@@ -124,6 +132,7 @@ type TextCustomFieldConfig implements CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     ui: JSON
 }
 
@@ -136,6 +145,7 @@ type LocaleTextCustomFieldConfig implements CustomField {
     readonly: Boolean
     internal: Boolean
     nullable: Boolean
+    requiresPermission: [Permission!]
     ui: JSON
 }
 

+ 12 - 2
packages/core/src/config/custom-field/custom-field-types.ts

@@ -7,6 +7,7 @@ import {
     LocaleStringCustomFieldConfig as GraphQLLocaleStringCustomFieldConfig,
     LocaleTextCustomFieldConfig as GraphQLLocaleTextCustomFieldConfig,
     LocalizedString,
+    Permission,
     RelationCustomFieldConfig as GraphQLRelationCustomFieldConfig,
     StringCustomFieldConfig as GraphQLStringCustomFieldConfig,
     TextCustomFieldConfig as GraphQLTextCustomFieldConfig,
@@ -33,17 +34,26 @@ export type DefaultValueType<T extends CustomFieldType> =
 
 export type BaseTypedCustomFieldConfig<T extends CustomFieldType, C extends CustomField> = Omit<
     C,
-    '__typename' | 'list'
+    '__typename' | 'list' | 'requiresPermission'
 > & {
     type: T;
     /**
      * @description
-     * Whether or not the custom field is available via the Shop API.
+     * Whether the custom field is available via the Shop API.
      * @default true
      */
     public?: boolean;
     nullable?: boolean;
     unique?: boolean;
+    /**
+     * @description
+     * The permission(s) required to read or write to this field.
+     * If the user has at least one of these permissions, they will be
+     * able to access the field.
+     *
+     * @since 2.2.0
+     */
+    requiresPermission?: Array<Permission | string> | Permission | string;
     ui?: UiComponentConfig<DefaultFormComponentId | string>;
 };
 

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -26,6 +26,7 @@
     "field-invalid-datetime-range-max": "The custom field \"{ name }\" value [{ value }] is greater than the maximum [{ max }]",
     "field-invalid-datetime-range-min": "The custom field \"{ name }\" value [{ value }] is less than the minimum [{ min }]",
     "field-invalid-non-nullable": "The custom field \"{ name }\" value cannot be set to null",
+    "field-invalid-no-permission": "You do not have the required permissions to update the \"{ name }\" field",
     "field-invalid-number-range-max": "The custom field \"{ name }\" value [{ value }] is greater than the maximum [{ max }]",
     "field-invalid-number-range-min": "The custom field \"{ name }\" value [{ value }] is less than the minimum [{ min }]",
     "field-invalid-readonly": "The custom field \"{ name }\" is readonly",

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
schema-admin.json


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
schema-shop.json


+ 1 - 0
scripts/codegen/generate-graphql-types.ts

@@ -19,6 +19,7 @@ const specFileToIgnore = [
     'shop-definitions',
     'custom-fields.e2e-spec',
     'custom-field-relations.e2e-spec',
+    'custom-field-permissions.e2e-spec',
     'order-item-price-calculation-strategy.e2e-spec',
     'list-query-builder.e2e-spec',
     'shop-order.e2e-spec',

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff