From 0298d48f8da0c8597a51622e4b5a1899ba318bd6 Mon Sep 17 00:00:00 2001 From: javayhu Date: Fri, 21 Feb 2025 01:04:34 +0800 Subject: [PATCH] feat: add authentication components and forms --- src/components/auth/auth-card.tsx | 54 ++++++ src/components/auth/auth-dialog.tsx | 123 ++++++++++++++ src/components/auth/bottom-button.tsx | 22 +++ src/components/auth/error-card.tsx | 18 ++ src/components/auth/login-button.tsx | 69 ++++++++ src/components/auth/login-form.tsx | 158 ++++++++++++++++++ src/components/auth/new-password-form.tsx | 112 +++++++++++++ src/components/auth/new-verification-form.tsx | 62 +++++++ src/components/auth/register-form.tsx | 141 ++++++++++++++++ src/components/auth/reset-form.tsx | 107 ++++++++++++ src/components/auth/social-login-button.tsx | 61 +++++++ 11 files changed, 927 insertions(+) create mode 100644 src/components/auth/auth-card.tsx create mode 100644 src/components/auth/auth-dialog.tsx create mode 100644 src/components/auth/bottom-button.tsx create mode 100644 src/components/auth/error-card.tsx create mode 100644 src/components/auth/login-button.tsx create mode 100644 src/components/auth/login-form.tsx create mode 100644 src/components/auth/new-password-form.tsx create mode 100644 src/components/auth/new-verification-form.tsx create mode 100644 src/components/auth/register-form.tsx create mode 100644 src/components/auth/reset-form.tsx create mode 100644 src/components/auth/social-login-button.tsx diff --git a/src/components/auth/auth-card.tsx b/src/components/auth/auth-card.tsx new file mode 100644 index 0000000..0f4fe87 --- /dev/null +++ b/src/components/auth/auth-card.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { BottomButton } from "@/components/auth/bottom-button"; +import { SocialLoginButton } from "@/components/auth/social-login-button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { siteConfig } from "@/config/site"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import { Logo } from "../logo"; + +interface AuthCardProps { + children: React.ReactNode; + headerLabel: string; + bottomButtonLabel: string; + bottomButtonHref: string; + showSocialLoginButton?: boolean; + className?: string; +} + +export const AuthCard = ({ + children, + headerLabel, + bottomButtonLabel, + bottomButtonHref, + showSocialLoginButton, + className, +}: AuthCardProps) => { + return ( + + + + + + {headerLabel} + + {children} + {showSocialLoginButton && ( + + + + )} + + + + + ); +}; diff --git a/src/components/auth/auth-dialog.tsx b/src/components/auth/auth-dialog.tsx new file mode 100644 index 0000000..e3e8502 --- /dev/null +++ b/src/components/auth/auth-dialog.tsx @@ -0,0 +1,123 @@ +"use client"; + +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import * as React from "react"; +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) => ( + // NOTICE: we changed the overlay background to be black/80 + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + {/* NOTICE: we changed the close button position to the right */} + + + 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, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/auth/bottom-button.tsx b/src/components/auth/bottom-button.tsx new file mode 100644 index 0000000..d63aab7 --- /dev/null +++ b/src/components/auth/bottom-button.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +interface BottomButtonProps { + href: string; + label: string; +} + +export const BottomButton = ({ href, label }: BottomButtonProps) => { + return ( + + ); +}; diff --git a/src/components/auth/error-card.tsx b/src/components/auth/error-card.tsx new file mode 100644 index 0000000..99c4bea --- /dev/null +++ b/src/components/auth/error-card.tsx @@ -0,0 +1,18 @@ +import { AuthCard } from "@/components/auth/auth-card"; +import { TriangleAlertIcon } from "lucide-react"; + +export const ErrorCard = () => { + return ( + +
+ +

Please try again.

