Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/admin-ui/src/lib/core/src/common/generated-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3256,6 +3256,8 @@ export type Mutation = {
updateUserChannels: UserStatus;
/** Update an existing Zone */
updateZone: Zone;
/** Manually verify a customer account, bypassing the email verification token flow. */
verifyCustomerAccount: Customer;
};


Expand Down Expand Up @@ -4213,6 +4215,11 @@ export type MutationUpdateZoneArgs = {
input: UpdateZoneInput;
};


export type MutationVerifyCustomerAccountArgs = {
id: Scalars['ID']['input'];
};

export type NativeAuthInput = {
password: Scalars['String']['input'];
username: Scalars['String']['input'];
Expand Down
7 changes: 7 additions & 0 deletions packages/common/src/generated-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3236,6 +3236,8 @@ export type Mutation = {
updateTaxRate: TaxRate;
/** Update an existing Zone */
updateZone: Zone;
/** Manually verify a customer account, bypassing the email verification token flow. */
verifyCustomerAccount: Customer;
};


Expand Down Expand Up @@ -4148,6 +4150,11 @@ export type MutationUpdateZoneArgs = {
input: UpdateZoneInput;
};


export type MutationVerifyCustomerAccountArgs = {
id: Scalars['ID']['input'];
};

export type NativeAuthInput = {
password: Scalars['String']['input'];
username: Scalars['String']['input'];
Expand Down
88 changes: 87 additions & 1 deletion packages/core/e2e/customer.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DeletionResult, ErrorCode, HistoryEntryType } from '@vendure/common/lib/generated-types';
import { omit } from '@vendure/common/lib/omit';
import { pick } from '@vendure/common/lib/pick';
import { AccountRegistrationEvent, EventBus } from '@vendure/core';
import { AccountRegistrationEvent, AccountVerifiedEvent, EventBus } from '@vendure/core';
import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
import path from 'path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -29,6 +29,7 @@ import {
updateAddressDocument,
updateCustomerDocument,
updateCustomerNoteDocument,
verifyCustomerAccountDocument,
} from './graphql/shared-definitions';
import {
activeOrderCustomerDocument,
Expand Down Expand Up @@ -681,4 +682,89 @@ describe('Customer resolver', () => {
expect(after?.history.totalItems).toBe(historyCount - 1);
});
});

describe('verifyCustomerAccount', () => {
let unverifiedCustomerId: string;

beforeAll(async () => {
const { createCustomer } = await adminClient.query(createCustomerDocument, {
input: {
emailAddress: 'unverified@test.com',
firstName: 'Unverified',
lastName: 'Customer',
},
});
customerErrorGuard.assertSuccess(createCustomer);
unverifiedCustomerId = createCustomer.id;
expect(createCustomer.user!.verified).toBe(false);
});

it('verifies an unverified customer', async () => {
const { verifyCustomerAccount } = await adminClient.query(verifyCustomerAccountDocument, {
id: unverifiedCustomerId,
});

expect(verifyCustomerAccount.user!.verified).toBe(true);
});

it('records a CUSTOMER_VERIFIED history entry', async () => {
const { customer } = await adminClient.query(getCustomerHistoryDocument, {
id: unverifiedCustomerId,
options: {
filter: {
type: {
eq: HistoryEntryType.CUSTOMER_VERIFIED,
},
},
},
});

customerHistoryGuard.assertSuccess(customer);

expect(customer.history.items.map(pick(['type', 'data']))).toContainEqual({
type: HistoryEntryType.CUSTOMER_VERIFIED,
data: { strategy: 'native' },
});
});

it('emits an AccountVerifiedEvent', async () => {
const { createCustomer } = await adminClient.query(createCustomerDocument, {
input: {
emailAddress: 'unverified2@test.com',
firstName: 'Unverified2',
lastName: 'Customer',
},
});
customerErrorGuard.assertSuccess(createCustomer);

const eventFn = vi.fn();
let resolveFn: () => void;
const subscription = server.app
.get(EventBus)
.ofType(AccountVerifiedEvent)
.subscribe(event => {
eventFn(event);
resolveFn?.();
});
const eventReceived = new Promise<void>(resolve => {
resolveFn = resolve;
});

await adminClient.query(verifyCustomerAccountDocument, { id: createCustomer.id });
await eventReceived;

expect(eventFn).toHaveBeenCalledTimes(1);
expect(eventFn.mock.calls[0][0] instanceof AccountVerifiedEvent).toBe(true);

subscription.unsubscribe();
});

it('is idempotent for an already-verified customer', async () => {
const { verifyCustomerAccount } = await adminClient.query(verifyCustomerAccountDocument, {
id: unverifiedCustomerId,
});

expect(verifyCustomerAccount.user!.verified).toBe(true);
});
});
});
22 changes: 11 additions & 11 deletions packages/core/e2e/graphql/graphql-env-admin.d.ts

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/core/e2e/graphql/shared-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,17 @@ export const deleteCustomerNoteDocument = graphql(`
}
`);

export const verifyCustomerAccountDocument = graphql(
`
mutation VerifyCustomerAccount($id: ID!) {
verifyCustomerAccount(id: $id) {
...Customer
}
}
`,
[customerFragment],
);

