From 11646ce09a930c837fc8bfdc28d8a02d692a55fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Mon, 14 Oct 2024 17:19:11 +0200 Subject: [PATCH 1/5] WIP --- api/src/modules/users/users.controller.ts | 2 +- client/package.json | 2 + client/src/app/layout.tsx | 3 + client/src/app/profile/page.tsx | 16 ++ client/src/components/ui/alert-dialog.tsx | 143 +++++++++++ client/src/components/ui/dialog.tsx | 123 +++++++++ client/src/components/ui/toast/toaster.tsx | 2 +- .../src/containers/auth/signup/form/action.ts | 2 +- .../profile/account-details/index.tsx | 171 +++++++++++++ .../profile/account-details/schema.ts | 9 + .../profile/delete-account/index.tsx | 96 +++++++ .../profile/edit-password/form/index.tsx | 182 +++++++++++++ .../profile/edit-password/form/schema.ts | 11 + .../profile/edit-password/index.tsx | 9 + client/src/containers/profile/index.tsx | 24 +- .../containers/profile/update-email/index.tsx | 150 +++++++++++ .../containers/profile/update-email/schema.ts | 9 + client/src/lib/query-keys.ts | 6 +- pnpm-lock.yaml | 242 ++++++++++++++++++ 19 files changed, 1191 insertions(+), 11 deletions(-) create mode 100644 client/src/components/ui/alert-dialog.tsx create mode 100644 client/src/components/ui/dialog.tsx create mode 100644 client/src/containers/profile/account-details/index.tsx create mode 100644 client/src/containers/profile/account-details/schema.ts create mode 100644 client/src/containers/profile/delete-account/index.tsx create mode 100644 client/src/containers/profile/edit-password/form/index.tsx create mode 100644 client/src/containers/profile/edit-password/form/schema.ts create mode 100644 client/src/containers/profile/edit-password/index.tsx create mode 100644 client/src/containers/profile/update-email/index.tsx create mode 100644 client/src/containers/profile/update-email/schema.ts diff --git a/api/src/modules/users/users.controller.ts b/api/src/modules/users/users.controller.ts index 1cae90d1..85dd1c90 100644 --- a/api/src/modules/users/users.controller.ts +++ b/api/src/modules/users/users.controller.ts @@ -47,7 +47,7 @@ export class UsersController { async update(@GetUser() user: User): Promise { return tsRestHandler(c.updateMe, async ({ body }) => { const updatedUser = await this.usersService.update(user.id, body); - return { body: { data: updatedUser }, status: HttpStatus.CREATED }; + return { body: { data: updatedUser }, status: HttpStatus.OK }; }); } diff --git a/client/package.json b/client/package.json index 47d69352..b8eecb63 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,8 @@ "dependencies": { "@hookform/resolvers": "3.9.0", "@lukemorales/query-key-factory": "1.3.4", + "@radix-ui/react-alert-dialog": "1.1.2", + "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-icons": "1.3.0", "@radix-ui/react-label": "2.1.0", "@radix-ui/react-separator": "1.1.0", diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index ef90bd63..26d871d8 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -6,6 +6,8 @@ import { getServerSession } from "next-auth"; import { config } from "@/app/auth/api/[...nextauth]/config"; +import Toaster from "@/components/ui/toast/toaster"; + import LayoutProviders from "./providers"; const inter = Inter({ subsets: ["latin"] }); @@ -27,6 +29,7 @@ export default async function RootLayout({
{children}
+ diff --git a/client/src/app/profile/page.tsx b/client/src/app/profile/page.tsx index 58d524c3..fc9672f5 100644 --- a/client/src/app/profile/page.tsx +++ b/client/src/app/profile/page.tsx @@ -1,10 +1,26 @@ import { QueryClient, dehydrate } from "@tanstack/react-query"; import { HydrationBoundary } from "@tanstack/react-query"; +import { client } from "@/lib/query-client"; +import { queryKeys } from "@/lib/query-keys"; + +import { auth } from "@/app/auth/api/[...nextauth]/config"; + import Profile from "@/containers/profile"; export default async function ProfilePage() { const queryClient = new QueryClient(); + const session = await auth(); + + await queryClient.prefetchQuery({ + queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, + queryFn: () => + client.user.findMe.query({ + extraHeaders: { + authorization: `Bearer ${session?.accessToken as string}`, + }, + }), + }); return ( diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..cfc54c49 --- /dev/null +++ b/client/src/components/ui/alert-dialog.tsx @@ -0,0 +1,143 @@ +"use client"; + +import * as React from "react"; + +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; + +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/client/src/components/ui/dialog.tsx b/client/src/components/ui/dialog.tsx new file mode 100644 index 00000000..d9d425a7 --- /dev/null +++ b/client/src/components/ui/dialog.tsx @@ -0,0 +1,123 @@ +"use client"; + +import * as React from "react"; + +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { Cross2Icon } from "@radix-ui/react-icons"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/client/src/components/ui/toast/toaster.tsx b/client/src/components/ui/toast/toaster.tsx index fd10fa6b..95056ad0 100644 --- a/client/src/components/ui/toast/toaster.tsx +++ b/client/src/components/ui/toast/toaster.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/toast"; import { useToast } from "@/components/ui/toast/use-toast"; -export function Toaster() { +export default function Toaster() { const { toasts } = useToast(); return ( diff --git a/client/src/containers/auth/signup/form/action.ts b/client/src/containers/auth/signup/form/action.ts index 0f7f160c..4e613bdb 100644 --- a/client/src/containers/auth/signup/form/action.ts +++ b/client/src/containers/auth/signup/form/action.ts @@ -19,7 +19,7 @@ export async function signUpAction( if (!parsed.success) { return { ok: false, - message: "Invalid form data", + message: "Invalid update-email data", }; } diff --git a/client/src/containers/profile/account-details/index.tsx b/client/src/containers/profile/account-details/index.tsx new file mode 100644 index 00000000..f8a421ca --- /dev/null +++ b/client/src/containers/profile/account-details/index.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { FC, KeyboardEvent, useCallback, useRef } from "react"; + +import { useForm } from "react-hook-form"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; +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, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/components/ui/toast/use-toast"; + +import { accountDetailsSchema } from "./schema"; + +const UpdateEmailForm: FC = () => { + const queryClient = useQueryClient(); + const { data: session, update: updateSession } = useSession(); + const formRef = useRef(null); + const { toast } = useToast(); + + const { data: user } = client.user.findMe.useQuery( + queryKeys.user.me(session?.user?.id as string).queryKey, + { + extraHeaders: { + authorization: `Bearer ${session?.accessToken as string}`, + }, + }, + { + select: (data) => data.body.data, + }, + ); + + const form = useForm>({ + resolver: zodResolver(accountDetailsSchema), + defaultValues: { + name: user?.name, + role: user?.role, + }, + mode: "onSubmit", + }); + + const onSubmit = useCallback( + async (data: FormData) => { + const formData = Object.fromEntries(data); + 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, + }, + body: { + name: parsed.data.name, + }, + extraHeaders: { + authorization: `Bearer ${session?.accessToken as string}`, + }, + }); + + if (response.status === 200) { + updateSession(response.body); + + queryClient.invalidateQueries({ + queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, + }); + + toast({ + description: "Your account details have been updated successfully.", + }); + } + } + }, + [queryClient, session, updateSession, toast], + ); + + const handleEnterKey = useCallback( + (evt: KeyboardEvent) => { + if (evt.code === "Enter" && form.formState.isValid) { + form.handleSubmit(() => { + onSubmit(new FormData(formRef.current!)); + })(); + } + }, + [form, onSubmit], + ); + + return ( +
+ { + form.handleSubmit(() => { + onSubmit(new FormData(formRef.current!)); + })(evt); + }} + > + ( + + Name + +
+ { + field.onBlur(); + }} + /> +
+
+ +
+ )} + /> + ( + + Role + +
+ +
+
+ +
+ )} + /> + + + + + ); +}; + +export default UpdateEmailForm; diff --git a/client/src/containers/profile/account-details/schema.ts b/client/src/containers/profile/account-details/schema.ts new file mode 100644 index 00000000..996e9296 --- /dev/null +++ b/client/src/containers/profile/account-details/schema.ts @@ -0,0 +1,9 @@ +import { ROLES } from "@shared/entities/users/roles.enum"; +import { UpdateUserSchema } from "@shared/schemas/users/update-user.schema"; +import { z } from "zod"; + +export const accountDetailsSchema = UpdateUserSchema.and( + z.object({ + role: z.enum([ROLES.ADMIN, ROLES.PARTNER, ROLES.GENERAL_USER]).optional(), + }), +); diff --git a/client/src/containers/profile/delete-account/index.tsx b/client/src/containers/profile/delete-account/index.tsx new file mode 100644 index 00000000..7539303b --- /dev/null +++ b/client/src/containers/profile/delete-account/index.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { FC, useCallback } from "react"; + +import { Trash2Icon } from "lucide-react"; +import { signOut, useSession } from "next-auth/react"; + +import { client } from "@/lib/query-client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/toast/use-toast"; + +const DeleteAccount: FC = () => { + const { data: session } = useSession(); + const { toast } = useToast(); + + const onDeleteAccount = useCallback(async () => { + try { + const { status, body } = await client.user.deleteMe.mutation({ + extraHeaders: { + authorization: `Bearer ${session?.accessToken as string}`, + }, + }); + + if (status === 200) { + signOut({ callbackUrl: "/auth/signin" }); + } + + if (status === 400 || status === 401) { + toast({ + variant: "destructive", + description: body.errors?.[0].title, + }); + } + } catch (e) { + toast({ + variant: "destructive", + description: "Something went wrong deleting the account.", + }); + } + }, [session?.accessToken, toast]); + + return ( +
+ + + + + + + Account deletion + + This action cannot be undone. This will permanently delete your + account and remove your data from our servers. + + + + + + + + + + + + +
+ ); +}; + +export default DeleteAccount; diff --git a/client/src/containers/profile/edit-password/form/index.tsx b/client/src/containers/profile/edit-password/form/index.tsx new file mode 100644 index 00000000..db4fb397 --- /dev/null +++ b/client/src/containers/profile/edit-password/form/index.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { FC, useCallback, useRef } from "react"; + +import { useForm } from "react-hook-form"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useSession } from "next-auth/react"; +import { z } from "zod"; + +import { client } from "@/lib/query-client"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/components/ui/toast/use-toast"; + +import { changePasswordSchema } from "./schema"; + +const SignUpForm: FC = () => { + const formRef = useRef(null); + const { data: session } = useSession(); + const { toast } = useToast(); + + const form = useForm>({ + resolver: zodResolver(changePasswordSchema), + defaultValues: { + password: "", + newPassword: "", + confirmPassword: "", + }, + mode: "onSubmit", + }); + + const onSubmit = useCallback( + async (data: FormData) => { + const formData = Object.fromEntries(data); + const parsed = changePasswordSchema.safeParse(formData); + if (parsed.success) { + try { + const response = await client.user.updatePassword.mutation({ + body: { + password: parsed.data.password, + newPassword: parsed.data.newPassword, + }, + extraHeaders: { + authorization: `Bearer ${session?.accessToken as string}`, + }, + }); + + if (response.status === 200) { + toast({ + description: "Your password has been updated successfully.", + }); + } + + if (response.status === 400 || response.status === 401) { + toast({ + variant: "destructive", + description: response.body.errors?.[0].title, + }); + } + } catch (e) { + toast({ + variant: "destructive", + description: "Something went wrong updating the password", + }); + } + } + }, + [session, toast], + ); + + return ( +
+ { + evt.preventDefault(); + form.handleSubmit(() => { + onSubmit(new FormData(formRef.current!)); + })(evt); + }} + > + ( + + Password + +
+ +
+
+ +
+ )} + /> + + ( + + New password + +
+ +
+
+ {!fieldState.invalid && ( + + Password must contain at least 8 characters. + + )} + +
+ )} + /> + + ( + + Confirm password + +
+ +
+
+ {!fieldState.invalid && ( + + Password must contain at least 8 characters. + + )} + +
+ )} + /> + +
+ +
+ + + ); +}; + +export default SignUpForm; diff --git a/client/src/containers/profile/edit-password/form/schema.ts b/client/src/containers/profile/edit-password/form/schema.ts new file mode 100644 index 00000000..ab47a3a8 --- /dev/null +++ b/client/src/containers/profile/edit-password/form/schema.ts @@ -0,0 +1,11 @@ +import { UpdateUserPasswordSchema } from "@shared/schemas/users/update-password.schema"; +import { z } from "zod"; + +export const changePasswordSchema = UpdateUserPasswordSchema.and( + z.object({ + confirmPassword: UpdateUserPasswordSchema.shape.newPassword, + }), +).refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords must match", + path: ["confirmPassword"], +}); diff --git a/client/src/containers/profile/edit-password/index.tsx b/client/src/containers/profile/edit-password/index.tsx new file mode 100644 index 00000000..4141b657 --- /dev/null +++ b/client/src/containers/profile/edit-password/index.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +import EditPasswordForm from "./form"; + +const EditPassword: FC = () => { + return ; +}; + +export default EditPassword; diff --git a/client/src/containers/profile/index.tsx b/client/src/containers/profile/index.tsx index 88f4229b..f70c1f0c 100644 --- a/client/src/containers/profile/index.tsx +++ b/client/src/containers/profile/index.tsx @@ -1,17 +1,27 @@ "use client"; -import { signOut, useSession } from "next-auth/react"; +import { signOut } from "next-auth/react"; + +import AccountDetails from "@/containers/profile/account-details"; +import EditPassword from "@/containers/profile/edit-password"; +import UpdateEmail from "@/containers/profile/update-email"; import { Button } from "@/components/ui/button"; +import DeleteAccount from "src/containers/profile/delete-account"; export default function Profile() { - const { data: session } = useSession(); - return ( -
-