+
+
+ ); +}; diff --git a/src/components/auth/login-button.tsx b/src/components/auth/login-button.tsx new file mode 100644 index 0000000..ad0d758 --- /dev/null +++ b/src/components/auth/login-button.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/auth/auth-dialog"; +import { LoginForm } from "@/components/auth/login-form"; +import { useMediaQuery } from "@/hooks/use-media-query"; +// import { authRoutes } from "@/routes"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +interface LoginWrapperProps { + children: React.ReactNode; + mode?: "modal" | "redirect"; + asChild?: boolean; +} + +export const LoginWrapper = ({ + children, + mode = "redirect", + asChild, +}: LoginWrapperProps) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isModalOpen, setIsModalOpen] = useState(false); + const { isTablet, isDesktop } = useMediaQuery(); + + const handleLogin = () => { + router.push("/auth/login"); + }; + + // Close the modal on route change + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + setIsModalOpen(false); + }, [pathname, searchParams]); + + // don't open the modal if the user is already in the auth pages + // keep isTablet or isDesktop open, if user resizes the window + + // TODO: add auth routes + // const isAuthRoute = authRoutes.includes(pathname); + if (mode === "modal" && /* !isAuthRoute && */(isTablet || isDesktop)) { + return ( + + {children} + + + {/* `DialogContent` requires a `DialogTitle` for the component to be accessible for screen reader users. */} + + + + + + ); + } + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: + + {children} + + ); +}; diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx new file mode 100644 index 0000000..a8fd8fe --- /dev/null +++ b/src/components/auth/login-form.tsx @@ -0,0 +1,158 @@ +"use client"; + +// import { login } from "@/actions/login"; +import { AuthCard } from "@/components/auth/auth-card"; +import { Icons } from "@/components/icons/icons"; +import { FormError } from "@/components/shared/form-error"; +import { FormSuccess } from "@/components/shared/form-success"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { LoginSchema } from "@/lib/schemas"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import type * as z from "zod"; + +export const LoginForm = ({ className }: { className?: string }) => { + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl"); + const urlError = + searchParams.get("error") === "OAuthAccountNotLinked" + ? "Email already in use with different provider!" + : ""; + + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [isPending, startTransition] = useTransition(); + + const form = useForm>({ + resolver: zodResolver(LoginSchema), + defaultValues: { + email: "", + password: "", + }, + }); + + const onSubmit = (values: z.infer) => { + setError(""); + setSuccess(""); + + // startTransition(() => { + // login(values, callbackUrl) + // .then((data) => { + // // console.log('login, data:', data); + // if (data?.status === "error") { + // console.log("login, error:", data.message); + // form.reset(); + // setError(data.message); + // } + + // if (data?.status === "success") { + // console.log("login, success:", data.message); + // form.reset(); + // setSuccess(data.message); + + // // if success without redirect url, means sent confirmation email + // if (data.redirectUrl) { + // window.location.href = data.redirectUrl; + // } + // } + // }) + // .catch((error) => { + // console.log("login, error:", error); + // setError("Something went wrong"); + // }); + // }); + }; + + return ( + +
+ +
+ ( + + Email + + + + + + )} + /> + ( + +
+ Password + +
+ + + + +
+ )} + /> +
+ + + + + +
+ ); +}; diff --git a/src/components/auth/new-password-form.tsx b/src/components/auth/new-password-form.tsx new file mode 100644 index 0000000..bf05184 --- /dev/null +++ b/src/components/auth/new-password-form.tsx @@ -0,0 +1,112 @@ +"use client"; + +// import { newPassword } from "@/actions/new-password"; +import { AuthCard } from "@/components/auth/auth-card"; +import { Icons } from "@/components/icons/icons"; +import { FormError } from "@/components/shared/form-error"; +import { FormSuccess } from "@/components/shared/form-success"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { NewPasswordSchema } from "@/lib/schemas"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useSearchParams } from "next/navigation"; +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import type * as z from "zod"; + +export const NewPasswordForm = () => { + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [isPending, startTransition] = useTransition(); + + const form = useForm>({ + resolver: zodResolver(NewPasswordSchema), + defaultValues: { + password: "", + }, + }); + + const onSubmit = (values: z.infer) => { + setError(""); + setSuccess(""); + + // startTransition(() => { + // newPassword(values, token) + // .then((data) => { + // if (data?.status === "error") { + // console.log("newPassword, error:", data.message); + // setError(data.message); + // } + + // if (data?.status === "success") { + // console.log("newPassword, success:", data.message); + // setSuccess(data.message); + // } + // }) + // .catch(() => { + // console.log("newPassword, error:", error); + // setError("Something went wrong"); + // }); + // }); + }; + + return ( + +
+ +
+ ( + + Password + + + + + + )} + /> +
+ + + + + +
+ ); +}; diff --git a/src/components/auth/new-verification-form.tsx b/src/components/auth/new-verification-form.tsx new file mode 100644 index 0000000..90f0162 --- /dev/null +++ b/src/components/auth/new-verification-form.tsx @@ -0,0 +1,62 @@ +"use client"; + +// import { newVerification } from "@/actions/new-verification"; +import { AuthCard } from "@/components/auth/auth-card"; +import { Icons } from "@/components/icons/icons"; +import { FormError } from "@/components/shared/form-error"; +import { FormSuccess } from "@/components/shared/form-success"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +export const NewVerificationForm = () => { + const [error, setError] = useState(); + const [success, setSuccess] = useState(); + + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + + const onSubmit = useCallback(() => { + if (success || error) return; + + if (!token) { + setError("Missing token!"); + return; + } + + // newVerification(token) + // .then((data) => { + // if (data.status === "success") { + // setSuccess(data.message); + // console.log("newVerification, success:", data.message); + // } + // if (data.status === "error") { + // setError(data.message); + // console.log("newVerification, error:", data.message); + // } + // }) + // .catch(() => { + // setError("Something went wrong"); + // }); + }, [token, success, error]); + + useEffect(() => { + onSubmit(); + }, [onSubmit]); + + return ( + +
+ {!success && !error && ( + + )} + + {!success && } +
+
+ ); +}; diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx new file mode 100644 index 0000000..1d109f0 --- /dev/null +++ b/src/components/auth/register-form.tsx @@ -0,0 +1,141 @@ +"use client"; + +// import { register } from "@/actions/register"; +import { AuthCard } from "@/components/auth/auth-card"; +import { Icons } from "@/components/icons/icons"; +import { FormError } from "@/components/shared/form-error"; +import { FormSuccess } from "@/components/shared/form-success"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { RegisterSchema } from "@/lib/schemas"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import type * as z from "zod"; + +export const RegisterForm = () => { + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [isPending, startTransition] = useTransition(); + + const form = useForm>({ + resolver: zodResolver(RegisterSchema), + defaultValues: { + email: "", + password: "", + name: "", + }, + }); + + const onSubmit = (values: z.infer) => { + setError(""); + setSuccess(""); + + // startTransition(() => { + // register(values) + // .then((data) => { + // if (data.status === "error") { + // console.log("register, error:", data.message); + // setError(data.message); + // } + // if (data.status === "success") { + // console.log("register, success:", data.message); + // setSuccess(data.message); + // } + // }) + // .catch((error) => { + // console.log("register, error:", error); + // setError("Something went wrong"); + // }); + // }); + }; + + return ( + +
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> +
+ + + + + +
+ ); +}; diff --git a/src/components/auth/reset-form.tsx b/src/components/auth/reset-form.tsx new file mode 100644 index 0000000..d65111a --- /dev/null +++ b/src/components/auth/reset-form.tsx @@ -0,0 +1,107 @@ +"use client"; + +// import { reset } from "@/actions/reset"; +import { AuthCard } from "@/components/auth/auth-card"; +import { FormError } from "@/components/shared/form-error"; +import { FormSuccess } from "@/components/shared/form-success"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ResetSchema } from "@/lib/schemas"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import type * as z from "zod"; +import { Icons } from "../icons/icons"; + +export const ResetForm = () => { + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [isPending, startTransition] = useTransition(); + + const form = useForm>({ + resolver: zodResolver(ResetSchema), + defaultValues: { + email: "", + }, + }); + + const onSubmit = (values: z.infer) => { + setError(""); + setSuccess(""); + + // startTransition(() => { + // reset(values) + // .then((data) => { + // if (data.status === "error") { + // console.log("reset, error:", data.message); + // setError(data.message); + // } + // if (data.status === "success") { + // console.log("reset, success:", data.message); + // setSuccess(data.message); + // } + // }) + // .catch((error) => { + // console.log("reset, error:", error); + // setError("Something went wrong"); + // }); + // }); + }; + + return ( + +
+ +
+ ( + + Email + + + + + + )} + /> +
+ + + + + +
+ ); +}; diff --git a/src/components/auth/social-login-button.tsx b/src/components/auth/social-login-button.tsx new file mode 100644 index 0000000..f0c89a8 --- /dev/null +++ b/src/components/auth/social-login-button.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Icons } from "@/components/icons/icons"; +import { Button } from "@/components/ui/button"; +// import { DEFAULT_LOGIN_REDIRECT } from "@/routes"; +// import { signIn } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { FaBrandsGitHub } from "../icons/github"; +import { FaBrandsGoogle } from "../icons/google"; + +/** + * social login buttons + */ +export const SocialLoginButton = () => { + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl"); + const [isLoading, setIsLoading] = useState<"google" | "github" | null>(null); + + const onClick = async (provider: "google" | "github") => { + setIsLoading(provider); + // signIn(provider, { + // callbackUrl: callbackUrl || DEFAULT_LOGIN_REDIRECT, + // }); + // no need to reset the loading state, keep loading before webpage redirects + // setIsLoading(null); + }; + + return ( +
+ + +
+ ); +};