export const updateCustomerGroupDocument = graphql(
`
mutation UpdateCustomerGroup($input: UpdateCustomerGroupInput!) {
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/api/resolvers/admin/customer.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
MutationUpdateCustomerAddressArgs,
MutationUpdateCustomerArgs,
MutationUpdateCustomerNoteArgs,
MutationVerifyCustomerAccountArgs,
Permission,
QueryCustomerArgs,
QueryCustomersArgs,
Expand Down Expand Up @@ -143,6 +144,16 @@ export class CustomerResolver {
return Promise.all(args.ids.map(id => this.deleteCustomer(ctx, { id })));
}

@Transaction()
@Mutation()
@Allow(Permission.UpdateCustomer)
async verifyCustomerAccount(
@Ctx() ctx: RequestContext,
@Args() args: MutationVerifyCustomerAccountArgs,
): Promise<Customer> {
return this.customerService.verifyCustomerAccount(ctx, args.id);
}

@Transaction()
@Mutation()
@Allow(Permission.UpdateCustomer)
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/api/schema/admin-api/customer.api.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ type Mutation {
addNoteToCustomer(input: AddNoteToCustomerInput!): Customer!
updateCustomerNote(input: UpdateCustomerNoteInput!): HistoryEntry!
deleteCustomerNote(id: ID!): DeletionResponse!

"Manually verify a customer account, bypassing the email verification token flow."
verifyCustomerAccount(id: ID!): Customer!
}

input UpdateCustomerInput {
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/service/services/customer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,46 @@ export class CustomerService {
return user;
}

/**
* @description
* Manually marks a Customer's email address as verified, bypassing the email
* verification token flow. Intended for use by administrators when the customer
* has not received or is unable to complete the verification email.
*
* If the Customer is already verified, this method is a no-op and returns the
* Customer unchanged.
*/
async verifyCustomerAccount(ctx: RequestContext, customerId: ID): Promise<Customer> {
const customer = await this.connection.getEntityOrThrow(ctx, Customer, customerId, {
channelId: ctx.channelId,
relations: ['user', 'user.authenticationMethods'],
});
if (!customer.user) {
throw new InternalServerError('error.cannot-locate-customer-for-user');
}
if (!customer.user.verified) {
const nativeAuthMethod = customer.user.getNativeAuthenticationMethod(false);
if (nativeAuthMethod) {
nativeAuthMethod.verificationToken = null;
await this.connection
.getRepository(ctx, NativeAuthenticationMethod)
.save(nativeAuthMethod);
}
customer.user.verified = true;
await this.connection.getRepository(ctx, User).save(customer.user, { reload: false });
await this.historyService.createHistoryEntryForCustomer({
customerId: customer.id,
ctx,
type: HistoryEntryType.CUSTOMER_VERIFIED,
data: {
strategy: NATIVE_AUTH_STRATEGY_NAME,
},
});
await this.eventBus.publish(new AccountVerifiedEvent(ctx, customer));
}
return assertFound(this.findOne(ctx, customer.id));
}

/**
* @description
* Publishes a new {@link PasswordResetEvent} for the given email address. This event creates
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,18 @@ export const deleteCustomerDocument = graphql(`
}
`);

export const verifyCustomerAccountDocument = graphql(`
mutation VerifyCustomerAccount($id: ID!) {
verifyCustomerAccount(id: $id) {
id
user {
id
verified
}
}
}
`);

export const createCustomerAddressDocument = graphql(`
mutation CreateCustomerAddress($customerId: ID!, $input: CreateAddressInput!) {
createCustomerAddress(customerId: $customerId, input: $input) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
customerDetailDocument,
removeCustomerFromGroupDocument,
updateCustomerDocument,
verifyCustomerAccountDocument,
} from './customers.graphql.js';

const pageId = 'customer-detail';
Expand Down Expand Up @@ -145,12 +146,35 @@ function CustomerDetailPage() {
},
});

const { mutate: verifyCustomer, isPending: isVerifyPending } = useMutation({
mutationFn: api.mutate(verifyCustomerAccountDocument),
onSuccess: () => {
toast.success(t`Customer account verified`);
refreshEntity();
},
onError: () => {
toast.error(t`Failed to verify customer account`);
},
});

const customerName = entity ? `${entity.firstName} ${entity.lastName}` : '';

return (
<Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
<PageTitle>{creatingNewEntity ? <Trans>New customer</Trans> : customerName}</PageTitle>
<PageActionBar>
{entity?.user && !entity.user.verified && (
<ActionBarItem itemId="verify-button" requiresPermission={['UpdateCustomer']}>
<Button
type="button"
variant="secondary"
disabled={isVerifyPending}
onClick={() => verifyCustomer({ id: entity.id })}
>
<Trans>Verify account</Trans>
</Button>
</ActionBarItem>
)}
<ActionBarItem itemId="save-button" requiresPermission={['UpdateCustomer']}>
<Button
type="submit"
Expand Down
2 changes: 1 addition & 1 deletion schema-admin.json

Large diffs are not rendered by default.

Loading