Welcome {session?.user?.name}

-

Email: {session?.user?.email}

-

role: {session?.user?.role}

+
+
+
+ + +
+
+ + +
+
+ + + ); +}; + +export default UpdateEmailForm; diff --git a/client/src/containers/profile/update-email/schema.ts b/client/src/containers/profile/update-email/schema.ts new file mode 100644 index 00000000..9985ddb7 --- /dev/null +++ b/client/src/containers/profile/update-email/schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +// todo: use the one in shared/dtos/users/request-email-update +export const accountDetailsSchema = z.object({ + email: z + .string({ message: "Email is required" }) + .min(1, "Email is required") + .email("Invalid email"), +}); diff --git a/client/src/lib/query-keys.ts b/client/src/lib/query-keys.ts index 3dc8a995..429a7981 100644 --- a/client/src/lib/query-keys.ts +++ b/client/src/lib/query-keys.ts @@ -6,4 +6,8 @@ import { export const authKeys = createQueryKeys("auth", { resetPasswordToken: (token: string) => ["reset-password-token", token], }); -export const queryKeys = mergeQueryKeys(authKeys); + +export const userKeys = createQueryKeys("user", { + me: (token: string) => ["me", token], +}); +export const queryKeys = mergeQueryKeys(authKeys, userKeys); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ac66a86..ae532446 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,6 +273,12 @@ importers: '@lukemorales/query-key-factory': specifier: 1.3.4 version: 1.3.4(@tanstack/query-core@5.59.0)(@tanstack/react-query@5.59.0(react@18.3.1)) + '@radix-ui/react-alert-dialog': + specifier: 1.1.2 + version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': + specifier: 1.1.2 + version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-icons': specifier: 1.3.0 version: 1.3.0(react@18.3.1) @@ -1936,6 +1942,19 @@ packages: '@radix-ui/primitive@1.1.0': resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + '@radix-ui/react-alert-dialog@1.1.2': + resolution: {integrity: sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.0': resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} peerDependencies: @@ -1976,6 +1995,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-dialog@1.1.2': + resolution: {integrity: sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dismissable-layer@1.1.1': resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==} peerDependencies: @@ -1989,11 +2021,42 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-guards@1.1.1': + resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.0': + resolution: {integrity: sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-icons@1.3.0': resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==} peerDependencies: react: ^16.x || ^17.x || ^18.x + '@radix-ui/react-id@1.1.0': + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-label@2.1.0': resolution: {integrity: sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==} peerDependencies: @@ -3234,6 +3297,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.4: + resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} + engines: {node: '>=10'} + aria-query@5.1.3: resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} @@ -3846,6 +3913,9 @@ packages: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -4363,6 +4433,10 @@ packages: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -4566,6 +4640,9 @@ packages: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -6044,6 +6121,26 @@ packages: redux: optional: true + react-remove-scroll-bar@2.3.6: + resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.6.0: + resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-router-dom@6.26.2: resolution: {integrity: sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==} engines: {node: '>=14.0.0'} @@ -6063,6 +6160,16 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-style-singleton@2.2.1: + resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-text-mask@5.5.0: resolution: {integrity: sha512-SLJlJQxa0uonMXsnXRpv5abIepGmHz77ylQcra0GNd7Jtk4Wj2Mtp85uGQHv1avba2uI8ZvRpIEQPpJKsqRGYw==} peerDependencies: @@ -6870,6 +6977,16 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.2: + resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-isomorphic-layout-effect@1.1.2: resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==} peerDependencies: @@ -6884,6 +7001,16 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + use-sidecar@1.1.2: + resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.2.2: resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} peerDependencies: @@ -9205,6 +9332,20 @@ snapshots: '@radix-ui/primitive@1.1.0': {} + '@radix-ui/react-alert-dialog@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) @@ -9235,6 +9376,28 @@ snapshots: optionalDependencies: '@types/react': 18.3.5 + '@radix-ui/react-dialog@1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.5)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.0(@types/react@18.3.5)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-dismissable-layer@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -9248,10 +9411,34 @@ snapshots: '@types/react': 18.3.5 '@types/react-dom': 18.3.0 + '@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.5)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + + '@radix-ui/react-focus-scope@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.5)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + '@types/react-dom': 18.3.0 + '@radix-ui/react-icons@1.3.0(react@18.3.1)': dependencies: react: 18.3.1 + '@radix-ui/react-id@1.1.0(@types/react@18.3.5)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.5)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.5 + '@radix-ui/react-label@2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -10683,6 +10870,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.4: + dependencies: + tslib: 2.7.0 + aria-query@5.1.3: dependencies: deep-equal: 2.2.3 @@ -11405,6 +11596,8 @@ snapshots: detect-newline@3.1.0: {} + detect-node-es@1.1.0: {} + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -12205,6 +12398,8 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.2 + get-nonce@1.0.1: {} + get-package-type@0.1.0: {} get-stream@6.0.1: {} @@ -12448,6 +12643,10 @@ snapshots: hasown: 2.0.2 side-channel: 1.0.6 + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + ipaddr.js@1.9.1: {} is-arguments@1.1.1: @@ -14057,6 +14256,25 @@ snapshots: react-dom: 18.3.1(react@18.3.1) redux: 4.2.1 + react-remove-scroll-bar@2.3.6(@types/react@18.3.5)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.1(@types/react@18.3.5)(react@18.3.1) + tslib: 2.7.0 + optionalDependencies: + '@types/react': 18.3.5 + + react-remove-scroll@2.6.0(@types/react@18.3.5)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.6(@types/react@18.3.5)(react@18.3.1) + react-style-singleton: 2.2.1(@types/react@18.3.5)(react@18.3.1) + tslib: 2.7.0 + use-callback-ref: 1.3.2(@types/react@18.3.5)(react@18.3.1) + use-sidecar: 1.1.2(@types/react@18.3.5)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.5 + react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@remix-run/router': 1.19.2 @@ -14086,6 +14304,15 @@ snapshots: - '@types/react' - supports-color + react-style-singleton@2.2.1(@types/react@18.3.5)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + invariant: 2.2.4 + react: 18.3.1 + tslib: 2.7.0 + optionalDependencies: + '@types/react': 18.3.5 + react-text-mask@5.5.0(react@18.3.1): dependencies: prop-types: 15.8.1 @@ -15018,6 +15245,13 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.2(@types/react@18.3.5)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.7.0 + optionalDependencies: + '@types/react': 18.3.5 + use-isomorphic-layout-effect@1.1.2(@types/react@18.3.5)(react@18.3.1): dependencies: react: 18.3.1 @@ -15028,6 +15262,14 @@ snapshots: dependencies: react: 18.3.1 + use-sidecar@1.1.2(@types/react@18.3.5)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.7.0 + optionalDependencies: + '@types/react': 18.3.5 + use-sync-external-store@1.2.2(react@18.3.1): dependencies: react: 18.3.1 From 67e7685aa9a9283cbd25420dcca4b55861899850 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Mon, 14 Oct 2024 18:33:11 +0200 Subject: [PATCH 2/5] WIP --- .../notifications/email/email.module.ts | 4 +- .../notifications/email/email.provider.ts | 17 +++ api/test/utils/mocks/mock-email.service.ts | 19 ++- .../app/auth/confirm-email/[token]/page.tsx | 5 + .../auth/confirm-email/form/index.tsx | 139 ++++++++++++++++++ .../containers/auth/confirm-email/index.tsx | 0 .../profile/account-details/index.tsx | 18 ++- .../containers/profile/update-email/index.tsx | 11 +- client/src/lib/query-keys.ts | 1 + e2e/tests/auth/delete-account.spec.ts | 54 +++++++ e2e/tests/auth/sign-in.spec.ts | 38 +++++ .../auth/{auth.spec.ts => sign-up.spec.ts} | 15 +- e2e/tests/auth/update-email.spec.ts | 57 +++++++ e2e/tests/auth/update-password.spec.ts | 57 +++++++ shared/contracts/users.contract.ts | 6 +- 15 files changed, 404 insertions(+), 37 deletions(-) create mode 100644 api/src/modules/notifications/email/email.provider.ts create mode 100644 client/src/app/auth/confirm-email/[token]/page.tsx create mode 100644 client/src/containers/auth/confirm-email/form/index.tsx create mode 100644 client/src/containers/auth/confirm-email/index.tsx create mode 100644 e2e/tests/auth/delete-account.spec.ts create mode 100644 e2e/tests/auth/sign-in.spec.ts rename e2e/tests/auth/{auth.spec.ts => sign-up.spec.ts} (84%) create mode 100644 e2e/tests/auth/update-email.spec.ts create mode 100644 e2e/tests/auth/update-password.spec.ts 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), }, From 865c25cb689063fcbfc9040d293bf4f6bc1168f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Tue, 15 Oct 2024 13:28:27 +0200 Subject: [PATCH 3/5] API: updates test PATCH user response --- api/test/integration/users/users-me.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/test/integration/users/users-me.spec.ts b/api/test/integration/users/users-me.spec.ts index c33bdf1b..6db277c6 100644 --- a/api/test/integration/users/users-me.spec.ts +++ b/api/test/integration/users/users-me.spec.ts @@ -85,7 +85,7 @@ describe('Users ME (e2e)', () => { .patch(usersContract.updateMe.path) .send({ name: newName }) .set('Authorization', `Bearer ${jwtToken}`); - expect(response.status).toBe(201); + expect(response.status).toBe(200); expect(response.body.data.id).toEqual(user.id); expect(response.body.data.name).toEqual(newName); From cc1c8a1dc525064a328fa05e25e2bb3e9bc7d2ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Wed, 16 Oct 2024 09:24:53 +0200 Subject: [PATCH 4/5] e2e: uses manager's page --- e2e/tests/auth/update-email.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/tests/auth/update-email.spec.ts b/e2e/tests/auth/update-email.spec.ts index 02cc9031..8cbd6f8a 100644 --- a/e2e/tests/auth/update-email.spec.ts +++ b/e2e/tests/auth/update-email.spec.ts @@ -26,7 +26,7 @@ test.describe("Auth - Sign In", () => { await testManager.close(); }); - test("Auth - Update user email", async ({ page }) => { + test("Auth - Update user email", async () => { const user: Pick = { email: "jhondoe@test.com", password: "12345678", @@ -52,6 +52,6 @@ test.describe("Auth - Sign In", () => { email: newEmail, } as User); - await expect(testManager.getPage()).toHaveURL("/profile"); + await expect(page).toHaveURL("/profile"); }); }); From a1137e1612585b9793d6c29183670f1a5610e744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Wed, 16 Oct 2024 10:46:56 +0200 Subject: [PATCH 5/5] fixes typing of hook --- client/src/containers/profile/account-details/index.tsx | 5 +---- client/src/containers/profile/update-email/index.tsx | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/client/src/containers/profile/account-details/index.tsx b/client/src/containers/profile/account-details/index.tsx index 9571715a..ec7c2e32 100644 --- a/client/src/containers/profile/account-details/index.tsx +++ b/client/src/containers/profile/account-details/index.tsx @@ -40,17 +40,15 @@ const UpdateEmailForm: FC = () => { }, }, { - // @ts-expect-error todo select: (data) => data.body.data, + queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, }, ); const form = useForm>({ resolver: zodResolver(accountDetailsSchema), defaultValues: { - // @ts-expect-error todo name: user?.name, - // @ts-expect-error todo role: user?.role, }, mode: "onSubmit", @@ -145,7 +143,6 @@ const UpdateEmailForm: FC = () => {
{ }, }, { - // @ts-expect-error todo select: (data) => data.body.data, + queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, }, ); const form = useForm>({ resolver: zodResolver(accountDetailsSchema), defaultValues: { - // @ts-expect-error todo email: user?.email, }, mode: "onSubmit", @@ -119,7 +118,6 @@ const UpdateEmailForm: FC = () => { type="email" autoComplete={field.name} onKeyDown={handleEnterKey} - // @ts-expect-error todo placeholder={user?.email} className="w-full" {...field}