├── .eslintrc.json ├── app ├── api │ └── auth │ │ └── [...nextauth] │ │ └── route.ts ├── favicon.ico ├── (auth) │ ├── layout.tsx │ ├── reset │ │ └── page.tsx │ ├── register │ │ └── page.tsx │ ├── resend │ │ └── page.tsx │ ├── error │ │ └── page.tsx │ ├── login │ │ └── page.tsx │ ├── verify │ │ └── page.tsx │ ├── new-password │ │ └── page.tsx │ └── two-factor │ │ └── page.tsx ├── (main) │ ├── settings │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ └── profile │ │ └── page.tsx ├── layout.tsx ├── not-found.tsx └── _components │ └── navbar.tsx ├── postcss.config.js ├── lib ├── db.ts ├── auth.ts └── utils.ts ├── auth ├── config.ts ├── providers.ts └── index.ts ├── public ├── assets │ ├── error.svg │ └── email-verified.svg ├── vercel.svg └── next.svg ├── routes.ts ├── services ├── account.ts ├── user.ts ├── mail.ts ├── two-factor-confirmation.ts ├── two-factor-token.ts ├── verification-token.ts └── reset-password-token.ts ├── next.config.js ├── components.json ├── .gitignore ├── types ├── next-auth.d.ts └── index.ts ├── tsconfig.json ├── components ├── form │ ├── verify-token-form.tsx │ ├── resend-form.tsx │ ├── reset-form.tsx │ ├── new-password-form.tsx │ ├── two-factor-form.tsx │ ├── register-form.tsx │ ├── login-form.tsx │ └── profile-form.tsx ├── ui │ ├── label.tsx │ ├── separator.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── form.tsx │ └── dropdown-menu.tsx └── auth │ ├── error-card.tsx │ ├── social.tsx │ ├── form-input.tsx │ ├── form-toggle.tsx │ └── card-wrapper.tsx ├── .env.example ├── middleware.ts ├── README.md ├── actions ├── resend.ts ├── verify-token.ts ├── register.ts ├── reset-password.ts ├── new-password.ts ├── two-factor.ts ├── profile.ts └── login.ts ├── styles └── globals.css ├── package.json ├── prisma └── schema.prisma ├── tailwind.config.ts └── schemas └── index.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/auth"; -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zulmy-azhary/next-auth-boilerplate/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default async function AuthLayout({ children }: { children: React.ReactNode }) { 2 | return
{children}
; 3 | } 4 | -------------------------------------------------------------------------------- /app/(main)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | 3 | export const metadata: Metadata = { 4 | title: "Settings", 5 | }; 6 | 7 | export default function SettingsPage() { 8 | return ( 9 |
SettingsPage
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | export const db = globalThis.prisma || new PrismaClient(); 8 | 9 | if (process.env.NODE_ENV !== "production") globalThis.prisma = db; -------------------------------------------------------------------------------- /auth/config.ts: -------------------------------------------------------------------------------- 1 | import { CredentialsProvider, GithubProvider, GoogleProvider } from "@/auth/providers"; 2 | import type { NextAuthConfig } from "next-auth"; 3 | 4 | export const authConfig = { 5 | providers: [CredentialsProvider, GithubProvider, GoogleProvider], 6 | } satisfies NextAuthConfig; -------------------------------------------------------------------------------- /app/(auth)/reset/page.tsx: -------------------------------------------------------------------------------- 1 | import { ResetForm } from "@/components/form/reset-form"; 2 | import { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Forgot Password" 6 | } 7 | 8 | export default function ForgotPassword() { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /public/assets/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | error 5 | 6 | 7 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth" 2 | 3 | export const currentUser = async () => { 4 | const session = await auth(); 5 | 6 | return session?.user; 7 | } 8 | 9 | export const currentRole = async () => { 10 | const session = await auth(); 11 | 12 | return session?.user.role; 13 | } -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { RegisterForm } from "@/components/form/register-form"; 2 | import type { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Register", 6 | }; 7 | 8 | export default function RegisterPage() { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /app/(auth)/resend/page.tsx: -------------------------------------------------------------------------------- 1 | import { ResendForm } from "@/components/form/resend-form"; 2 | import { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Resend Confirmation", 6 | }; 7 | 8 | export default function ResendPage() { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "@/app/_components/navbar"; 2 | 3 | export default async function MainLayout({ children }: { children: React.ReactNode }) { 4 | return ( 5 | <> 6 | 7 |
{children}
8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/(main)/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@/lib/auth"; 2 | import { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Home", 6 | }; 7 | 8 | export default async function Home() { 9 | const user = await currentUser(); 10 | return
Hello {user?.name}
; 11 | } 12 | -------------------------------------------------------------------------------- /routes.ts: -------------------------------------------------------------------------------- 1 | export const publicRoutes: string[] = ["/verify"]; 2 | 3 | export const authRoutes: string[] = [ 4 | "/login", 5 | "/register", 6 | "/error", 7 | "/resend", 8 | "/reset", 9 | "/new-password", 10 | "/two-factor" 11 | ]; 12 | 13 | export const apiAuthPrefix: string = "/api/auth"; 14 | 15 | export const DEFAULT_LOGIN_REDIRECT: string = "/"; 16 | -------------------------------------------------------------------------------- /services/account.ts: -------------------------------------------------------------------------------- 1 | // Services for OAuth providers such as Google, Github, etc... 2 | import { db } from "@/lib/db"; 3 | 4 | export const getAccountByUserId = async (userId: string) => { 5 | try { 6 | const account = await db.account.findFirst({ 7 | where: { userId }, 8 | }); 9 | 10 | return account; 11 | } catch { 12 | return null; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "avatars.githubusercontent.com", 8 | }, 9 | { 10 | protocol: "https", 11 | hostname: "lh3.googleusercontent.com", 12 | }, 13 | ], 14 | }, 15 | }; 16 | 17 | module.exports = nextConfig 18 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "@/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /app/(auth)/error/page.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorCard } from "@/components/auth/error-card"; 2 | import { Metadata } from "next"; 3 | import { AuthError } from "next-auth"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Oops! Something went wrong", 7 | }; 8 | 9 | export default function AuthErrorPage({ 10 | searchParams, 11 | }: { 12 | searchParams: { message: AuthError["type"] }; 13 | }) { 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from "@/components/form/login-form"; 2 | import { Metadata } from "next"; 3 | import { redirect } from "next/navigation"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Login", 7 | }; 8 | 9 | export default async function LoginPage({ searchParams }: { searchParams: { error: string } }) { 10 | if (searchParams.error) redirect(`/error?message=${searchParams.error}`); 11 | return ; 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /app/(main)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { ProfileForm } from "@/components/form/profile-form"; 2 | import { currentUser } from "@/lib/auth"; 3 | import { Metadata } from "next"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Profile", 7 | }; 8 | 9 | export default async function ProfilePage() { 10 | const user = await currentUser(); 11 | if (!user) return; 12 | 13 | return ( 14 |
15 |

Profile Settings

16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(auth)/verify/page.tsx: -------------------------------------------------------------------------------- 1 | import { newVerification } from "@/actions/verify-token"; 2 | import { NewVerificationForm } from "@/components/form/verify-token-form"; 3 | import { Metadata } from "next"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Verify Email", 8 | }; 9 | 10 | export default async function NewVerificationPage({ 11 | searchParams, 12 | }: { 13 | searchParams: { token: string }; 14 | }) { 15 | if (!searchParams.token) redirect("/login"); 16 | const data = await newVerification(searchParams.token); 17 | 18 | return ; 19 | } 20 | -------------------------------------------------------------------------------- /app/(auth)/new-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { NewPasswordForm } from "@/components/form/new-password-form"; 2 | import { getResetPasswordToken } from "@/services/reset-password-token"; 3 | import { Metadata } from "next"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export const metadata: Metadata = { 7 | title: "Reset Password", 8 | }; 9 | 10 | export default async function NewPassword({ searchParams }: { searchParams: { token: string } }) { 11 | if (!searchParams.token) redirect("/"); 12 | const resetPasswordToken = await getResetPasswordToken(searchParams.token); 13 | if (!resetPasswordToken) redirect("/"); 14 | 15 | return ; 16 | } 17 | -------------------------------------------------------------------------------- /types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from "@prisma/client"; 2 | import { DefaultSession } from "next-auth"; 3 | 4 | export type ExtendedUser = DefaultSession["user"] & { 5 | role: UserRole; 6 | isTwoFactorEnabled: boolean; 7 | isOAuth: boolean; 8 | }; 9 | 10 | declare module "next-auth" { 11 | interface Session { 12 | user: ExtendedUser; 13 | } 14 | } 15 | 16 | declare module "@auth/core/jwt" { 17 | interface JWT extends ExtendedUser {} 18 | } 19 | 20 | // declare module "next-auth/providers/github" { 21 | // interface GithubProfile { 22 | // role: Role; 23 | // } 24 | // } 25 | 26 | // declare module "next-auth/providers/google" { 27 | // interface GoogleProfile { 28 | // role: Role; 29 | // } 30 | // } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | }, 24 | "typeRoots": ["types"] 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "@/styles/globals.css"; 4 | import { Toaster } from "@/components/ui/sonner"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: { 10 | default: "Next Dashboard | Zulmy Azhary", 11 | template: "Next Dashboard | %s", 12 | }, 13 | description: "Generated by create next app", 14 | }; 15 | 16 | export default async function RootLayout({ children }: { children: React.ReactNode }) { 17 | return ( 18 | 19 | 20 | 21 | {children} 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/form/verify-token-form.tsx: -------------------------------------------------------------------------------- 1 | import { CardWrapper } from "@/components/auth/card-wrapper"; 2 | import type { Response } from "@/types"; 3 | import { redirect } from "next/navigation"; 4 | 5 | type NewVerificationFormProps = { 6 | data: Response; 7 | }; 8 | 9 | export const NewVerificationForm = ({ data }: NewVerificationFormProps) => { 10 | if (!data.success) { 11 | return redirect("/login"); 12 | } 13 | 14 | return ( 15 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Your app base url 2 | NEXT_PUBLIC_APP_URL="http://localhost:3000" 3 | 4 | # Your postgres' database url 5 | # example: postgresql://username:password@host:port/database 6 | POSTGRES_URL= 7 | POSTGRES_PRISMA_URL= 8 | POSTGRES_URL_NON_POOLING= 9 | POSTGRES_USER= 10 | POSTGRES_HOST= 11 | POSTGRES_PASSWORD= 12 | POSTGRES_DATABASE= 13 | 14 | # You can generate by run this command on your terminal: openssl rand -base64 32 15 | AUTH_SECRET= 16 | 17 | # You can get github id & secret by creating OAuth Apps from Settings > Developer Settings > OAuth Apps 18 | # More info: https://next-auth.js.org/providers/github 19 | GITHUB_ID= 20 | GITHUB_SECRET= 21 | 22 | # https://next-auth.js.org/providers/google 23 | GOOGLE_ID= 24 | GOOGLE_SECRET= 25 | 26 | # https://resend.com 27 | RESEND_API_KEY= 28 | RESEND_DOMAIN= 29 | EMAIL_FROM="Next Dashboard " 30 | 31 | # You can generate by run this command on your terminal: openssl rand -hex 64 32 | JWT_SECRET= 33 | -------------------------------------------------------------------------------- /components/auth/error-card.tsx: -------------------------------------------------------------------------------- 1 | import { CardWrapper } from "@/components/auth/card-wrapper"; 2 | import { AuthError } from "next-auth"; 3 | import { redirect } from "next/navigation"; 4 | 5 | type ErrorCardProps = { 6 | message?: AuthError["type"]; 7 | }; 8 | 9 | export const ErrorCard = ({ message }: ErrorCardProps) => { 10 | let headerDescription = 11 | "Oops! Something went wrong. Please contact administrator for more details or try again later."; 12 | 13 | if (!message) { 14 | redirect("/login"); 15 | } 16 | 17 | if (message === "OAuthAccountNotLinked") { 18 | headerDescription = 19 | "Another account already registered with the same Email Address. Please login the different one."; 20 | } 21 | 22 | return ( 23 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { Toaster as Sonner } from "sonner"; 5 | 6 | type ToasterProps = React.ComponentProps; 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme(); 10 | 11 | return ( 12 | 26 | ); 27 | }; 28 | 29 | export { Toaster }; 30 | -------------------------------------------------------------------------------- /services/user.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { registerSchema } from "@/schemas"; 3 | import { Prisma } from "@prisma/client"; 4 | import { z } from "zod"; 5 | 6 | export const getUserByEmail = async (email: string) => { 7 | try { 8 | const user = await db.user.findUnique({ where: { email } }); 9 | 10 | return user; 11 | } catch { 12 | return null; 13 | } 14 | }; 15 | 16 | export const getUserById = async (id: string) => { 17 | try { 18 | const user = await db.user.findUnique({ where: { id } }); 19 | 20 | return user; 21 | } catch { 22 | return null; 23 | } 24 | }; 25 | 26 | export const createUser = async (payload: z.infer) => { 27 | try { 28 | return await db.user.create({ 29 | data: payload, 30 | }); 31 | } catch { 32 | return null; 33 | } 34 | }; 35 | 36 | type UpdateUserType = Prisma.Args["data"]; 37 | export const updateUserById = async (id: string, payload: UpdateUserType) => { 38 | try { 39 | return await db.user.update({ 40 | where: { id }, 41 | data: payload, 42 | }); 43 | } catch { 44 | return null; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /components/auth/social.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { signIn } from "next-auth/react"; 5 | import { useSearchParams } from "next/navigation"; 6 | import { IoLogoGithub } from "react-icons/io5"; 7 | import { FcGoogle } from "react-icons/fc"; 8 | import { DEFAULT_LOGIN_REDIRECT } from "@/routes"; 9 | 10 | export const Social = () => { 11 | const searchParams = useSearchParams(); 12 | const callbackUrl = searchParams.get("callbackUrl") || DEFAULT_LOGIN_REDIRECT; 13 | 14 | const onClick = (provider: "google" | "github") => { 15 | signIn(provider, { 16 | callbackUrl, 17 | }); 18 | }; 19 | 20 | return ( 21 |
22 | 30 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authConfig } from "@/auth/config"; 2 | import NextAuth from "next-auth"; 3 | import { DEFAULT_LOGIN_REDIRECT, apiAuthPrefix, authRoutes, publicRoutes } from "@/routes"; 4 | 5 | export const { auth } = NextAuth(authConfig); 6 | 7 | export default auth((req) => { 8 | const { nextUrl } = req; 9 | const isLoggedIn = !!req.auth; 10 | 11 | const isApiAuthRoute = nextUrl.pathname.startsWith(apiAuthPrefix); 12 | const isPublicRoutes = publicRoutes.includes(nextUrl.pathname); 13 | const isAuthRoutes = authRoutes.includes(nextUrl.pathname); 14 | 15 | if (isApiAuthRoute) { 16 | return null; 17 | } 18 | 19 | if (isAuthRoutes) { 20 | if (isLoggedIn) { 21 | return Response.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl)); 22 | } 23 | return null; 24 | } 25 | 26 | if (!isLoggedIn && !isPublicRoutes) { 27 | return Response.redirect(new URL("/login", nextUrl)); 28 | } 29 | 30 | return null; 31 | }); 32 | 33 | // Optionally, don't invoke Middleware on some paths 34 | // Read more: https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher 35 | export const config = { 36 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 37 | }; 38 | -------------------------------------------------------------------------------- /app/(auth)/two-factor/page.tsx: -------------------------------------------------------------------------------- 1 | import { TwoFactorForm } from "@/components/form/two-factor-form"; 2 | import { verifyJwtToken } from "@/lib/utils"; 3 | import { loginSchema } from "@/schemas"; 4 | import { getTwoFactorTokenByEmail } from "@/services/two-factor-token"; 5 | import { Metadata } from "next"; 6 | import { cookies } from "next/headers"; 7 | import { redirect } from "next/navigation"; 8 | import { z } from "zod"; 9 | 10 | export const metadata: Metadata = { 11 | title: "Two-Factor Authentication", 12 | }; 13 | 14 | export default async function TwoFactorPage() { 15 | const cookieStore = cookies(); 16 | 17 | let credentials = cookieStore.get("credentials-session"); 18 | if (!credentials) { 19 | redirect("/"); 20 | } 21 | 22 | const verifyToken = verifyJwtToken>(credentials.value); 23 | if (!verifyToken.valid || !verifyToken.decoded) { 24 | redirect("/"); 25 | } 26 | 27 | const existingToken = await getTwoFactorTokenByEmail(verifyToken.decoded.email); 28 | if (!existingToken) { 29 | redirect("/"); 30 | } 31 | 32 | return ( 33 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /components/auth/form-input.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; 2 | import { Input } from "@/components/ui/input"; 3 | import { cn } from "@/lib/utils"; 4 | import { Control, FieldValues, Path } from "react-hook-form"; 5 | 6 | type FormInputProps = React.ComponentPropsWithRef<"input"> & { 7 | control: Control; 8 | name: Path; 9 | label: string; 10 | isPending?: boolean; 11 | }; 12 | 13 | export const FormInput = (props: FormInputProps) => { 14 | const { control, name, label, isPending, disabled, ...rest } = props; 15 | return ( 16 | ( 20 | 21 | {label} 22 | 23 | 29 | 30 | 31 | 32 | )} 33 | /> 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /auth/providers.ts: -------------------------------------------------------------------------------- 1 | import { loginSchema } from "@/schemas"; 2 | import { getUserByEmail } from "@/services/user"; 3 | import Credentials from "next-auth/providers/credentials"; 4 | import Github from "next-auth/providers/github"; 5 | import Google from "next-auth/providers/google"; 6 | import bcrypt from "bcryptjs"; 7 | 8 | export const CredentialsProvider = Credentials({ 9 | async authorize(credentials) { 10 | const validatedFields = loginSchema.safeParse(credentials); 11 | 12 | if (validatedFields.success) { 13 | const { email, password } = validatedFields.data; 14 | 15 | const user = await getUserByEmail(email); 16 | if (!user || !user.password) return null; 17 | 18 | const passwordsMatch = await bcrypt.compare(password, user.password); 19 | 20 | if (passwordsMatch) return user; 21 | } 22 | 23 | return null; 24 | }, 25 | }); 26 | 27 | export const GithubProvider = Github({ 28 | clientId: process.env.GITHUB_ID as string, 29 | clientSecret: process.env.GITHUB_SECRET as string, 30 | }); 31 | 32 | export const GoogleProvider = Google({ 33 | clientId: process.env.GOOGLE_ID as string, 34 | clientSecret: process.env.GOOGLE_SECRET as string, 35 | authorization: { 36 | params: { 37 | prompt: "consent", 38 | access_type: "offline", 39 | response_type: "code", 40 | }, 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /services/mail.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | 3 | const resend = new Resend(process.env.RESEND_API_KEY); 4 | 5 | export const sendVerificationEmail = async (email: string, token: string) => { 6 | const verifyEmailLink = `${process.env.NEXT_PUBLIC_APP_URL}/verify?token=${token}`; 7 | 8 | await resend.emails.send({ 9 | from: process.env.EMAIL_FROM as string, 10 | to: email, 11 | subject: "[Next Dashboard] Action required: Verify your email", 12 | html: `

Click Here to verify your email.

`, 13 | }); 14 | }; 15 | 16 | export const sendResetPasswordEmail = async (email: string, token: string) => { 17 | const resetPasswordLink = `${process.env.NEXT_PUBLIC_APP_URL}/new-password?token=${token}`; 18 | 19 | await resend.emails.send({ 20 | from: process.env.EMAIL_FROM as string, 21 | to: email, 22 | subject: "[Next Dashboard] Action required: Reset your password", 23 | html: `

Click Here to reset your password.

`, 24 | }); 25 | }; 26 | 27 | export const sendTwoFactorEmail = async (email: string, token: string) => { 28 | await resend.emails.send({ 29 | from: process.env.EMAIL_FROM as string, 30 | to: email, 31 | subject: "[Next Dashboard] Action required: Confirm Two-Factor Authentication", 32 | html: `

${token} is your authentication Code.

`, 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /components/auth/form-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/components/ui/form"; 2 | import { Control, FieldValues, Path } from "react-hook-form"; 3 | import { Switch } from "@/components/ui/switch"; 4 | 5 | type FormToggleProps = React.ComponentPropsWithRef<"button"> & { 6 | control: Control; 7 | name: Path; 8 | label: string; 9 | isPending?: boolean; 10 | description: string; 11 | }; 12 | 13 | export const FormToggle = (props: FormToggleProps) => { 14 | const { control, name, label, description, isPending, ...rest } = props; 15 | return ( 16 | ( 20 | 21 | {label} 22 |
23 | 24 | {description} 25 | 26 | 27 | 33 | 34 |
35 |
36 | )} 37 | /> 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /services/two-factor-confirmation.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { setTokenExpiration } from "@/lib/utils"; 3 | 4 | export const generateTwoFactorConfirmation = async (userId: string) => { 5 | const existingTwoFactorConfirmation = await getTwoFactorConfirmationByUserId(userId); 6 | if (existingTwoFactorConfirmation) { 7 | await deleteTwoFactorConfirmationById(existingTwoFactorConfirmation.id); 8 | } 9 | 10 | const expires = setTokenExpiration(60 * 15); // 15 minutes 11 | 12 | const twoFactorConfirmation = await db.twoFactorConfirmation.create({ 13 | data: { 14 | userId, 15 | expires, 16 | }, 17 | }); 18 | 19 | return twoFactorConfirmation; 20 | }; 21 | 22 | export const getTwoFactorConfirmationByUserId = async (userId: string) => { 23 | try { 24 | const twoFactorConfirmation = await db.twoFactorConfirmation.findUnique({ 25 | where: { userId }, 26 | }); 27 | 28 | return twoFactorConfirmation; 29 | } catch { 30 | return null; 31 | } 32 | }; 33 | 34 | export const deleteTwoFactorConfirmationById = async (id: string) => { 35 | try { 36 | return await db.twoFactorConfirmation.delete({ 37 | where: { id }, 38 | }); 39 | } catch { 40 | return null; 41 | } 42 | }; 43 | 44 | export const deleteTwoFactorConfirmationByUserId = async (userId: string) => { 45 | try { 46 | return await db.twoFactorConfirmation.delete({ 47 | where: { userId }, 48 | }); 49 | } catch { 50 | return null; 51 | } 52 | } -------------------------------------------------------------------------------- /services/two-factor-token.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { setTokenExpiration } from "@/lib/utils"; 3 | import crypto from "node:crypto"; 4 | 5 | export const generateTwoFactorToken = async (email: string) => { 6 | const existingToken = await getTwoFactorTokenByEmail(email); 7 | if (existingToken) { 8 | await deleteTwoFactorTokenById(existingToken.id); 9 | } 10 | 11 | const token = String(crypto.randomInt(100000, 1000000)); 12 | const expires = setTokenExpiration(60 * 2); // 2 minutes 13 | 14 | const twoFactorToken = await db.twoFactorToken.create({ 15 | data: { 16 | email, 17 | token, 18 | expires, 19 | }, 20 | }); 21 | 22 | return twoFactorToken; 23 | }; 24 | 25 | export const getTwoFactorToken = async (token: string) => { 26 | try { 27 | const twoFactorToken = await db.twoFactorToken.findUnique({ 28 | where: { token }, 29 | }); 30 | 31 | return twoFactorToken; 32 | } catch { 33 | return null; 34 | } 35 | }; 36 | 37 | export const getTwoFactorTokenByEmail = async (email: string) => { 38 | try { 39 | const twoFactorToken = await db.twoFactorToken.findFirst({ 40 | where: { email }, 41 | }); 42 | 43 | return twoFactorToken; 44 | } catch { 45 | return null; 46 | } 47 | }; 48 | 49 | export const deleteTwoFactorTokenById = async (id: string) => { 50 | try { 51 | return await db.twoFactorToken.delete({ 52 | where: { id }, 53 | }); 54 | } catch { 55 | return null; 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /services/verification-token.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { setTokenExpiration } from "@/lib/utils"; 3 | import { v4 as uuid } from "uuid"; 4 | 5 | export const generateVerificationToken = async (email: string) => { 6 | const existingToken = await getVerificationTokenByEmail(email); 7 | if (existingToken) { 8 | await deleteVerificationTokenById(existingToken.id); 9 | } 10 | 11 | const token = uuid(); 12 | const expires = setTokenExpiration(); 13 | 14 | const verificationToken = await db.verificationToken.create({ 15 | data: { 16 | email, 17 | token, 18 | expires, 19 | }, 20 | }); 21 | 22 | return verificationToken; 23 | }; 24 | 25 | export const getVerificationToken = async (token: string) => { 26 | try { 27 | const verificationToken = await db.verificationToken.findUnique({ 28 | where: { token }, 29 | }); 30 | 31 | return verificationToken; 32 | } catch { 33 | return null; 34 | } 35 | }; 36 | 37 | export const getVerificationTokenByEmail = async (email: string) => { 38 | try { 39 | const verificationToken = await db.verificationToken.findFirst({ 40 | where: { email }, 41 | }); 42 | 43 | return verificationToken; 44 | } catch { 45 | return null; 46 | } 47 | }; 48 | 49 | export const deleteVerificationTokenById = async (id: string) => { 50 | try { 51 | return await db.verificationToken.delete({ 52 | where: { id }, 53 | }); 54 | } catch { 55 | return null; 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /services/reset-password-token.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { setTokenExpiration } from "@/lib/utils"; 3 | import { v4 as uuid } from "uuid"; 4 | 5 | export const generateResetPasswordToken = async (email: string) => { 6 | const existingToken = await getResetPasswordTokenByEmail(email); 7 | if (existingToken) { 8 | await deleteResetPasswordTokenById(existingToken.id); 9 | } 10 | 11 | const token = uuid(); 12 | const expires = setTokenExpiration(); 13 | 14 | const resetPasswordToken = await db.resetPasswordToken.create({ 15 | data: { 16 | email, 17 | token, 18 | expires, 19 | }, 20 | }); 21 | 22 | return resetPasswordToken; 23 | }; 24 | 25 | export const getResetPasswordToken = async (token: string) => { 26 | try { 27 | const resetPasswordToken = await db.resetPasswordToken.findUnique({ 28 | where: { token }, 29 | }); 30 | 31 | return resetPasswordToken; 32 | } catch { 33 | return null; 34 | } 35 | }; 36 | 37 | export const getResetPasswordTokenByEmail = async (email: string) => { 38 | try { 39 | const resetPasswordToken = await db.resetPasswordToken.findFirst({ 40 | where: { email }, 41 | }); 42 | 43 | return resetPasswordToken; 44 | } catch { 45 | return null; 46 | } 47 | }; 48 | 49 | export const deleteResetPasswordTokenById = async (id: string) => { 50 | try { 51 | return await db.resetPasswordToken.delete({ 52 | where: { id }, 53 | }); 54 | } catch { 55 | return null; 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /actions/resend.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { response } from "@/lib/utils"; 4 | import { resendSchema } from "@/schemas"; 5 | import { sendVerificationEmail } from "@/services/mail"; 6 | import { generateVerificationToken, getVerificationTokenByEmail } from "@/services/verification-token"; 7 | import { z } from "zod"; 8 | 9 | export const resendToken = async (payload: z.infer) => { 10 | // Check if user input is not valid. 11 | const validatedFields = resendSchema.safeParse(payload); 12 | if (!validatedFields.success) { 13 | return response({ 14 | success: false, 15 | error: { 16 | code: 422, 17 | message: "Invalid fields.", 18 | }, 19 | }); 20 | } 21 | 22 | const { email } = validatedFields.data; 23 | 24 | // Check if token doesn't exist, then return an error. 25 | const existingToken = await getVerificationTokenByEmail(email); 26 | if (!existingToken) { 27 | return response({ 28 | success: false, 29 | error: { 30 | code: 422, 31 | message: "Failed to resend verification email.", 32 | }, 33 | }); 34 | } 35 | 36 | // Generate verification token and resend to the email. 37 | const verificationToken = await generateVerificationToken(existingToken.email); 38 | await sendVerificationEmail(verificationToken.email, verificationToken.token); 39 | 40 | // Return response success. 41 | return response({ 42 | success: true, 43 | code: 201, 44 | message: "Confirmation email sent. Please check your email.", 45 | }); 46 | } -------------------------------------------------------------------------------- /public/assets/email-verified.svg: -------------------------------------------------------------------------------- 1 | email-verification -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | const responseStatus = { 2 | 200: "OK", 3 | 201: "Created", 4 | 202: "Accepted", 5 | 203: "Non-Authoritative Information", 6 | 204: "No Content", 7 | 400: "Bad Request", 8 | 401: "Unauthorized", 9 | 402: "Payment Required", 10 | 403: "Forbidden", 11 | 404: "Not Found", 12 | 405: "Method Not Allowed", 13 | 406: "Not Acceptable", 14 | 408: "Request Timeout", 15 | 410: "Gone", 16 | 422: "Unprocessable Entity", 17 | 429: "Too Many Requests", 18 | 500: "Internal Server Error", 19 | 502: "Bad Gateway", 20 | 503: "Service Unavailable", 21 | } as const; 22 | 23 | const reason = { 24 | REQUIRED: "The requested resource is required", 25 | NOT_AVAILABLE: "The requested resource is not available", 26 | EXPIRED: "The requested resource is expired", 27 | } as const; 28 | 29 | type ResponseStatus = typeof responseStatus; 30 | type ResponseCode = keyof ResponseStatus; 31 | 32 | type ErrorType = keyof typeof reason; 33 | 34 | type ResponseError = { 35 | success: false; 36 | error: { 37 | code: ResponseCode; 38 | type?: ErrorType; 39 | message: string; 40 | }; 41 | }; 42 | 43 | export type ResponseWithMessage = 44 | | { 45 | success: true; 46 | code: ResponseCode; 47 | message: string; 48 | } 49 | | ResponseError; 50 | 51 | export type ResponseSuccess = 52 | | { 53 | success: true; 54 | code: ResponseCode; 55 | message?: string; 56 | data: T; 57 | } 58 | | ResponseError; 59 | 60 | export type Response = T extends object ? ResponseSuccess : ResponseWithMessage; 61 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "404 Not Found", 6 | }; 7 | 8 | export default function NotFound() { 9 | return ( 10 |
11 |
12 |
13 |

14 | Error 15 |

16 |

404

17 |
18 | 19 |
20 |

21 | Page Not Found 22 |

23 |

24 | The content you’re looking for doesn’t exist. Either it was removed, or you mistyped the 25 | link.
26 |
27 | Sorry about that! Please visit our homepage to get where you need to go. 28 |

29 | 33 | Go back to Homepage 34 | 35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /actions/verify-token.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { isExpired, response } from "@/lib/utils"; 4 | import { getUserByEmail, updateUserById } from "@/services/user"; 5 | import { deleteVerificationTokenById, getVerificationToken } from "@/services/verification-token"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export const newVerification = async (token: string) => { 9 | // Check if token doesn't exist, then return an error. 10 | const existingToken = await getVerificationToken(token); 11 | if (!existingToken) { 12 | return response({ 13 | success: false, 14 | error: { 15 | code: 422, 16 | message: "Invalid token provided.", 17 | }, 18 | }); 19 | } 20 | 21 | // Check if token has expired, then redirect to the resend form. 22 | const hasExpired = isExpired(existingToken.expires); 23 | if (hasExpired) { 24 | redirect("/resend"); 25 | } 26 | 27 | // Check if email address doesn't exist, then return an error 28 | const existingUser = await getUserByEmail(existingToken.email); 29 | if (!existingUser || !existingUser.email || !existingUser.password) { 30 | return response({ 31 | success: false, 32 | error: { 33 | code: 401, 34 | message: "Email address does not exist.", 35 | }, 36 | }); 37 | } 38 | 39 | // Update user verified based on current datetime. 40 | await updateUserById(existingUser.id, { 41 | emailVerified: new Date(), 42 | email: existingToken.email, // This is needed when user want to change their email address 43 | }); 44 | // Then delete verify token. 45 | await deleteVerificationTokenById(existingToken.id); 46 | 47 | return response({ 48 | success: true, 49 | code: 200, 50 | message: "Your email address has been verified.", 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /actions/register.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { registerSchema } from "@/schemas"; 4 | import { z } from "zod"; 5 | import { createUser, getUserByEmail } from "@/services/user"; 6 | import { generateVerificationToken } from "@/services/verification-token"; 7 | import { sendVerificationEmail } from "@/services/mail"; 8 | import { hashPassword, response } from "@/lib/utils"; 9 | 10 | export const register = async (payload: z.infer) => { 11 | // Check if user input is not valid. 12 | const validatedFields = registerSchema.safeParse(payload); 13 | if (!validatedFields.success) { 14 | return response({ 15 | success: false, 16 | error: { 17 | code: 422, 18 | message: "Invalid fields.", 19 | }, 20 | }); 21 | } 22 | const { name, email, password } = validatedFields.data; 23 | 24 | // Check if user already exist, then return an error. 25 | const existingUser = await getUserByEmail(email); 26 | if (existingUser) { 27 | return response({ 28 | success: false, 29 | error: { 30 | code: 422, 31 | message: "Email address already exists. Please use another one.", 32 | }, 33 | }); 34 | } 35 | 36 | // Hash password that user entered. 37 | const hashedPassword = await hashPassword(password); 38 | 39 | // Create an user. 40 | await createUser({ name, email, password: hashedPassword }); 41 | 42 | // Generate verification token, then send it to the email. 43 | const verificationToken = await generateVerificationToken(email); 44 | await sendVerificationEmail(verificationToken.email, verificationToken.token); 45 | 46 | // Return response success. 47 | return response({ 48 | success: true, 49 | code: 201, 50 | message: "Confirmation email sent. Please check your email.", 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /actions/reset-password.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { z } from "zod"; 4 | import { resetPasswordSchema } from "@/schemas"; 5 | import { getUserByEmail } from "@/services/user"; 6 | import { generateResetPasswordToken } from "@/services/reset-password-token"; 7 | import { sendResetPasswordEmail } from "@/services/mail"; 8 | import { response } from "@/lib/utils"; 9 | 10 | export const resetPassword = async (payload: z.infer) => { 11 | // Check if user input is not valid. 12 | const validatedFields = resetPasswordSchema.safeParse(payload); 13 | if (!validatedFields.success) { 14 | return response({ 15 | success: false, 16 | error: { 17 | code: 422, 18 | message: "Invalid fields.", 19 | }, 20 | }); 21 | } 22 | 23 | const { email } = validatedFields.data; 24 | 25 | // Check if user doesn't exist, then return an error. 26 | const existingUser = await getUserByEmail(email); 27 | if (!existingUser || !existingUser.email || !existingUser.password) { 28 | return response({ 29 | success: false, 30 | error: { 31 | code: 401, 32 | message: "Email address does not exist.", 33 | }, 34 | }); 35 | } 36 | 37 | // Check if user email isn't verified yet, then return an error. 38 | if (!existingUser.emailVerified) { 39 | return response({ 40 | success: false, 41 | error: { 42 | code: 401, 43 | message: "Your email address is not verified yet. Please check your email.", 44 | }, 45 | }); 46 | } 47 | 48 | // Generate reset password token, then send it to the email. 49 | const resetPasswordToken = await generateResetPasswordToken(email); 50 | await sendResetPasswordEmail(resetPasswordToken.email, resetPasswordToken.token); 51 | 52 | // Return response success. 53 | return response({ 54 | success: true, 55 | code: 201, 56 | message: "Email has been sent. Please check to your email.", 57 | }); 58 | }; 59 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, body, :root { 6 | height: 100%; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 222.2 84% 4.9%; 13 | 14 | --card: 0 0% 100%; 15 | --card-foreground: 222.2 84% 4.9%; 16 | 17 | --popover: 0 0% 100%; 18 | --popover-foreground: 222.2 84% 4.9%; 19 | 20 | --primary: 222.2 47.4% 11.2%; 21 | --primary-foreground: 210 40% 98%; 22 | 23 | --secondary: 210 40% 96.1%; 24 | --secondary-foreground: 222.2 47.4% 11.2%; 25 | 26 | --muted: 210 40% 96.1%; 27 | --muted-foreground: 215.4 16.3% 46.9%; 28 | 29 | --accent: 210 40% 96.1%; 30 | --accent-foreground: 222.2 47.4% 11.2%; 31 | 32 | --destructive: 0 84.2% 60.2%; 33 | --destructive-foreground: 210 40% 98%; 34 | 35 | --border: 214.3 31.8% 91.4%; 36 | --input: 214.3 31.8% 91.4%; 37 | --ring: 222.2 84% 4.9%; 38 | 39 | --radius: 0.5rem; 40 | } 41 | 42 | .dark { 43 | --background: 222.2 84% 4.9%; 44 | --foreground: 210 40% 98%; 45 | 46 | --card: 222.2 84% 4.9%; 47 | --card-foreground: 210 40% 98%; 48 | 49 | --popover: 222.2 84% 4.9%; 50 | --popover-foreground: 210 40% 98%; 51 | 52 | --primary: 210 40% 98%; 53 | --primary-foreground: 222.2 47.4% 11.2%; 54 | 55 | --secondary: 217.2 32.6% 17.5%; 56 | --secondary-foreground: 210 40% 98%; 57 | 58 | --muted: 217.2 32.6% 17.5%; 59 | --muted-foreground: 215 20.2% 65.1%; 60 | 61 | --accent: 217.2 32.6% 17.5%; 62 | --accent-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 62.8% 30.6%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --border: 217.2 32.6% 17.5%; 68 | --input: 217.2 32.6% 17.5%; 69 | --ring: 212.7 26.8% 83.9%; 70 | } 71 | } 72 | 73 | @layer base { 74 | * { 75 | @apply border-border; 76 | } 77 | body { 78 | @apply bg-background text-foreground; 79 | } 80 | } -------------------------------------------------------------------------------- /components/form/resend-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CardWrapper } from "@/components/auth/card-wrapper"; 4 | import { resendSchema } from "@/schemas"; 5 | import { useForm } from "react-hook-form"; 6 | import { z } from "zod"; 7 | import { Form } from "@/components/ui/form"; 8 | import { FormInput } from "@/components/auth/form-input"; 9 | import { useTransition } from "react"; 10 | import { resendToken } from "@/actions/resend"; 11 | import { toast } from "sonner"; 12 | import { Button } from "@/components/ui/button"; 13 | import { zodResolver } from "@hookform/resolvers/zod"; 14 | 15 | export const ResendForm = () => { 16 | const [isPending, startTransition] = useTransition(); 17 | const form = useForm>({ 18 | resolver: zodResolver(resendSchema), 19 | defaultValues: { 20 | email: "" 21 | } 22 | }) 23 | 24 | const handleSubmit = form.handleSubmit(values => { 25 | startTransition(() => { 26 | resendToken(values).then((data) => { 27 | if (data.success) { 28 | return toast.success(data.message); 29 | } 30 | return toast.error(data.error.message); 31 | }); 32 | }) 33 | }); 34 | return ( 35 | 41 |
42 | 43 | 51 | 52 | 53 | 54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-dashboard", 3 | "version": "0.1.0", 4 | "private": true, 5 | "author": { 6 | "name": "Zulmy Azhary", 7 | "email": "zulmyazhary32@gmail.com", 8 | "url": "https://zoel.vercel.app" 9 | }, 10 | "scripts": { 11 | "dev": "next dev", 12 | "build": "next build", 13 | "start": "next start", 14 | "lint": "next lint", 15 | "prisma:update": "npx prisma generate && npx prisma db push", 16 | "prisma:clean": "npx prisma generate && npx prisma migrate reset && npx prisma db push", 17 | "postinstall": "prisma generate" 18 | }, 19 | "dependencies": { 20 | "@auth/prisma-adapter": "^1.0.14", 21 | "@hookform/resolvers": "^3.3.4", 22 | "@prisma/client": "^5.7.1", 23 | "@radix-ui/react-avatar": "^1.0.4", 24 | "@radix-ui/react-dropdown-menu": "^2.0.6", 25 | "@radix-ui/react-label": "^2.0.2", 26 | "@radix-ui/react-separator": "^1.0.3", 27 | "@radix-ui/react-slot": "^1.0.2", 28 | "@radix-ui/react-switch": "^1.0.3", 29 | "bcryptjs": "^2.4.3", 30 | "class-variance-authority": "^0.7.0", 31 | "clsx": "^2.0.0", 32 | "jsonwebtoken": "^9.0.2", 33 | "lucide-react": "^0.303.0", 34 | "next": "14.0.4", 35 | "next-auth": "^5.0.0-beta.4", 36 | "next-themes": "^0.2.1", 37 | "react": "^18", 38 | "react-dom": "^18", 39 | "react-hook-form": "^7.49.2", 40 | "react-icons": "^4.12.0", 41 | "resend": "^2.1.0", 42 | "sonner": "^1.3.1", 43 | "tailwind-merge": "^2.2.0", 44 | "tailwindcss-animate": "^1.0.7", 45 | "uuid": "^9.0.1", 46 | "zod": "^3.22.4" 47 | }, 48 | "devDependencies": { 49 | "@types/bcryptjs": "^2.4.6", 50 | "@types/jsonwebtoken": "^9.0.5", 51 | "@types/node": "^20", 52 | "@types/react": "^18", 53 | "@types/react-dom": "^18", 54 | "@types/uuid": "^9.0.7", 55 | "autoprefixer": "^10.0.1", 56 | "eslint": "^8", 57 | "eslint-config-next": "14.0.4", 58 | "postcss": "^8", 59 | "prisma": "^5.7.1", 60 | "tailwindcss": "^3.3.0", 61 | "typescript": "^5" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /components/form/reset-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CardWrapper } from "@/components/auth/card-wrapper"; 4 | import { Form } from "@/components/ui/form"; 5 | import { FormInput } from "@/components/auth/form-input"; 6 | import { useTransition } from "react"; 7 | import { useForm } from "react-hook-form"; 8 | import { resetPasswordSchema } from "@/schemas"; 9 | import { zodResolver } from "@hookform/resolvers/zod"; 10 | import { z } from "zod"; 11 | import { Button } from "@/components/ui/button"; 12 | import { resetPassword } from "@/actions/reset-password"; 13 | import { toast } from "sonner"; 14 | import { useRouter } from "next/navigation"; 15 | 16 | export const ResetForm = () => { 17 | const router = useRouter(); 18 | const [isPending, startTransition] = useTransition(); 19 | const form = useForm>({ 20 | resolver: zodResolver(resetPasswordSchema), 21 | defaultValues: { 22 | email: "", 23 | }, 24 | }); 25 | 26 | const handleSubmit = form.handleSubmit((values) => { 27 | startTransition(() => { 28 | resetPassword(values).then((data) => { 29 | if (data.success) { 30 | router.push("/login"); 31 | return toast.success(data.message); 32 | } 33 | return toast.error(data.error.message); 34 | }); 35 | }); 36 | }); 37 | 38 | return ( 39 | 45 |
46 | 47 | 55 | 58 | 59 | 60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | import { sign, verify, type SignOptions, type Secret } from "jsonwebtoken"; 4 | import bcrypt from "bcryptjs"; 5 | import { Response, ResponseWithMessage } from "@/types"; 6 | 7 | export function cn(...inputs: ClassValue[]) { 8 | return twMerge(clsx(inputs)); 9 | } 10 | 11 | export async function hashPassword(password: string) { 12 | return await bcrypt.hash(password, await bcrypt.genSalt()); 13 | } 14 | 15 | /** 16 | * Function to check whether the given value is expired or not. 17 | * @param expires The date that want to check 18 | * @return true if the value is expired, false otherwise 19 | */ 20 | export function isExpired(expires: Date): boolean { 21 | return new Date(expires) < new Date(); 22 | } 23 | 24 | /** 25 | * Function to set token expiration. 26 | * @param exp Duration of token expiration, default is 3600 milliseconds or 1 hour 27 | * @return Generates datetime for the token expiration 28 | */ 29 | export function setTokenExpiration(exp: number = 60 * 60) { 30 | return new Date(new Date().getTime() + 1000 * exp); 31 | } 32 | 33 | /** 34 | * Function to generate jwt. 35 | * @param payload The payload want to generate 36 | * @param options The sign options 37 | * @return The token generated 38 | */ 39 | 40 | export function signJwt(payload: Record, options?: SignOptions) { 41 | return sign(payload, process.env.JWT_SECRET as Secret, { 42 | ...options, 43 | algorithm: "HS256", 44 | }); 45 | } 46 | 47 | export const verifyJwtToken = (token: string) => { 48 | try { 49 | const decoded = verify(token, process.env.JWT_SECRET as Secret); 50 | return { 51 | valid: true, 52 | decoded: decoded as T, 53 | }; 54 | } catch (error) { 55 | return { 56 | valid: false, 57 | decoded: null, 58 | }; 59 | } 60 | }; 61 | 62 | // Overload for response status in server action 63 | export function response(response: ResponseWithMessage): Response; 64 | export function response>(response: Response): Response; 65 | export function response(response: T): T { 66 | return response; 67 | } 68 | -------------------------------------------------------------------------------- /components/auth/card-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardFooter, 6 | CardHeader, 7 | CardTitle, 8 | } from "@/components/ui/card"; 9 | import { Button } from "@/components/ui/button"; 10 | import Link from "next/link"; 11 | import { Social } from "@/components/auth/social"; 12 | import { Separator } from "@/components/ui/separator"; 13 | import Image from "next/image"; 14 | 15 | type CardWrapperProps = React.HTMLAttributes & { 16 | headerTitle: string; 17 | headerDescription: string; 18 | backButtonLabel: string; 19 | backButtonHref: string; 20 | showSocial?: boolean; 21 | heroImage?: string; 22 | }; 23 | 24 | export const CardWrapper = (props: CardWrapperProps) => { 25 | const { 26 | heroImage, 27 | headerTitle, 28 | headerDescription, 29 | backButtonLabel, 30 | backButtonHref, 31 | showSocial, 32 | children, 33 | ...rest 34 | } = props; 35 | 36 | return ( 37 | 38 | {heroImage ? ( 39 |
40 | Hero Image 41 |
42 | ) : null} 43 | 44 | {headerTitle} 45 | {headerDescription} 46 | 47 | {children ? {children} : null} 48 | {showSocial ? ( 49 | <> 50 | 51 | 52 |

Or connect with

53 | 54 |
55 | 56 | 57 | 58 | 59 | ) : null} 60 | 61 | 62 | 65 | 66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("POSTGRES_PRISMA_URL") 11 | directUrl = env("POSTGRES_URL_NON_POOLING") 12 | } 13 | 14 | model Account { 15 | id String @id @default(cuid()) 16 | userId String 17 | type String 18 | provider String 19 | providerAccountId String 20 | refresh_token String? @db.Text 21 | access_token String? @db.Text 22 | expires_at Int? 23 | token_type String? 24 | scope String? 25 | id_token String? @db.Text 26 | session_state String? 27 | 28 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 29 | 30 | @@unique([provider, providerAccountId]) 31 | } 32 | 33 | enum UserRole { 34 | Admin 35 | User 36 | } 37 | 38 | model User { 39 | id String @id @default(cuid()) 40 | name String? 41 | email String? @unique 42 | emailVerified DateTime? 43 | image String? // You can use gravatar.com to get image profile 44 | password String? 45 | role UserRole @default(User) 46 | accounts Account[] 47 | isTwoFactorEnabled Boolean @default(false) 48 | twoFactorConfirmation TwoFactorConfirmation? 49 | } 50 | 51 | model VerificationToken { 52 | id String @id @default(cuid()) 53 | email String 54 | token String @unique 55 | expires DateTime 56 | 57 | @@unique([email, token]) 58 | } 59 | 60 | model ResetPasswordToken { 61 | id String @id @default(cuid()) 62 | email String 63 | token String @unique 64 | expires DateTime 65 | 66 | @@unique([email, token]) 67 | } 68 | 69 | model TwoFactorToken { 70 | id String @id @default(cuid()) 71 | email String 72 | token String @unique 73 | expires DateTime 74 | 75 | @@unique([email, token]) 76 | } 77 | 78 | model TwoFactorConfirmation { 79 | id String @id @default(cuid()) 80 | userId String @unique 81 | expires DateTime 82 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 83 | } 84 | -------------------------------------------------------------------------------- /actions/new-password.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { newPasswordSchema } from "@/schemas"; 4 | import { 5 | deleteResetPasswordTokenById, 6 | getResetPasswordToken, 7 | } from "@/services/reset-password-token"; 8 | import { getUserByEmail, updateUserById } from "@/services/user"; 9 | import { redirect } from "next/navigation"; 10 | import { z } from "zod"; 11 | import { hashPassword, isExpired, response } from "@/lib/utils"; 12 | 13 | export const newPassword = async (payload: z.infer, token: string) => { 14 | // Check if user input is not valid, then return an error. 15 | const validatedFields = newPasswordSchema.safeParse(payload); 16 | if (!validatedFields.success) { 17 | return response({ 18 | success: false, 19 | error: { 20 | code: 422, 21 | message: "Invalid fields.", 22 | }, 23 | }); 24 | } 25 | 26 | const { password } = validatedFields.data; 27 | 28 | // Check if token doesn't exist, then redirect to login page. 29 | const existingToken = await getResetPasswordToken(token); 30 | if (!existingToken) redirect("/"); 31 | 32 | // Check if token has expired, then return an error. 33 | const hasExpired = isExpired(existingToken.expires); 34 | if (hasExpired) { 35 | return response({ 36 | success: false, 37 | error: { 38 | code: 401, 39 | message: "Token has expired. Please resend to your email.", 40 | }, 41 | }); 42 | } 43 | 44 | // Check if email address doesn't exist, then return an error. 45 | const existingUser = await getUserByEmail(existingToken.email); 46 | if (!existingUser || !existingUser.email || !existingUser.password) { 47 | return response({ 48 | success: false, 49 | error: { 50 | code: 401, 51 | message: "Email address does not exist.", 52 | }, 53 | }); 54 | } 55 | 56 | // Create new password by hashing the password first. 57 | const hashedPassword = await hashPassword(password); 58 | 59 | // Replace the old password with the new one. 60 | await updateUserById(existingUser.id, { 61 | password: hashedPassword, 62 | }); 63 | // Delete reset password token. 64 | await deleteResetPasswordTokenById(existingToken.id); 65 | 66 | // Then return response success. 67 | return response({ 68 | success: true, 69 | code: 200, 70 | message: "Your password has been reset successfully.", 71 | }); 72 | }; 73 | -------------------------------------------------------------------------------- /components/form/new-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CardWrapper } from "@/components/auth/card-wrapper"; 4 | import { Form } from "@/components/ui/form"; 5 | import { FormInput } from "@/components/auth/form-input"; 6 | import { useTransition } from "react"; 7 | import { useForm } from "react-hook-form"; 8 | import { newPasswordSchema } from "@/schemas"; 9 | import { zodResolver } from "@hookform/resolvers/zod"; 10 | import { z } from "zod"; 11 | import { Button } from "@/components/ui/button"; 12 | import { useRouter } from "next/navigation"; 13 | import { newPassword } from "@/actions/new-password"; 14 | import { toast } from "sonner"; 15 | 16 | type NewPasswordFormProps = { 17 | token: string; 18 | }; 19 | 20 | export const NewPasswordForm = ({ token }: NewPasswordFormProps) => { 21 | const router = useRouter(); 22 | const [isPending, startTransition] = useTransition(); 23 | const form = useForm>({ 24 | resolver: zodResolver(newPasswordSchema), 25 | defaultValues: { 26 | password: "", 27 | confirmPassword: "", 28 | }, 29 | }); 30 | 31 | const handleSubmit = form.handleSubmit((values) => { 32 | startTransition(() => { 33 | newPassword(values, token).then((data) => { 34 | if (data.success) { 35 | router.push("/login"); 36 | return toast.success(data.message); 37 | } 38 | return toast.error(data.error.message); 39 | }); 40 | }); 41 | }); 42 | 43 | return ( 44 | 50 |
51 | 52 | 60 | 68 | 71 | 72 | 73 |
74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{ts,tsx}", 7 | "./components/**/*.{ts,tsx}", 8 | "./app/**/*.{ts,tsx}", 9 | "./src/**/*.{ts,tsx}", 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | fontSize: { 76 | "10xl": ["12rem", { lineHeight: "1" }], 77 | }, 78 | }, 79 | }, 80 | plugins: [require("tailwindcss-animate")], 81 | } satisfies Config; 82 | 83 | export default config -------------------------------------------------------------------------------- /schemas/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const EMAIL_SCHEMA = z 4 | .string() 5 | .min(1, "Email Address is required.") 6 | .email("Invalid Email Address."); 7 | 8 | export const loginSchema = z.object({ 9 | email: EMAIL_SCHEMA, 10 | password: z.string().min(1, "Password is required."), 11 | }); 12 | 13 | export const registerSchema = z.object({ 14 | email: EMAIL_SCHEMA, 15 | name: z 16 | .string() 17 | .min(1, { 18 | message: "Name is required.", 19 | }) 20 | .min(4, "Name must be at least 4 characters.") 21 | .max(24, "Maximum length of Name is 24 characters."), 22 | password: z 23 | .string() 24 | .min(1, "Password is required.") 25 | .min(6, "Password must be at least 6 characters."), 26 | }); 27 | 28 | export const resendSchema = z.object({ 29 | email: EMAIL_SCHEMA, 30 | }); 31 | 32 | export const resetPasswordSchema = z.object({ 33 | email: EMAIL_SCHEMA, 34 | }); 35 | 36 | export const newPasswordSchema = z 37 | .object({ 38 | password: z 39 | .string() 40 | .min(1, "Password is required.") 41 | .min(6, "Password must be at least 6 characters."), 42 | confirmPassword: z.string().min(1, "Confirm Password is required."), 43 | }) 44 | .refine((data) => data.password === data.confirmPassword, { 45 | message: "Password doesn't match.", 46 | path: ["confirmPassword"], 47 | }); 48 | 49 | export const twoFactorSchema = z.object({ 50 | code: z 51 | .string() 52 | .regex(/^[0-9]+$/, "Code must be a number.") 53 | .length(6, "Code must be 6 digits long."), 54 | }); 55 | 56 | export const profileSchema = z 57 | .object({ 58 | name: z.optional( 59 | z 60 | .string() 61 | .min(1, { 62 | message: "Name is required.", 63 | }) 64 | .min(4, "Name must be at least 4 characters.") 65 | .max(24, "Maximum length of Name is 24 characters.") 66 | ), 67 | email: z.optional(z.string().email()), 68 | password: z.optional(z.string().min(6, "Password must be at least 6 characters.")), 69 | newPassword: z.optional(z.string().min(6, "New Password must be at least 6 characters.")), 70 | isTwoFactorEnabled: z.optional(z.boolean()), 71 | }) 72 | .refine( 73 | (data) => { 74 | if (!data.password && data.newPassword) return false; 75 | return true; 76 | }, 77 | { 78 | message: "Password is required.", 79 | path: ["password"], 80 | } 81 | ) 82 | .refine( 83 | (data) => { 84 | if (data.password && !data.newPassword) return false; 85 | return true; 86 | }, 87 | { 88 | message: "New Password is required.", 89 | path: ["newPassword"], 90 | } 91 | ); 92 | -------------------------------------------------------------------------------- /components/form/two-factor-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CardWrapper } from "@/components/auth/card-wrapper"; 4 | import { loginSchema, twoFactorSchema } from "@/schemas"; 5 | import { useForm } from "react-hook-form"; 6 | import { z } from "zod"; 7 | import { Form } from "@/components/ui/form"; 8 | import { FormInput } from "@/components/auth/form-input"; 9 | import { Button } from "@/components/ui/button"; 10 | import { useTransition } from "react"; 11 | import { zodResolver } from "@hookform/resolvers/zod"; 12 | import { resendTwoFactor, twoFactor } from "@/actions/two-factor"; 13 | import { toast } from "sonner"; 14 | 15 | type TwoFactorFormProps = { 16 | payload: z.infer; 17 | }; 18 | 19 | export const TwoFactorForm = ({ payload }: TwoFactorFormProps) => { 20 | const [isPending, startTransition] = useTransition(); 21 | const form = useForm>({ 22 | resolver: zodResolver(twoFactorSchema), 23 | defaultValues: { 24 | code: "", 25 | }, 26 | }); 27 | 28 | const handleSubmit = form.handleSubmit((values) => { 29 | startTransition(() => { 30 | twoFactor(values, payload).then((data) => { 31 | if (!data) return; 32 | if (!data.success) { 33 | return toast.error(data.error.message); 34 | } 35 | }); 36 | }); 37 | }); 38 | 39 | const handleResend = () => { 40 | startTransition(() => { 41 | resendTwoFactor(payload.email).then((data) => { 42 | if (data.success) { 43 | return toast.success(data.message); 44 | } 45 | return toast.error(data.error.message); 46 | }); 47 | }); 48 | }; 49 | 50 | return ( 51 | 57 |
58 | 59 | 67 | 70 | 71 | 80 | 81 |
82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /components/form/register-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CardWrapper } from "@/components/auth/card-wrapper"; 4 | import { Form } from "@/components/ui/form"; 5 | import { registerSchema } from "@/schemas"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { useForm } from "react-hook-form"; 8 | import { z } from "zod"; 9 | import { FormInput } from "@/components/auth/form-input"; 10 | import { Button } from "@/components/ui/button"; 11 | import { useTransition } from "react"; 12 | import { register } from "@/actions/register"; 13 | import { toast } from "sonner"; 14 | import { useRouter } from "next/navigation"; 15 | 16 | export const RegisterForm = () => { 17 | const router = useRouter(); 18 | const [isPending, startTransition] = useTransition(); 19 | const form = useForm>({ 20 | resolver: zodResolver(registerSchema), 21 | defaultValues: { 22 | name: "", 23 | email: "", 24 | password: "", 25 | }, 26 | }); 27 | 28 | const handleSubmit = form.handleSubmit((values) => { 29 | startTransition(() => { 30 | register(values).then((data) => { 31 | if (data.success) { 32 | router.push("/login"); 33 | return toast.success(data.message); 34 | } 35 | return toast.error(data.error.message); 36 | }); 37 | }); 38 | }); 39 | 40 | return ( 41 | 47 |
48 | 49 |
50 | 58 | 66 | 74 |
75 | 78 |
79 | 80 |
81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /auth/index.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { authConfig } from "@/auth/config"; 3 | import { PrismaAdapter } from "@auth/prisma-adapter"; 4 | import { db } from "@/lib/db"; 5 | import { getUserById, updateUserById } from "@/services/user"; 6 | import { getTwoFactorConfirmationByUserId } from "@/services/two-factor-confirmation"; 7 | import { isExpired } from "@/lib/utils"; 8 | import { getAccountByUserId } from "@/services/account"; 9 | 10 | export const { 11 | handlers: { GET, POST }, 12 | auth, 13 | signIn, 14 | signOut, 15 | update 16 | } = NextAuth({ 17 | adapter: PrismaAdapter(db), 18 | session: { 19 | strategy: "jwt", 20 | maxAge: 60 * 60 * 24, // 1 Day 21 | }, 22 | pages: { 23 | signIn: "/login", 24 | error: "/error", 25 | }, 26 | events: { 27 | async linkAccount({ user }) { 28 | await updateUserById(user.id, { emailVerified: new Date() }); 29 | }, 30 | }, 31 | callbacks: { 32 | async jwt({ token }) { 33 | if (!token.sub) return token; 34 | 35 | const existingUser = await getUserById(token.sub); 36 | if (!existingUser) return token; 37 | 38 | const existingAccount = await getAccountByUserId(existingUser.id); 39 | 40 | token.name = existingUser.name; 41 | token.email = existingUser.email; 42 | token.role = existingUser.role; 43 | token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled; 44 | token.isOAuth = !!existingAccount; 45 | 46 | return token; 47 | }, 48 | async session({ token, session }) { 49 | if (token.sub && session.user) { 50 | session.user.id = token.sub; 51 | } 52 | 53 | if (token.role && session.user) { 54 | session.user.role = token.role; 55 | } 56 | 57 | if (session.user) { 58 | session.user.name = token.name; 59 | session.user.email = token.email; 60 | session.user.isTwoFactorEnabled = token.isTwoFactorEnabled; 61 | session.user.isOAuth = token.isOAuth; 62 | } 63 | 64 | return session; 65 | }, 66 | async signIn({ user, account }) { 67 | if (account?.provider !== "credentials") return true; 68 | 69 | const existingUser = await getUserById(user.id); 70 | // Prevent sign in without email verification 71 | if (!existingUser?.emailVerified) return false; 72 | 73 | // If user's 2FA checked 74 | if (existingUser.isTwoFactorEnabled) { 75 | const existingTwoFactorConfirmation = await getTwoFactorConfirmationByUserId( 76 | existingUser.id 77 | ); 78 | // If two factor confirmation doesn't exist, then prevent to login 79 | if (!existingTwoFactorConfirmation) return false; 80 | // If two factor confirmation is expired, then prevent to login 81 | const hasExpired = isExpired(existingTwoFactorConfirmation.expires); 82 | if (hasExpired) return false; 83 | } 84 | 85 | return true; 86 | }, 87 | }, 88 | ...authConfig, 89 | }); 90 | -------------------------------------------------------------------------------- /components/form/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CardWrapper } from "@/components/auth/card-wrapper"; 4 | import { useForm } from "react-hook-form"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import { Form } from "@/components/ui/form"; 7 | import { z } from "zod"; 8 | import { loginSchema } from "@/schemas"; 9 | import { Button } from "@/components/ui/button"; 10 | import { useTransition } from "react"; 11 | import { login } from "@/actions/login"; 12 | import { FormInput } from "@/components/auth/form-input"; 13 | import { toast } from "sonner"; 14 | import Link from "next/link"; 15 | import { useRouter } from "next/navigation"; 16 | 17 | export const LoginForm = () => { 18 | const router = useRouter(); 19 | const [isPending, startTransition] = useTransition(); 20 | const form = useForm>({ 21 | resolver: zodResolver(loginSchema), 22 | mode: "onChange", 23 | defaultValues: { 24 | email: "", 25 | password: "", 26 | }, 27 | }); 28 | 29 | const handleSubmit = form.handleSubmit((values) => { 30 | startTransition(() => { 31 | login(values) 32 | .then((data) => { 33 | if (!data) return; 34 | if (!data.success) { 35 | return toast.error(data.error.message); 36 | } 37 | return router.push("/two-factor"); 38 | }) 39 | .catch(() => toast.error("Something went wrong.")); 40 | }); 41 | }); 42 | 43 | return ( 44 | 51 |
52 | 53 |
54 | 62 |
63 | 71 | 79 |
80 |
81 | 84 |
85 | 86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /app/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuGroup, 6 | DropdownMenuItem, 7 | DropdownMenuLabel, 8 | DropdownMenuSeparator, 9 | DropdownMenuShortcut, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 13 | import { UserRound } from "lucide-react"; 14 | import { signOut } from "@/auth"; 15 | import Link from "next/link"; 16 | import { currentUser } from "@/lib/auth"; 17 | 18 | async function AuthNav() { 19 | const user = await currentUser(); 20 | 21 | if (!user) return; 22 | 23 | return ( 24 | 25 | 26 | 35 | 36 | 37 | My Account 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 |

{user.name}

49 |

{user.email}

50 |

{user.role}

51 |
52 |
53 |
54 | 55 | 56 | 57 | 58 | Profile 59 | ⇧⌘P 60 | 61 | 62 | 63 | 64 | Settings 65 | ⌘S 66 | 67 | 68 | 69 | 70 |
{ 72 | "use server"; 73 | await signOut(); 74 | }} 75 | > 76 | 77 | 81 | 82 |
83 |
84 |
85 | ); 86 | } 87 | 88 | export default function Navbar() { 89 | return ( 90 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /actions/two-factor.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { loginSchema, twoFactorSchema } from "@/schemas"; 4 | import { z } from "zod"; 5 | import { getUserByEmail } from "@/services/user"; 6 | import { 7 | deleteTwoFactorTokenById, 8 | generateTwoFactorToken, 9 | getTwoFactorTokenByEmail, 10 | } from "@/services/two-factor-token"; 11 | import { isExpired, response } from "@/lib/utils"; 12 | import { generateTwoFactorConfirmation } from "@/services/two-factor-confirmation"; 13 | import { signInCredentials } from "@/actions/login"; 14 | import { cookies } from "next/headers"; 15 | import { sendTwoFactorEmail } from "@/services/mail"; 16 | 17 | export const twoFactor = async ( 18 | payload: z.infer, 19 | credentials: z.infer 20 | ) => { 21 | // Check if user input is not valid. 22 | const validatedFields = twoFactorSchema.safeParse(payload); 23 | if (!validatedFields.success) { 24 | return response({ 25 | success: false, 26 | error: { 27 | code: 422, 28 | message: "Invalid fields.", 29 | }, 30 | }); 31 | } 32 | 33 | const { code } = validatedFields.data; 34 | 35 | // Check if email address doesn't exist, then return an error. 36 | const existingUser = await getUserByEmail(credentials.email); 37 | if (!existingUser || !existingUser.email || !existingUser.password) { 38 | return response({ 39 | success: false, 40 | error: { 41 | code: 401, 42 | message: "Email address does not exist.", 43 | }, 44 | }); 45 | } 46 | 47 | // Check if token invalid or doesn't exist, then return an error. 48 | const twoFactorToken = await getTwoFactorTokenByEmail(credentials.email); 49 | if (!twoFactorToken || twoFactorToken.token !== code) { 50 | return response({ 51 | success: false, 52 | error: { 53 | code: 422, 54 | message: "Invalid code.", 55 | }, 56 | }); 57 | } 58 | 59 | // Check if token has expired. then return an error. 60 | const hasExpired = isExpired(twoFactorToken.expires); 61 | if (hasExpired) { 62 | return response({ 63 | success: false, 64 | error: { 65 | code: 401, 66 | message: "Code has been expired. Please resend the 2FA code to your email.", 67 | }, 68 | }); 69 | } 70 | 71 | // Delete two factor token, and generate two factor confirmation 72 | await deleteTwoFactorTokenById(twoFactorToken.id); 73 | await generateTwoFactorConfirmation(existingUser.id); 74 | 75 | // Delete credentials-session's payload from login page. 76 | const cookieStore = cookies(); 77 | cookieStore.delete("credentials-session"); 78 | 79 | // Then try to sign in with next-auth credentials. 80 | return await signInCredentials(credentials.email, credentials.password); 81 | }; 82 | 83 | // Resend Two Factor Authentication 84 | export const resendTwoFactor = async (email: string) => { 85 | // Check if email doesn't exist to generate token, then return an error. 86 | const twoFactorToken = await generateTwoFactorToken(email); 87 | if (!twoFactorToken) { 88 | return response({ 89 | success: false, 90 | error: { 91 | code: 422, 92 | message: "Failed to resend two factor authentication.", 93 | }, 94 | }); 95 | } 96 | 97 | // Send two factor authentication code to the email. 98 | await sendTwoFactorEmail(twoFactorToken.email, twoFactorToken.token); 99 | return response({ 100 | success: true, 101 | code: 201, 102 | message: "Two factor authentication code has been sent to your email.", 103 | }); 104 | }; 105 | -------------------------------------------------------------------------------- /actions/profile.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { profileSchema } from "@/schemas"; 4 | import { z } from "zod"; 5 | import { currentUser } from "@/lib/auth"; 6 | import { hashPassword, response } from "@/lib/utils"; 7 | import { getUserByEmail, getUserById, updateUserById } from "@/services/user"; 8 | import { update } from "@/auth"; 9 | import { deleteTwoFactorConfirmationByUserId } from "@/services/two-factor-confirmation"; 10 | import bcrypt from "bcryptjs"; 11 | import { generateVerificationToken } from "@/services/verification-token"; 12 | import { sendVerificationEmail } from "@/services/mail"; 13 | 14 | export const profile = async (payload: z.infer) => { 15 | // Check if user input is not valid, then return an error. 16 | const validatedFields = profileSchema.safeParse(payload); 17 | if (!validatedFields.success) { 18 | return response({ 19 | success: false, 20 | error: { 21 | code: 422, 22 | message: "Invalid fields.", 23 | }, 24 | }); 25 | } 26 | 27 | let { name, email, password, newPassword, isTwoFactorEnabled } = validatedFields.data; 28 | 29 | // Check if current user does not exist, then return an error. 30 | const user = await currentUser(); 31 | if (!user) { 32 | return response({ 33 | success: false, 34 | error: { 35 | code: 401, 36 | message: "Unauthorized.", 37 | }, 38 | }); 39 | } 40 | 41 | // Check if user does not exist in the database, then return an error. 42 | const existingUser = await getUserById(user.id); 43 | if (!existingUser) { 44 | return response({ 45 | success: false, 46 | error: { 47 | code: 401, 48 | message: "Unauthorized.", 49 | }, 50 | }); 51 | } 52 | 53 | // Check if current user logged in with OAuth provider (Google or Github), then prevent to update few fields. 54 | if (user.isOAuth) { 55 | email = undefined; 56 | password = undefined; 57 | newPassword = undefined; 58 | isTwoFactorEnabled = undefined; 59 | } 60 | 61 | // Check if user trying to update the email address 62 | if (email && email !== user.email) { 63 | // Check if email already in use from another user and make sure that email doesn't same as current user. 64 | const existingEmail = await getUserByEmail(email); 65 | if (existingEmail && user.id !== existingEmail.id) { 66 | return response({ 67 | success: false, 68 | error: { 69 | code: 422, 70 | message: "The email address you have entered is already in use. Please use another one.", 71 | }, 72 | }); 73 | } 74 | 75 | // Generate verification token, then send it to the email. 76 | const verificationToken = await generateVerificationToken(email); 77 | await sendVerificationEmail(verificationToken.email, verificationToken.token); 78 | 79 | // Return response success. 80 | return response({ 81 | success: true, 82 | code: 201, 83 | message: "Confirmation email sent. Please check your email.", 84 | }); 85 | } 86 | 87 | // Check if password not entered, then don't update the password. 88 | if (!password || !newPassword) { 89 | password = undefined; 90 | } 91 | 92 | // Check if password entered 93 | if (password && newPassword && existingUser.password) { 94 | // Check if passwords doesn't matches, then return an error. 95 | const isPasswordMatch = await bcrypt.compare(password, existingUser.password); 96 | if (!isPasswordMatch) { 97 | return response({ 98 | success: false, 99 | error: { 100 | code: 401, 101 | message: "Incorrect password.", 102 | }, 103 | }); 104 | } 105 | 106 | const hashedPassword = await hashPassword(newPassword); 107 | password = hashedPassword; 108 | } 109 | 110 | // Check if user disabled 2fa, then delete two factor confirmation 111 | if (!isTwoFactorEnabled) { 112 | await deleteTwoFactorConfirmationByUserId(existingUser.id); 113 | } 114 | 115 | // Update current user 116 | const updatedUser = await updateUserById(existingUser.id, { 117 | name, 118 | email, 119 | password, 120 | isTwoFactorEnabled, 121 | }); 122 | 123 | // Update session 124 | await update({ user: { ...updatedUser } }); 125 | 126 | // Return response success. 127 | return response({ 128 | success: true, 129 | code: 204, 130 | message: "Profile updated.", 131 | }); 132 | }; 133 | -------------------------------------------------------------------------------- /components/form/profile-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { profileSchema } from "@/schemas"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import { useTransition } from "react"; 6 | import { useForm } from "react-hook-form"; 7 | import { z } from "zod"; 8 | import { Form } from "@/components/ui/form"; 9 | import { FormInput } from "@/components/auth/form-input"; 10 | import { Button } from "@/components/ui/button"; 11 | import { profile } from "@/actions/profile"; 12 | import { toast } from "sonner"; 13 | import { ExtendedUser } from "@/types/next-auth"; 14 | import { FormToggle } from "@/components/auth/form-toggle"; 15 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 16 | import { UserRound } from "lucide-react"; 17 | 18 | type ProfileFormProps = { 19 | user: ExtendedUser; 20 | }; 21 | 22 | export const ProfileForm = ({ user }: ProfileFormProps) => { 23 | const [isPending, startTransition] = useTransition(); 24 | const form = useForm>({ 25 | resolver: zodResolver(profileSchema), 26 | mode: "onChange", 27 | values: { 28 | name: user.name || undefined, 29 | email: user.email || undefined, 30 | password: undefined, 31 | newPassword: undefined, 32 | isTwoFactorEnabled: user.isTwoFactorEnabled || undefined, 33 | }, 34 | }); 35 | 36 | const handleSubmit = form.handleSubmit((values) => { 37 | startTransition(() => { 38 | profile(values).then((data) => { 39 | if (data.success) { 40 | form.reset(); 41 | return toast.success(data.message); 42 | } 43 | return toast.error(data.error.message); 44 | }); 45 | }); 46 | }); 47 | 48 | return ( 49 | <> 50 |
51 | 52 | 53 | 54 | 55 | 56 | 57 |
58 |
59 |
60 | 61 |
62 | 70 | {!user.isOAuth && ( 71 | <> 72 | 81 | 90 | 99 | 106 | 107 | )} 108 |
109 | 112 |
113 | 114 |
115 | 116 | ); 117 | }; 118 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |