diff --git a/api/package.json b/api/package.json index 3c72e2a5..96a42634 100644 --- a/api/package.json +++ b/api/package.json @@ -25,7 +25,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/typeorm": "^10.0.2", - "@ts-rest/nest": "^3.51.0", + "@ts-rest/nest": "3.45.2", "@types/multer": "1.4.12", "bcrypt": "catalog:", "class-transformer": "catalog:", diff --git a/client/package.json b/client/package.json index 47d69352..955219e5 100644 --- a/client/package.json +++ b/client/package.json @@ -33,6 +33,7 @@ "@types/node": "catalog:", "@types/react": "^18", "@types/react-dom": "^18", + "@typescript-eslint/eslint-plugin": "^7.0.0", "eslint": "^8", "eslint-config-next": "14.2.8", "eslint-plugin-prettier": "5.2.1", diff --git a/client/src/containers/profile/account-details/form/index.tsx b/client/src/containers/profile/account-details/form/index.tsx new file mode 100644 index 00000000..9c5c27af --- /dev/null +++ b/client/src/containers/profile/account-details/form/index.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { FC, KeyboardEvent, useCallback, useRef, useState } from "react"; + +import { useForm } from "react-hook-form"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { SquarePenIcon, XIcon } from "lucide-react"; +import { useSession } from "next-auth/react"; +import { z } from "zod"; + +import { client } from "@/lib/query-client"; +import { queryKeys } from "@/lib/query-keys"; + +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 AccountDetailsForm: FC = () => { + const queryClient = useQueryClient(); + const { data: session, update: updateSession } = useSession(); + const [isEditing, setEditing] = useState(false); + const formRef = useRef(null); + const { toast } = useToast(); + + const { data: user } = client.user.findMe.useQuery( + queryKeys.users.me(session?.user?.id as string).queryKey, + { + extraHeaders: { + authorization: `Bearer ${session?.accessToken}`, + }, + query: {}, + }, + { + select: (res) => res.body.data, + }, + ); + + const form = useForm>({ + resolver: zodResolver(accountDetailsSchema), + defaultValues: { + name: user?.name, + email: user?.email, + }, + mode: "onSubmit", + }); + + const onSubmit = useCallback( + async (data: FormData) => { + const formData = Object.fromEntries(data); + const parsed = accountDetailsSchema.safeParse(formData); + + if (parsed.success) { + const response = await client.user.updateUser.mutation({ + params: { + id: session?.user?.id as string, + }, + body: { + email: parsed.data.email, + }, + extraHeaders: { + authorization: `Bearer ${session?.accessToken}`, + }, + }); + + if (response.status === 200) { + await updateSession(response.body); + + await queryClient.invalidateQueries({ + queryKey: queryKeys.users.me(session?.user?.id as string).queryKey, + }); + + toast({ + description: "Your email has been updated successfully.", + }); + } + } + }, + [queryClient, session, updateSession, toast], + ); + + const handleEnterKey = useCallback( + (evt: KeyboardEvent) => { + if (evt.code === "Enter" && isEditing && form.formState.isValid) { + form.handleSubmit(async () => { + await onSubmit(new FormData(formRef.current!)); + })(); + } + }, + [isEditing, form, onSubmit], + ); + + return ( +
+ { + form.handleSubmit(async () => { + await onSubmit(new FormData(formRef.current!)); + })(evt); + }} + > + ( + + Email + +
+ { + field.onBlur(); + setEditing(false); + }} + /> + +
+
+ +
+ )} + /> + + + ); +}; + +export default AccountDetailsForm; diff --git a/client/src/containers/profile/account-details/form/schema.ts b/client/src/containers/profile/account-details/form/schema.ts new file mode 100644 index 00000000..6647e20b --- /dev/null +++ b/client/src/containers/profile/account-details/form/schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const accountDetailsSchema = z.object({ + email: z + .string({ message: "Email is required" }) + .min(1, "Email is required") + .email("Invalid email"), + name: z.string({ message: "Name is required" }).min(1, "Name is required"), +}); 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..c0a68615 --- /dev/null +++ b/client/src/containers/profile/account-details/index.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +import AccountDetailsForm from "./form"; + +const AccountDetails: FC = () => { + return ; +}; + +export default AccountDetails; diff --git a/client/src/containers/profile/index.tsx b/client/src/containers/profile/index.tsx index 88f4229b..e20972e2 100644 --- a/client/src/containers/profile/index.tsx +++ b/client/src/containers/profile/index.tsx @@ -2,16 +2,40 @@ import { signOut, useSession } from "next-auth/react"; +import { client } from "@/lib/query-client"; +import { queryKeys } from "@/lib/query-keys"; + +import AccountDetailsForm from "@/containers/profile/account-details/form"; +import UpdatePassword from "@/containers/profile/update-password"; + import { Button } from "@/components/ui/button"; export default function Profile() { const { data: session } = useSession(); + const { data } = client.user.findMe.useQuery( + queryKeys.users.me( + session?.accessToken as NonNullable["accessToken"], + ).queryKey, + { + extraHeaders: { + authorization: `Bearer ${session?.accessToken}`, + }, + query: {}, + }, + { + select: (res) => res.body.data, + }, + ); + + console.log(data); + return ( -
-

Welcome {session?.user?.name}

-

Email: {session?.user?.email}

-

role: {session?.user?.role}

+
+
+ + +
+
+ + + ); +}; + +export default UpdatePassword; diff --git a/client/src/containers/profile/update-password/form/schema.ts b/client/src/containers/profile/update-password/form/schema.ts new file mode 100644 index 00000000..39c27175 --- /dev/null +++ b/client/src/containers/profile/update-password/form/schema.ts @@ -0,0 +1,8 @@ +import { PasswordSchema } from "@shared/schemas/auth/login.schema"; +import { z } from "zod"; + +export const changePasswordSchema = PasswordSchema.and( + z.object({ + newPassword: PasswordSchema.shape.password, + }), +); diff --git a/client/src/containers/profile/update-password/index.tsx b/client/src/containers/profile/update-password/index.tsx new file mode 100644 index 00000000..ccf71c04 --- /dev/null +++ b/client/src/containers/profile/update-password/index.tsx @@ -0,0 +1,13 @@ +import { FC } from "react"; + +import UpdatePasswordForm from "./form"; + +const UpdatePassword: FC = () => { + return ( +
+ +
+ ); +}; + +export default UpdatePassword; diff --git a/client/src/lib/query-keys.ts b/client/src/lib/query-keys.ts index 3dc8a995..b5ee3070 100644 --- a/client/src/lib/query-keys.ts +++ b/client/src/lib/query-keys.ts @@ -6,4 +6,10 @@ import { export const authKeys = createQueryKeys("auth", { resetPasswordToken: (token: string) => ["reset-password-token", token], }); -export const queryKeys = mergeQueryKeys(authKeys); + +export const usersKeys = createQueryKeys("users", { + me: (token: string) => ["me", token], +}); + + +export const queryKeys = mergeQueryKeys(authKeys, usersKeys);