From 5fd5f55dea8ce7fcb90d0c8c9619e78eaac012fb Mon Sep 17 00:00:00 2001 From: tin_pham Date: Fri, 5 May 2023 13:44:03 +0700 Subject: [PATCH] feat(auth): login with credentials & getCurrentUser --- app/actions/getCurrentUser.ts | 37 +++++++ app/actions/index.ts | 1 + app/components/modals/index.tsx | 9 +- app/components/modals/loginModal.tsx | 117 +++++++++++++++++++++ app/components/modals/registerModal.tsx | 4 +- app/components/modals/wrapper.tsx | 2 + app/components/navbar/index.tsx | 9 +- app/components/navbar/menuItem.tsx | 13 ++- app/components/navbar/userMenu.tsx | 121 +++++++++++++++++++--- app/hooks/index.ts | 3 +- app/hooks/modal/useLoginModal.ts | 15 +++ app/hooks/{ => modal}/useRegisterModal.ts | 0 app/layout.tsx | 7 +- app/types/index.ts | 10 ++ pages/api/{ => auth}/[...nextauth].ts | 2 +- 15 files changed, 323 insertions(+), 27 deletions(-) create mode 100644 app/actions/getCurrentUser.ts create mode 100644 app/actions/index.ts create mode 100644 app/components/modals/loginModal.tsx create mode 100644 app/hooks/modal/useLoginModal.ts rename app/hooks/{ => modal}/useRegisterModal.ts (100%) create mode 100644 app/types/index.ts rename pages/api/{ => auth}/[...nextauth].ts (95%) diff --git a/app/actions/getCurrentUser.ts b/app/actions/getCurrentUser.ts new file mode 100644 index 0000000..49b4f40 --- /dev/null +++ b/app/actions/getCurrentUser.ts @@ -0,0 +1,37 @@ +import { authOptions } from "@/pages/api/auth/[...nextauth]"; +import { getServerSession } from "next-auth/next"; +import prisma from "@/app/libs/prismadb"; + +export async function getSession() { + return await getServerSession(authOptions); +} + +export default async function getCurrentUser() { + try { + const session = await getSession(); + + if (!session?.user?.email) { + return null; + } + + const currentUser = await prisma.user.findUnique({ + where: { + email: session.user.email as string, + }, + }); + + if (!currentUser) { + return null; + } + + return { + ...currentUser, + createdAt: currentUser.createdAt.toISOString(), + updatedAt: currentUser.updatedAt.toISOString(), + emailVerified: currentUser.emailVerified?.toISOString() || null, + }; + } catch (error) { + console.log("error: ", error); + return null; + } +} diff --git a/app/actions/index.ts b/app/actions/index.ts new file mode 100644 index 0000000..1b07306 --- /dev/null +++ b/app/actions/index.ts @@ -0,0 +1 @@ +export { default as getCurrentUser } from "./getCurrentUser"; diff --git a/app/components/modals/index.tsx b/app/components/modals/index.tsx index 346f1b3..7cdf6e0 100644 --- a/app/components/modals/index.tsx +++ b/app/components/modals/index.tsx @@ -13,6 +13,7 @@ interface Props { disabled?: boolean; secondaryAction?: () => void; secondaryActionLabel?: string; + isLoading?: boolean; } const Modal: React.FC = ({ actionLabel, @@ -20,7 +21,7 @@ const Modal: React.FC = ({ onSubmit = () => {}, body, footer, - + isLoading, disabled, isOpen, secondaryAction, @@ -102,7 +103,11 @@ const Modal: React.FC = ({ {secondaryActionLabel} )} - diff --git a/app/components/modals/loginModal.tsx b/app/components/modals/loginModal.tsx new file mode 100644 index 0000000..3ba002d --- /dev/null +++ b/app/components/modals/loginModal.tsx @@ -0,0 +1,117 @@ +"use client"; + +import axios from "axios"; +import React from "react"; +import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; +import { FcGoogle } from "react-icons/fc"; +import { AiFillGithub } from "react-icons/ai"; +import { ROUTE } from "@/app/api/constant"; +import { useRegisterModal, useLoginModal } from "@/app/hooks"; +import { toast } from "react-hot-toast"; +import Modal from "."; +import Heading from "../heading"; +import Input from "../inputs"; +import InputPassword from "../inputs/password"; +import Button from "../button"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +const LoginModal = () => { + const router = useRouter(); + const registerModal = useRegisterModal(); + const loginModal = useLoginModal(); + const [isLoading, setIsLoading] = React.useState(false); + + const { + register, + handleSubmit, + setError, + formState: { errors }, + } = useForm({ + defaultValues: { + email: "", + password: "", + }, + }); + + const onSubmit: SubmitHandler = (data) => { + setIsLoading(true); + + signIn("credentials", { + ...data, + redirect: false, + }).then((callback) => { + setIsLoading(false); + + if (callback?.ok) { + toast.success("Logged in successfully!"); + router.refresh(); + loginModal.onClose(); + } + if (callback?.error) { + setError("password", { + message: callback.error, + }); + } + }); + }; + + return ( + + + + + + } + footer={ +
+
+ + +
+
+
Already have an account?
+
{ + registerModal.onClose(); + }} + > + Login +
+
+
+
+ } + /> + ); +}; + +export default LoginModal; diff --git a/app/components/modals/registerModal.tsx b/app/components/modals/registerModal.tsx index 93ba282..6827442 100644 --- a/app/components/modals/registerModal.tsx +++ b/app/components/modals/registerModal.tsx @@ -6,13 +6,14 @@ import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; import { FcGoogle } from "react-icons/fc"; import { AiFillGithub } from "react-icons/ai"; import { ROUTE } from "@/app/api/constant"; -import useRegisterModal from "@/app/hooks/useRegisterModal"; +import { useRegisterModal } from "@/app/hooks"; import { toast } from "react-hot-toast"; import Modal from "."; import Heading from "../heading"; import Input from "../inputs"; import InputPassword from "../inputs/password"; import Button from "../button"; + const RegisterModal = () => { const registerModal = useRegisterModal(); const [isLoading, setIsLoading] = React.useState(false); @@ -48,6 +49,7 @@ const RegisterModal = () => { return ( { return ( <> + ); }; diff --git a/app/components/navbar/index.tsx b/app/components/navbar/index.tsx index 2991cde..c0c198d 100644 --- a/app/components/navbar/index.tsx +++ b/app/components/navbar/index.tsx @@ -1,12 +1,17 @@ "use client"; +import { SafeUser } from "@/app/types"; import React from "react"; import Container from "../container"; import Logo from "./logo"; import Search from "./search"; import UserMenu from "./userMenu"; -const Navbar = () => { +interface Props { + currentUser?: SafeUser | null; +} + +const Navbar: React.FC = ({ currentUser }) => { return (
@@ -14,7 +19,7 @@ const Navbar = () => {
- +
diff --git a/app/components/navbar/menuItem.tsx b/app/components/navbar/menuItem.tsx index 3a59563..0884c73 100644 --- a/app/components/navbar/menuItem.tsx +++ b/app/components/navbar/menuItem.tsx @@ -3,15 +3,20 @@ import React from "react"; interface Props { - onClick: () => void; + onClick?: () => void; label: string; + disabled?: boolean; } -const MenuItem: React.FC = ({ label, onClick }) => { +const MenuItem: React.FC = ({ label, onClick, disabled }) => { return (
{ + if (onClick) onClick(); + }} + className={`px-4 py-3 hover:bg-neutral-50 transition font-semibold ${ + disabled ? "hover:bg-white cursor-default" : "" + }`} > {label}
diff --git a/app/components/navbar/userMenu.tsx b/app/components/navbar/userMenu.tsx index fe12def..737a26b 100644 --- a/app/components/navbar/userMenu.tsx +++ b/app/components/navbar/userMenu.tsx @@ -1,19 +1,98 @@ "use client"; +import { useLoginModal, useRegisterModal } from "@/app/hooks"; +import { SafeUser } from "@/app/types"; +import { signOut } from "next-auth/react"; import React from "react"; +import { toast } from "react-hot-toast"; import { AiOutlineMenu } from "react-icons/ai"; import Avatar from "../avatar"; import MenuItem from "./menuItem"; -import useRegisterModal from "@/app/hooks/useRegisterModal"; -type Props = {}; -const UserMenu: React.FC = () => { +type Props = { + currentUser?: SafeUser | null; +}; +const UserMenu: React.FC = ({ currentUser }) => { const [isOpen, setIsOpen] = React.useState(false); + const isLoggedIn = React.useMemo(() => { + return !!currentUser; + }, [currentUser]); + const toggleOpen = React.useCallback(() => { setIsOpen((prev) => !prev); }, []); const registerModal = useRegisterModal(); + const loginModal = useLoginModal(); + + const menuOptions = React.useMemo(() => { + return [ + { + label: "Login", + onClick: () => { + loginModal.onOpen(); + }, + isShow: !isLoggedIn, + }, + { + label: "Sign Up", + onClick: () => { + registerModal.onOpen(); + }, + isShow: !isLoggedIn, + }, + { + label: "My trips", + onClick: () => { + toast.success("My trips"); + }, + isShow: isLoggedIn, + }, + { + label: "My trips", + onClick: () => { + toast.success("My trips"); + }, + isShow: isLoggedIn, + }, + { + label: "My favorites", + onClick: () => { + toast.success("My favorites"); + }, + isShow: isLoggedIn, + }, + { + label: "My reservations", + onClick: () => { + toast.success("My reservations"); + }, + isShow: isLoggedIn, + }, + { + label: "My properties", + onClick: () => { + toast.success("My properties"); + }, + isShow: isLoggedIn, + }, + { + label: "Airbnb my home", + onClick: () => { + toast.success("Airbnb my home"); + }, + isShow: isLoggedIn, + }, + { + label: "Logout", + onClick: () => { + signOut(); + toast.success("Logout successfully!"); + }, + isShow: isLoggedIn, + }, + ]; + }, [isLoggedIn, loginModal, registerModal]); return (
@@ -35,18 +114,32 @@ const UserMenu: React.FC = () => {
{isOpen && ( -
+
- {[ - { label: "Login", onClick: () => {} }, - { label: "Sign Up", onClick: registerModal.onOpen }, - ].map((item, index) => ( - - ))} + {isLoggedIn && ( + <> + {}} + disabled + /> +
+ + )} + {menuOptions.map((item, index) => { + return item.isShow ? ( + { + item.onClick(); + toggleOpen(); + }} + label={item.label} + /> + ) : ( + + ); + })}
)} diff --git a/app/hooks/index.ts b/app/hooks/index.ts index 01db27b..ea6c1d5 100644 --- a/app/hooks/index.ts +++ b/app/hooks/index.ts @@ -1 +1,2 @@ -export * from "./useRegisterModal"; +export { default as useRegisterModal } from "./modal/useRegisterModal"; +export { default as useLoginModal } from "./modal/useLoginModal"; diff --git a/app/hooks/modal/useLoginModal.ts b/app/hooks/modal/useLoginModal.ts new file mode 100644 index 0000000..2fe5e6b --- /dev/null +++ b/app/hooks/modal/useLoginModal.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +interface LoginModalStore { + isOpen: boolean; + onOpen: () => void; + onClose: () => void; +} + +const useLoginModal = create((set) => ({ + isOpen: false, + onOpen: () => set({ isOpen: true }), + onClose: () => set({ isOpen: false }), +})); + +export default useLoginModal; diff --git a/app/hooks/useRegisterModal.ts b/app/hooks/modal/useRegisterModal.ts similarity index 100% rename from app/hooks/useRegisterModal.ts rename to app/hooks/modal/useRegisterModal.ts diff --git a/app/layout.tsx b/app/layout.tsx index b7538ad..8f8840b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import ModalWrapper from "./components/modals/wrapper"; import Navbar from "./components/navbar"; import "./globals.css"; import ToasterProvider from "./providers/ToasterProvider"; +import { getCurrentUser } from "./actions"; export const metadata = { title: "Airbnb", @@ -14,18 +15,20 @@ const font = Nunito({ subsets: ["latin"], }); -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const currentUser = await getCurrentUser(); + return ( - + {children} diff --git a/app/types/index.ts b/app/types/index.ts new file mode 100644 index 0000000..f9a8e15 --- /dev/null +++ b/app/types/index.ts @@ -0,0 +1,10 @@ +import { User } from "@prisma/client"; + +export type SafeUser = Omit< + User, + "createdAt" | "updatedAt" | "emailVerified" +> & { + createdAt: string; + updatedAt: string; + emailVerified: string | null; +}; diff --git a/pages/api/[...nextauth].ts b/pages/api/auth/[...nextauth].ts similarity index 95% rename from pages/api/[...nextauth].ts rename to pages/api/auth/[...nextauth].ts index 4c74cb8..9b56561 100644 --- a/pages/api/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -42,7 +42,7 @@ export const authOptions: AuthOptions = { user.hashedPassword ); if (!isCorrectPassword) { - throw new Error("Invalid credentials"); + throw new Error("Invalid email or password. Please try again."); } return user;