Jelajahi Sumber

feat(core): Expose User.authenticationMethod in GraphQL APIs

Michael Bromley 5 tahun lalu
induk
melakukan
96f923a0a1

+ 14 - 5
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -175,6 +175,14 @@ export type AuthenticationInput = {
   native?: Maybe<NativeAuthInput>;
 };
 
+export type AuthenticationMethod = Node & {
+   __typename?: 'AuthenticationMethod';
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  strategy: Scalars['String'];
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
    __typename?: 'BooleanCustomFieldConfig';
   name: Scalars['String'];
@@ -598,7 +606,7 @@ export type CreateZoneInput = {
 /**
  * @description
  * ISO 4217 currency code
- *
+ * 
  * @docsCategory common
  */
 export enum CurrencyCode {
@@ -1393,7 +1401,7 @@ export type JobSortParameter = {
 /**
  * @description
  * The state of a Job in the JobQueue
- *
+ * 
  * @docsCategory common
  */
 export enum JobState {
@@ -1411,7 +1419,7 @@ export enum JobState {
  * region or script modifier (e.g. de_AT). The selection available is based
  * on the [Unicode CLDR summary list](https://unicode-org.github.io/cldr-staging/charts/37/summary/root.html)
  * and includes the major spoken languages of the world and any widely-used variants.
- *
+ * 
  * @docsCategory common
  */
 export enum LanguageCode {
@@ -2605,7 +2613,7 @@ export type PaymentMethodSortParameter = {
  * @description
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
- *
+ * 
  * @docsCategory common
  */
 export enum Permission {
@@ -3775,7 +3783,8 @@ export type User = Node & {
   identifier: Scalars['String'];
   verified: Scalars['Boolean'];
   roles: Array<Role>;
-  lastLogin?: Maybe<Scalars['String']>;
+  lastLogin?: Maybe<Scalars['DateTime']>;
+  authenticationMethods: Array<AuthenticationMethod>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 

+ 3 - 0
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -36,6 +36,9 @@ const result: IntrospectionResultData = {
                     {
                         name: 'Role',
                     },
+                    {
+                        name: 'AuthenticationMethod',
+                    },
                     {
                         name: 'Asset',
                     },

+ 10 - 1
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -174,6 +174,14 @@ export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
 };
 
+export type AuthenticationMethod = Node & {
+    __typename?: 'AuthenticationMethod';
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    strategy: Scalars['String'];
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -3605,7 +3613,8 @@ export type User = Node & {
     identifier: Scalars['String'];
     verified: Scalars['Boolean'];
     roles: Array<Role>;
-    lastLogin?: Maybe<Scalars['String']>;
+    lastLogin?: Maybe<Scalars['DateTime']>;
+    authenticationMethods: Array<AuthenticationMethod>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 

+ 15 - 1
packages/common/src/generated-shop-types.ts

@@ -97,6 +97,15 @@ export enum AssetType {
 
 export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
+    google?: Maybe<GoogleAuthInput>;
+};
+
+export type AuthenticationMethod = Node & {
+    __typename?: 'AuthenticationMethod';
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    strategy: Scalars['String'];
 };
 
 export type BooleanCustomFieldConfig = CustomField & {
@@ -887,6 +896,10 @@ export type GlobalSettings = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type GoogleAuthInput = {
+    token: Scalars['String'];
+};
+
 export type HistoryEntry = Node & {
     __typename?: 'HistoryEntry';
     id: Scalars['ID'];
@@ -2267,7 +2280,8 @@ export type User = Node & {
     identifier: Scalars['String'];
     verified: Scalars['Boolean'];
     roles: Array<Role>;
-    lastLogin?: Maybe<Scalars['String']>;
+    lastLogin?: Maybe<Scalars['DateTime']>;
+    authenticationMethods: Array<AuthenticationMethod>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 

+ 14 - 5
packages/common/src/generated-types.ts

@@ -174,6 +174,14 @@ export type AuthenticationInput = {
   native?: Maybe<NativeAuthInput>;
 };
 
+export type AuthenticationMethod = Node & {
+   __typename?: 'AuthenticationMethod';
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  strategy: Scalars['String'];
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
    __typename?: 'BooleanCustomFieldConfig';
   name: Scalars['String'];
@@ -597,7 +605,7 @@ export type CreateZoneInput = {
 /**
  * @description
  * ISO 4217 currency code
- *
+ * 
  * @docsCategory common
  */
 export enum CurrencyCode {
@@ -1385,7 +1393,7 @@ export type JobSortParameter = {
 /**
  * @description
  * The state of a Job in the JobQueue
- *
+ * 
  * @docsCategory common
  */
 export enum JobState {
@@ -1403,7 +1411,7 @@ export enum JobState {
  * region or script modifier (e.g. de_AT). The selection available is based
  * on the [Unicode CLDR summary list](https://unicode-org.github.io/cldr-staging/charts/37/summary/root.html)
  * and includes the major spoken languages of the world and any widely-used variants.
- *
+ * 
  * @docsCategory common
  */
 export enum LanguageCode {
@@ -2565,7 +2573,7 @@ export type PaymentMethodSortParameter = {
  * @description
  * Permissions for administrators and customers. Used to control access to
  * GraphQL resolvers via the {@link Allow} decorator.
- *
+ * 
  * @docsCategory common
  */
 export enum Permission {
@@ -3727,7 +3735,8 @@ export type User = Node & {
   identifier: Scalars['String'];
   verified: Scalars['Boolean'];
   roles: Array<Role>;
-  lastLogin?: Maybe<Scalars['String']>;
+  lastLogin?: Maybe<Scalars['DateTime']>;
+  authenticationMethods: Array<AuthenticationMethod>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 

+ 77 - 1
packages/core/e2e/authentication-strategy.e2e-spec.ts

@@ -6,23 +6,30 @@ import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
 import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { NativeAuthenticationStrategy } from '../src/config/auth/native-authentication-strategy';
 
 import { TestAuthenticationStrategy, VALID_AUTH_TOKEN } from './fixtures/test-authentication-strategies';
 import {
     Authenticate,
     GetCustomerHistory,
     GetCustomers,
+    GetCustomerUserAuth,
     HistoryEntryType,
     Me,
 } from './graphql/generated-e2e-admin-types';
+import { Register } from './graphql/generated-e2e-shop-types';
 import { GET_CUSTOMER_HISTORY, ME } from './graphql/shared-definitions';
+import { REGISTER_ACCOUNT } from './graphql/shop-definitions';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 describe('AuthenticationStrategy', () => {
     const { server, adminClient, shopClient } = createTestEnvironment(
         mergeConfig(testConfig, {
             authOptions: {
-                shopAuthenticationStrategy: [new TestAuthenticationStrategy()],
+                shopAuthenticationStrategy: [
+                    new NativeAuthenticationStrategy(),
+                    new TestAuthenticationStrategy(),
+                ],
             },
         }),
     );
@@ -108,6 +115,18 @@ describe('AuthenticationStrategy', () => {
             ]);
         });
 
+        it('user authenticationMethod populated', async () => {
+            const { customer } = await adminClient.query<
+                GetCustomerUserAuth.Query,
+                GetCustomerUserAuth.Variables
+            >(GET_CUSTOMER_USER_AUTH, {
+                id: newCustomerId,
+            });
+
+            expect(customer?.user?.authenticationMethods.length).toBe(1);
+            expect(customer?.user?.authenticationMethods[0].strategy).toBe('test_strategy');
+        });
+
         it('creates authenticated session', async () => {
             const { me } = await shopClient.query<Me.Query>(ME);
 
@@ -136,6 +155,47 @@ describe('AuthenticationStrategy', () => {
                 userData.email,
             ]);
         });
+
+        it('registerCustomerAccount with external email', async () => {
+            const { registerCustomerAccount } = await shopClient.query<Register.Mutation, Register.Variables>(
+                REGISTER_ACCOUNT,
+                {
+                    input: {
+                        emailAddress: userData.email,
+                    },
+                },
+            );
+
+            expect(registerCustomerAccount).toBe(true);
+            const { customer } = await adminClient.query<
+                GetCustomerUserAuth.Query,
+                GetCustomerUserAuth.Variables
+            >(GET_CUSTOMER_USER_AUTH, {
+                id: newCustomerId,
+            });
+
+            expect(customer?.user?.authenticationMethods.length).toBe(2);
+            expect(customer?.user?.authenticationMethods[1].strategy).toBe('native');
+
+            const { customer: customer2 } = await adminClient.query<
+                GetCustomerHistory.Query,
+                GetCustomerHistory.Variables
+            >(GET_CUSTOMER_HISTORY, {
+                id: newCustomerId,
+                options: {
+                    skip: 2,
+                },
+            });
+
+            expect(customer2?.history.items.map(pick(['type', 'data']))).toEqual([
+                {
+                    type: HistoryEntryType.CUSTOMER_REGISTERED,
+                    data: {
+                        strategy: 'native',
+                    },
+                },
+            ]);
+        });
     });
 });
 
@@ -161,3 +221,19 @@ const GET_CUSTOMERS = gql`
         }
     }
 `;
+
+const GET_CUSTOMER_USER_AUTH = gql`
+    query GetCustomerUserAuth($id: ID!) {
+        customer(id: $id) {
+            id
+            user {
+                id
+                verified
+                authenticationMethods {
+                    id
+                    strategy
+                }
+            }
+        }
+    }
+`;

+ 41 - 1
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -174,6 +174,14 @@ export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
 };
 
+export type AuthenticationMethod = Node & {
+    __typename?: 'AuthenticationMethod';
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    strategy: Scalars['String'];
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -3605,7 +3613,8 @@ export type User = Node & {
     identifier: Scalars['String'];
     verified: Scalars['Boolean'];
     roles: Array<Role>;
-    lastLogin?: Maybe<Scalars['String']>;
+    lastLogin?: Maybe<Scalars['DateTime']>;
+    authenticationMethods: Array<AuthenticationMethod>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
@@ -3708,6 +3717,27 @@ export type GetCustomersQuery = { __typename?: 'Query' } & {
         };
 };
 
+export type GetCustomerUserAuthQueryVariables = {
+    id: Scalars['ID'];
+};
+
+export type GetCustomerUserAuthQuery = { __typename?: 'Query' } & {
+    customer?: Maybe<
+        { __typename?: 'Customer' } & Pick<Customer, 'id'> & {
+                user?: Maybe<
+                    { __typename?: 'User' } & Pick<User, 'id' | 'verified'> & {
+                            authenticationMethods: Array<
+                                { __typename?: 'AuthenticationMethod' } & Pick<
+                                    AuthenticationMethod,
+                                    'id' | 'strategy'
+                                >
+                            >;
+                        }
+                >;
+            }
+    >;
+};
+
 export type GetChannelsQueryVariables = {};
 
 export type GetChannelsQuery = { __typename?: 'Query' } & {
@@ -5786,6 +5816,16 @@ export namespace GetCustomers {
     export type Items = NonNullable<GetCustomersQuery['customers']['items'][0]>;
 }
 
+export namespace GetCustomerUserAuth {
+    export type Variables = GetCustomerUserAuthQueryVariables;
+    export type Query = GetCustomerUserAuthQuery;
+    export type Customer = NonNullable<GetCustomerUserAuthQuery['customer']>;
+    export type User = NonNullable<NonNullable<GetCustomerUserAuthQuery['customer']>['user']>;
+    export type AuthenticationMethods = NonNullable<
+        NonNullable<NonNullable<GetCustomerUserAuthQuery['customer']>['user']>['authenticationMethods'][0]
+    >;
+}
+
 export namespace GetChannels {
     export type Variables = GetChannelsQueryVariables;
     export type Query = GetChannelsQuery;

+ 15 - 1
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -97,6 +97,15 @@ export enum AssetType {
 
 export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
+    google?: Maybe<GoogleAuthInput>;
+};
+
+export type AuthenticationMethod = Node & {
+    __typename?: 'AuthenticationMethod';
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    strategy: Scalars['String'];
 };
 
 export type BooleanCustomFieldConfig = CustomField & {
@@ -887,6 +896,10 @@ export type GlobalSettings = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type GoogleAuthInput = {
+    token: Scalars['String'];
+};
+
 export type HistoryEntry = Node & {
     __typename?: 'HistoryEntry';
     id: Scalars['ID'];
@@ -2267,7 +2280,8 @@ export type User = Node & {
     identifier: Scalars['String'];
     verified: Scalars['Boolean'];
     roles: Array<Role>;
-    lastLogin?: Maybe<Scalars['String']>;
+    lastLogin?: Maybe<Scalars['DateTime']>;
+    authenticationMethods: Array<AuthenticationMethod>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 

+ 2 - 0
packages/core/src/api/api-internal-modules.ts

@@ -53,6 +53,7 @@ import {
 } from './resolvers/entity/product-variant-entity.resolver';
 import { RefundEntityResolver } from './resolvers/entity/refund-entity.resolver';
 import { RoleEntityResolver } from './resolvers/entity/role-entity.resolver';
+import { UserEntityResolver } from './resolvers/entity/user-entity.resolver';
 import { ShopAuthResolver } from './resolvers/shop/shop-auth.resolver';
 import { ShopCustomerResolver } from './resolvers/shop/shop-customer.resolver';
 import { ShopEnvironmentResolver } from './resolvers/shop/shop-environment.resolver';
@@ -108,6 +109,7 @@ export const entityResolvers = [
     ProductVariantEntityResolver,
     RefundEntityResolver,
     RoleEntityResolver,
+    UserEntityResolver,
 ];
 
 export const adminEntityResolvers = [

+ 35 - 0
packages/core/src/api/resolvers/entity/user-entity.resolver.ts

@@ -0,0 +1,35 @@
+import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
+import { AuthenticationMethod as AuthenticationMethodType } from '@vendure/common/lib/generated-types';
+
+import { NATIVE_AUTH_STRATEGY_NAME } from '../../../config/auth/native-authentication-strategy';
+import { AuthenticationMethod } from '../../../entity/authentication-method/authentication-method.entity';
+import { ExternalAuthenticationMethod } from '../../../entity/authentication-method/external-authentication-method.entity';
+import { User } from '../../../entity/user/user.entity';
+import { UserService } from '../../../service/services/user.service';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('User')
+export class UserEntityResolver {
+    constructor(private userService: UserService) {}
+
+    @ResolveField()
+    async authenticationMethods(
+        @Ctx() ctx: RequestContext,
+        @Parent() user: User,
+    ): Promise<AuthenticationMethodType[]> {
+        let methodEntities: AuthenticationMethod[] = [];
+        if (user.authenticationMethods) {
+            methodEntities = user.authenticationMethods;
+        }
+        methodEntities = await this.userService
+            .getUserById(user.id)
+            .then(u => u?.authenticationMethods ?? []);
+
+        return methodEntities.map(m => ({
+            ...m,
+            id: m.id.toString(),
+            strategy: m instanceof ExternalAuthenticationMethod ? m.strategy : NATIVE_AUTH_STRATEGY_NAME,
+        }));
+    }
+}

+ 9 - 1
packages/core/src/api/schema/type/user.type.graphql

@@ -5,5 +5,13 @@ type User implements Node {
     identifier: String!
     verified: Boolean!
     roles: [Role!]!
-    lastLogin: String
+    lastLogin: DateTime
+    authenticationMethods: [AuthenticationMethod!]!
+}
+
+type AuthenticationMethod implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    strategy: String!
 }

+ 21 - 6
packages/core/src/service/services/customer.service.ts

@@ -27,6 +27,7 @@ import { assertFound, idsAreEqual, normalizeEmailAddress } from '../../common/ut
 import { NATIVE_AUTH_STRATEGY_NAME } from '../../config/auth/native-authentication-strategy';
 import { ConfigService } from '../../config/config.service';
 import { Address } from '../../entity/address/address.entity';
+import { NativeAuthenticationMethod } from '../../entity/authentication-method/native-authentication-method.entity';
 import { CustomerGroup } from '../../entity/customer-group/customer-group.entity';
 import { Customer } from '../../entity/customer/customer.entity';
 import { HistoryEntry } from '../../entity/history-entry/history-entry.entity';
@@ -170,15 +171,21 @@ export class CustomerService {
             }
         }
         let user = await this.userService.getUserByEmailAddress(input.emailAddress);
+        const hasNativeAuthMethod = !!user?.authenticationMethods.find(
+            m => m instanceof NativeAuthenticationMethod,
+        );
         if (user && user.verified) {
-            // If the user has already been verified, do nothing
-            return false;
+            if (hasNativeAuthMethod) {
+                // If the user has already been verified and has already
+                // registered with the native authentication strategy, do nothing.
+                return false;
+            }
         }
         const customer = await this.createOrUpdate({
             emailAddress: input.emailAddress,
-            title: input.title || '',
-            firstName: input.firstName || '',
-            lastName: input.lastName || '',
+            title: input.title || undefined,
+            firstName: input.firstName || undefined,
+            lastName: input.lastName || undefined,
         });
         await this.historyService.createHistoryEntryForCustomer({
             customerId: customer.id,
@@ -190,7 +197,15 @@ export class CustomerService {
         });
         if (!user) {
             user = await this.userService.createCustomerUser(input.emailAddress, input.password || undefined);
-        } else if (!user.verified) {
+        }
+        if (!hasNativeAuthMethod) {
+            user = await this.userService.addNativeAuthenticationMethod(
+                user,
+                input.emailAddress,
+                input.password || undefined,
+            );
+        }
+        if (!user.verified) {
             user = await this.userService.setVerificationToken(user);
         }
         customer.user = user;

+ 10 - 5
packages/core/src/service/services/user.service.ts

@@ -40,6 +40,7 @@ export class UserService {
         return this.connection.getRepository(User).findOne({
             where: {
                 identifier: emailAddress,
+                deletedAt: null,
             },
             relations: ['roles', 'roles.channels', 'authenticationMethods'],
         });
@@ -47,6 +48,13 @@ export class UserService {
 
     async createCustomerUser(identifier: string, password?: string): Promise<User> {
         const user = new User();
+        user.identifier = identifier;
+        const customerRole = await this.roleService.getCustomerRole();
+        user.roles = [customerRole];
+        return this.connection.manager.save(this.addNativeAuthenticationMethod(user, identifier, password));
+    }
+
+    async addNativeAuthenticationMethod(user: User, identifier: string, password?: string): Promise<User> {
         const authenticationMethod = new NativeAuthenticationMethod();
         if (this.configService.authOptions.requireVerification) {
             authenticationMethod.verificationToken = this.verificationTokenGenerator.generateVerificationToken();
@@ -59,13 +67,10 @@ export class UserService {
         } else {
             authenticationMethod.passwordHash = '';
         }
-        user.identifier = identifier;
         authenticationMethod.identifier = identifier;
         await this.connection.manager.save(authenticationMethod);
-        const customerRole = await this.roleService.getCustomerRole();
-        user.roles = [customerRole];
-        user.authenticationMethods = [authenticationMethod];
-        return this.connection.manager.save(user);
+        user.authenticationMethods = [...(user.authenticationMethods ?? []), authenticationMethod];
+        return user;
     }
 
     async createAdminUser(identifier: string, password: string): Promise<User> {

+ 10 - 1
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -174,6 +174,14 @@ export type AuthenticationInput = {
     native?: Maybe<NativeAuthInput>;
 };
 
+export type AuthenticationMethod = Node & {
+    __typename?: 'AuthenticationMethod';
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    strategy: Scalars['String'];
+};
+
 export type BooleanCustomFieldConfig = CustomField & {
     __typename?: 'BooleanCustomFieldConfig';
     name: Scalars['String'];
@@ -3605,7 +3613,8 @@ export type User = Node & {
     identifier: Scalars['String'];
     verified: Scalars['Boolean'];
     roles: Array<Role>;
-    lastLogin?: Maybe<Scalars['String']>;
+    lastLogin?: Maybe<Scalars['DateTime']>;
+    authenticationMethods: Array<AuthenticationMethod>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 

File diff ditekan karena terlalu besar
+ 0 - 0
schema-admin.json


File diff ditekan karena terlalu besar
+ 0 - 0
schema-shop.json


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini