diff --git a/api/src/modules/notifications/email/email.module.ts b/api/src/modules/notifications/email/email.module.ts index 5bbef799..4433931c 100644 --- a/api/src/modules/notifications/email/email.module.ts +++ b/api/src/modules/notifications/email/email.module.ts @@ -1,15 +1,15 @@ import { forwardRef, Module } from '@nestjs/common'; import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface'; -import { NodemailerEmailService } from '@api/modules/notifications/email/nodemailer.email.service'; import { AuthModule } from '@api/modules/auth/auth.module'; import { EmailFailedEventHandler } from '@api/modules/notifications/email/events/handlers/emai-failed-event.handler'; import { SendWelcomeEmailHandler } from '@api/modules/notifications/email/commands/handlers/send-welcome-email.handler'; import { SendEmailConfirmationHandler } from '@api/modules/notifications/email/commands/handlers/send-email-confirmation.handler'; +import { EmailProviderFactory } from '@api/modules/notifications/email/email.provider'; @Module({ imports: [forwardRef(() => AuthModule)], providers: [ - { provide: IEmailServiceToken, useClass: NodemailerEmailService }, + EmailProviderFactory, SendEmailConfirmationHandler, SendWelcomeEmailHandler, EmailFailedEventHandler, diff --git a/api/src/modules/notifications/email/email.provider.ts b/api/src/modules/notifications/email/email.provider.ts new file mode 100644 index 00000000..a31d38e1 --- /dev/null +++ b/api/src/modules/notifications/email/email.provider.ts @@ -0,0 +1,17 @@ +import { FactoryProvider } from '@nestjs/common'; +import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface'; +import { MockEmailService } from '../../../../test/utils/mocks/mock-email.service'; +import { NodemailerEmailService } from '@api/modules/notifications/email/nodemailer.email.service'; +import { ApiConfigService } from '@api/modules/config/app-config.service'; +import { EventBus } from '@nestjs/cqrs'; + +export const EmailProviderFactory: FactoryProvider = { + provide: IEmailServiceToken, + useFactory: (configService: ApiConfigService, eventBus: EventBus) => { + const env = configService.get('NODE_ENV'); + return env === 'test' + ? new MockEmailService() + : new NodemailerEmailService(eventBus, configService); + }, + inject: [ApiConfigService], +}; diff --git a/api/test/utils/mocks/mock-email.service.ts b/api/test/utils/mocks/mock-email.service.ts index 3777aaf8..232ec312 100644 --- a/api/test/utils/mocks/mock-email.service.ts +++ b/api/test/utils/mocks/mock-email.service.ts @@ -1,11 +1,20 @@ -import { IEmailServiceInterface } from '@api/modules/notifications/email/email-service.interface'; +import { + IEmailServiceInterface, + SendMailDTO, +} from '@api/modules/notifications/email/email-service.interface'; import { Logger } from '@nestjs/common'; export class MockEmailService implements IEmailServiceInterface { logger: Logger = new Logger(MockEmailService.name); - sendMail = jest.fn(async (): Promise => { - this.logger.log('Mock Email sent'); - return Promise.resolve(); - }); + sendMail = + typeof jest !== 'undefined' + ? jest.fn(async (sendMailDTO: SendMailDTO): Promise => { + this.logger.log('Mock Email sent', this.constructor.name); + return Promise.resolve(); + }) + : async (sendMailDTO: SendMailDTO): Promise => { + this.logger.log('Mock Email sent', this.constructor.name); + return Promise.resolve(); + }; } diff --git a/client/src/app/auth/confirm-email/[token]/page.tsx b/client/src/app/auth/confirm-email/[token]/page.tsx new file mode 100644 index 00000000..fbffb70b --- /dev/null +++ b/client/src/app/auth/confirm-email/[token]/page.tsx @@ -0,0 +1,5 @@ +import ConfirmEmailForm from "@/containers/auth/confirm-email/form"; + +export default function ConfirmEmailPage() { + return ; +} diff --git a/client/src/containers/auth/confirm-email/form/index.tsx b/client/src/containers/auth/confirm-email/form/index.tsx new file mode 100644 index 00000000..62a578ab --- /dev/null +++ b/client/src/containers/auth/confirm-email/form/index.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { FC, FormEvent, useCallback, useRef } from "react"; + +import { useForm } from "react-hook-form"; + +import { useParams, useRouter, useSearchParams } from "next/navigation"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { TOKEN_TYPE_ENUM } from "@shared/schemas/auth/token-type.schema"; +import { RequestEmailUpdateSchema } from "@shared/schemas/users/request-email-update.schema"; +import { useQuery } from "@tanstack/react-query"; +import { z } from "zod"; + +import { client } from "@/lib/query-client"; +import { queryKeys } from "@/lib/query-keys"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useApiResponseToast } from "@/components/ui/toast/use-api-response-toast"; + +const NewPasswordForm: FC = () => { + const router = useRouter(); + const params = useParams<{ token: string }>(); + const searchParams = useSearchParams(); + const newEmail = searchParams.get("newEmail"); + + const formRef = useRef(null); + const form = useForm>({ + resolver: zodResolver(RequestEmailUpdateSchema), + defaultValues: { + newEmail: newEmail as NonNullable, + }, + }); + const { apiResponseToast, toast } = useApiResponseToast(); + + const { + data: isValidToken, + isFetching, + isError, + } = useQuery({ + queryKey: queryKeys.auth.confirmEmailToken(params.token).queryKey, + queryFn: () => { + return client.auth.validateToken.query({ + headers: { + authorization: `Bearer ${params.token}`, + }, + query: { + tokenType: TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION, + }, + }); + }, + select: (data) => data.status === 200, + }); + + const handleEmailConfirmation = useCallback( + (evt: FormEvent) => { + evt.preventDefault(); + + form.handleSubmit(async (formValues) => { + try { + const { status, body } = await client.auth.confirmEmail.mutation({ + body: formValues, + extraHeaders: { + authorization: `Bearer ${params.token}`, + }, + }); + apiResponseToast( + { status, body }, + { + successMessage: "Email updated successfully.", + }, + ); + router.push("/auth/signin"); + } catch (err) { + toast({ + variant: "destructive", + description: "Something went wrong", + }); + } + })(evt); + }, + [form, apiResponseToast, toast, params.token, router], + ); + + const isDisabled = isFetching || isError || !isValidToken; + + return ( +
+
+

Confirm email

+ {!isValidToken && ( +

+ The token is invalid or has expired. +

+ )} +
+
+ + ( + + + + + + + )} + /> +
+ +
+ + +
+ ); +}; + +export default NewPasswordForm; diff --git a/client/src/containers/auth/confirm-email/index.tsx b/client/src/containers/auth/confirm-email/index.tsx new file mode 100644 index 00000000..e69de29b diff --git a/client/src/containers/profile/account-details/index.tsx b/client/src/containers/profile/account-details/index.tsx index f8a421ca..9571715a 100644 --- a/client/src/containers/profile/account-details/index.tsx +++ b/client/src/containers/profile/account-details/index.tsx @@ -40,6 +40,7 @@ const UpdateEmailForm: FC = () => { }, }, { + // @ts-expect-error todo select: (data) => data.body.data, }, ); @@ -47,7 +48,9 @@ const UpdateEmailForm: FC = () => { const form = useForm>({ resolver: zodResolver(accountDetailsSchema), defaultValues: { + // @ts-expect-error todo name: user?.name, + // @ts-expect-error todo role: user?.role, }, mode: "onSubmit", @@ -59,7 +62,6 @@ const UpdateEmailForm: FC = () => { const parsed = accountDetailsSchema.safeParse(formData); if (parsed.success) { - // todo: update method const response = await client.user.updateMe.mutation({ params: { id: session?.user?.id as string, @@ -73,9 +75,9 @@ const UpdateEmailForm: FC = () => { }); if (response.status === 200) { - updateSession(response.body); + await updateSession(response.body); - queryClient.invalidateQueries({ + await queryClient.invalidateQueries({ queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, }); @@ -91,8 +93,8 @@ const UpdateEmailForm: FC = () => { const handleEnterKey = useCallback( (evt: KeyboardEvent) => { if (evt.code === "Enter" && form.formState.isValid) { - form.handleSubmit(() => { - onSubmit(new FormData(formRef.current!)); + form.handleSubmit(async () => { + await onSubmit(new FormData(formRef.current!)); })(); } }, @@ -105,8 +107,8 @@ const UpdateEmailForm: FC = () => { ref={formRef} className="w-full space-y-4" onSubmit={(evt) => { - form.handleSubmit(() => { - onSubmit(new FormData(formRef.current!)); + form.handleSubmit(async () => { + await onSubmit(new FormData(formRef.current!)); })(evt); }} > @@ -143,8 +145,8 @@ const UpdateEmailForm: FC = () => {
diff --git a/client/src/containers/profile/update-email/index.tsx b/client/src/containers/profile/update-email/index.tsx index cc5f0eca..1ea1a985 100644 --- a/client/src/containers/profile/update-email/index.tsx +++ b/client/src/containers/profile/update-email/index.tsx @@ -40,6 +40,7 @@ const UpdateEmailForm: FC = () => { }, }, { + // @ts-expect-error todo select: (data) => data.body.data, }, ); @@ -47,6 +48,7 @@ const UpdateEmailForm: FC = () => { const form = useForm>({ resolver: zodResolver(accountDetailsSchema), defaultValues: { + // @ts-expect-error todo email: user?.email, }, mode: "onSubmit", @@ -58,13 +60,9 @@ const UpdateEmailForm: FC = () => { const parsed = accountDetailsSchema.safeParse(formData); if (parsed.success) { - // todo: update method - const response = await client.user.updateUser.mutation({ - params: { - id: session?.user?.id as string, - }, + const response = await client.user.requestEmailUpdate.mutation({ body: { - email: parsed.data.email, + newEmail: parsed.data.email, }, extraHeaders: { authorization: `Bearer ${session?.accessToken as string}`, @@ -121,6 +119,7 @@ const UpdateEmailForm: FC = () => { type="email" autoComplete={field.name} onKeyDown={handleEnterKey} + // @ts-expect-error todo placeholder={user?.email} className="w-full" {...field} diff --git a/client/src/lib/query-keys.ts b/client/src/lib/query-keys.ts index 429a7981..96daeb3f 100644 --- a/client/src/lib/query-keys.ts +++ b/client/src/lib/query-keys.ts @@ -5,6 +5,7 @@ import { export const authKeys = createQueryKeys("auth", { resetPasswordToken: (token: string) => ["reset-password-token", token], + confirmEmailToken: (token: string) => ["confirm-email-token", token], }); export const userKeys = createQueryKeys("user", { diff --git a/e2e/tests/auth/delete-account.spec.ts b/e2e/tests/auth/delete-account.spec.ts new file mode 100644 index 00000000..1cc2221f --- /dev/null +++ b/e2e/tests/auth/delete-account.spec.ts @@ -0,0 +1,54 @@ +import { expect, Page, test } from "@playwright/test"; +import { E2eTestManager } from "@shared/lib/e2e-test-manager"; +import { User } from "@shared/entities/users/user.entity"; +import { ROLES } from "@shared/entities/users/roles.enum"; + +let testManager: E2eTestManager; +let page: Page; + +test.describe.configure({ mode: "serial" }); + +test.describe("Auth - Delete Account", () => { + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + testManager = await E2eTestManager.load(page); + }); + + test.beforeEach(async () => { + await testManager.clearDatabase(); + }); + + test.afterEach(async () => { + // await testManager.clearDatabase(); + }); + + test.afterAll(async () => { + await testManager.close(); + }); + + test("an user deletes their account successfully", async () => { + const user: Pick = { + email: "jhondoe@test.com", + password: "12345678", + partnerName: "partner-test", + role: ROLES.ADMIN, + }; + + await testManager.mocks().createUser(user); + await testManager.login(user as User); + + await page.waitForURL('/profile'); + + await page.getByRole('button', { name: 'Delete account' }).click(); + await page.getByRole('button', { name: 'Delete account' }).click(); + + await page.waitForURL('/auth/signin'); + + await page.getByLabel("Email").fill(user.email); + await page.locator('input[type="password"]').fill(user.password); + await page.getByRole("button", { name: /log in/i }).click(); + + + await expect(page.getByText('Invalid credentials')).toBeVisible(); + }); +}); diff --git a/e2e/tests/auth/sign-in.spec.ts b/e2e/tests/auth/sign-in.spec.ts new file mode 100644 index 00000000..5f57509b --- /dev/null +++ b/e2e/tests/auth/sign-in.spec.ts @@ -0,0 +1,38 @@ +import { expect, Page, test } from "@playwright/test"; +import { E2eTestManager } from "@shared/lib/e2e-test-manager"; +import { User } from "@shared/entities/users/user.entity"; + +let testManager: E2eTestManager; +let page: Page; + +test.describe.configure({ mode: "serial" }); + +test.describe("Auth - Sign In", () => { + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + testManager = await E2eTestManager.load(page); + }); + + test.beforeEach(async () => { + await testManager.clearDatabase(); + }); + + test.afterEach(async () => { + // await testManager.clearDatabase(); + }); + + test.afterAll(async () => { + await testManager.close(); + }); + + test("an user signs in successfully", async ({ page }) => { + const user: Pick = { + email: "jhondoe@test.com", + password: "12345678", + partnerName: "admin", + }; + await testManager.mocks().createUser(user); + await testManager.login(user as User); + await expect(testManager.getPage()).toHaveURL("/profile"); + }); +}); diff --git a/e2e/tests/auth/auth.spec.ts b/e2e/tests/auth/sign-up.spec.ts similarity index 84% rename from e2e/tests/auth/auth.spec.ts rename to e2e/tests/auth/sign-up.spec.ts index ade52fa9..a683bea6 100644 --- a/e2e/tests/auth/auth.spec.ts +++ b/e2e/tests/auth/sign-up.spec.ts @@ -8,7 +8,7 @@ let page: Page; test.describe.configure({ mode: "serial" }); -test.describe("Auth", () => { +test.describe("Auth - Sign Up", () => { test.beforeAll(async ({ browser }) => { page = await browser.newPage(); testManager = await E2eTestManager.load(page); @@ -26,19 +26,6 @@ test.describe("Auth", () => { await testManager.close(); }); - test("an user signs in successfully", async ({ page }) => { - const user: Pick = { - email: "jhondoe@test.com", - password: "12345678", - partnerName: "admin", - }; - await testManager.mocks().createUser(user); - await testManager.login(user as User); - await expect( - testManager.page.getByText(`Email: ${user.email}`), - ).toBeVisible(); - }); - test("an user signs up successfully", async ({ page }) => { const user: Pick = { email: "johndoe@test.com", diff --git a/e2e/tests/auth/update-email.spec.ts b/e2e/tests/auth/update-email.spec.ts new file mode 100644 index 00000000..02cc9031 --- /dev/null +++ b/e2e/tests/auth/update-email.spec.ts @@ -0,0 +1,57 @@ +import { expect, Page, test } from "@playwright/test"; +import { E2eTestManager } from "@shared/lib/e2e-test-manager"; +import { User } from "@shared/entities/users/user.entity"; +import { TOKEN_TYPE_ENUM } from "@shared/schemas/auth/token-type.schema"; + +let testManager: E2eTestManager; +let page: Page; + +test.describe.configure({ mode: "serial" }); + +test.describe("Auth - Sign In", () => { + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + testManager = await E2eTestManager.load(page); + }); + + test.beforeEach(async () => { + await testManager.clearDatabase(); + }); + + test.afterEach(async () => { + // await testManager.clearDatabase(); + }); + + test.afterAll(async () => { + await testManager.close(); + }); + + test("Auth - Update user email", async ({ page }) => { + const user: Pick = { + email: "jhondoe@test.com", + password: "12345678", + partnerName: "admin", + }; + const userCreated = await testManager.mocks().createUser(user); + + const token = await testManager.generateTokenByType( + userCreated, + TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION, + ); + + const newEmail = 'newmail@mail.com'; + + await page.goto(`/auth/confirm-email/${token}?newEmail=${newEmail}`); + + await page.getByRole("button", { name: /confirm email/i }).click(); + + await expect(page).toHaveURL("/auth/signin"); + + await testManager.login({ + ...user, + email: newEmail, + } as User); + + await expect(testManager.getPage()).toHaveURL("/profile"); + }); +}); diff --git a/e2e/tests/auth/update-password.spec.ts b/e2e/tests/auth/update-password.spec.ts new file mode 100644 index 00000000..e14327d2 --- /dev/null +++ b/e2e/tests/auth/update-password.spec.ts @@ -0,0 +1,57 @@ +import { expect, Page, test } from "@playwright/test"; +import { E2eTestManager } from "@shared/lib/e2e-test-manager"; +import { User } from "@shared/entities/users/user.entity"; +import { ROLES } from "@shared/entities/users/roles.enum"; + +let testManager: E2eTestManager; +let page: Page; + +test.describe.configure({ mode: "serial" }); + +test.describe("Auth - Update Password", () => { + test.beforeAll(async ({ browser }) => { + page = await browser.newPage(); + testManager = await E2eTestManager.load(page); + }); + + test.beforeEach(async () => { + await testManager.clearDatabase(); + }); + + test.afterEach(async () => { + // await testManager.clearDatabase(); + }); + + test.afterAll(async () => { + await testManager.close(); + }); + + test("an user changes their password successfully", async () => { + const user: Pick = { + email: "jhondoe@test.com", + password: "12345678", + partnerName: "partner-test", + role: ROLES.ADMIN, + }; + const newPassword = "987654321987654321"; + + await testManager.mocks().createUser(user); + await testManager.login(user as User); + + await page.waitForURL('/profile'); + + await page.getByPlaceholder('Type your current password').fill(user.password); + await page.getByPlaceholder('Create new password').fill(newPassword); + await page.getByPlaceholder('Repeat new password').fill(newPassword); + + await page.getByRole("button", { name: /update password/i }).click(); + + await page.getByRole("button", { name: /sign out/i }).click(); + + await expect(page).toHaveURL(/auth\/signin/); + + await testManager.login({ email: user.email, password: newPassword } as User); + + await expect(page).toHaveURL("/profile"); + }); +}); diff --git a/shared/contracts/users.contract.ts b/shared/contracts/users.contract.ts index fe2db709..f63d00f8 100644 --- a/shared/contracts/users.contract.ts +++ b/shared/contracts/users.contract.ts @@ -4,6 +4,7 @@ import { User } from "@shared/entities/users/user.entity"; import { UserDto } from "@shared/dtos/users/user.dto"; import { z } from "zod"; import { UpdateUserDto } from "@shared/dtos/users/update-user.dto"; +import { JSONAPIError } from '@shared/dtos/json-api.error'; import { ApiResponse } from "@shared/dtos/global/api-response.dto"; import { UpdateUserPasswordSchema } from "@shared/schemas/users/update-password.schema"; @@ -12,10 +13,11 @@ import { RequestEmailUpdateSchema } from "@shared/schemas/users/request-email-up const contract = initContract(); export const usersContract = contract.router({ findMe: { - method: "GET", - path: "/users/me", + method: 'GET', + path: '/users/me', responses: { 200: contract.type>(), + 401: contract.type(), }, query: generateEntityQuerySchema(User), },