├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── actions ├── admin.ts ├── login.ts ├── logout.ts ├── new-password.ts ├── new-verification.ts ├── register.ts ├── reset.ts └── settings.ts ├── app ├── (protected) │ ├── _components │ │ └── navbar.tsx │ ├── admin │ │ └── page.tsx │ ├── client │ │ └── page.tsx │ ├── layout.tsx │ ├── server │ │ └── page.tsx │ └── settings │ │ └── page.tsx ├── api │ ├── admin │ │ └── route.ts │ └── auth │ │ └── [...nextauth] │ │ └── route.ts ├── auth │ ├── error │ │ └── page.tsx │ ├── layout.tsx │ ├── login │ │ └── page.tsx │ ├── new-password │ │ └── page.tsx │ ├── new-verification │ │ └── page.tsx │ ├── register │ │ └── page.tsx │ └── reset │ │ └── page.tsx ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── auth.config.ts ├── auth.ts ├── components.json ├── components ├── auth │ ├── back-button.tsx │ ├── card-wrapper.tsx │ ├── error-card.tsx │ ├── header.tsx │ ├── login-button.tsx │ ├── login-form.tsx │ ├── logout-button.tsx │ ├── new-password-form.tsx │ ├── new-verification-form.tsx │ ├── register-form.tsx │ ├── reset-form.tsx │ ├── role-gate.tsx │ ├── social.tsx │ └── user-button.tsx ├── form-error.tsx ├── form-success.tsx ├── ui │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ ├── sonner.tsx │ └── switch.tsx └── user-info.tsx ├── data ├── account.ts ├── password-reset-token.ts ├── two-factor-confirmation.ts ├── two-factor-token.ts ├── user.ts └── verificiation-token.ts ├── hooks ├── use-current-role.ts └── use-current-user.ts ├── lib ├── auth.ts ├── db.ts ├── mail.ts ├── tokens.ts └── utils.ts ├── license.md ├── middleware.ts ├── next-auth.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── login.jpg ├── next.svg └── vercel.svg ├── routes.ts ├── schemas └── index.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | DIRECT_URL= 3 | 4 | AUTH_SECRET= 5 | 6 | GITHUB_CLIENT_ID= 7 | GITHUB_CLIENT_SECRET= 8 | 9 | GOOGLE_CLIENT_ID= 10 | GOOGLE_CLIENT_SECRET= 11 | 12 | RESEND_API_KEY= 13 | 14 | NEXT_PUBLIC_APP_URL="http://localhost:3000" 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | .qodo 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auth V5 2 | 3 | > Next Auth V5 Advanced guide 4 | 5 | ## Built With 6 | 7 | - Major languages: TypeScript 8 | - Framework: Next.js 9 | - Libraries: Prisma, Auth.js, React 10 | 11 | ## Screenshots 12 | 13 | ![screenshot](./public/login.jpg) 14 | 15 | ## Getting Started 16 | 17 | - cloning the repository by running `git clone [https://](https://github.com/Hombre2014/nextjs-14-auth-v5-tutorial.git)` 18 | - cd into the project directory `cd nextjs-14-auth-v5-tutorial` 19 | - Install the dependencies by running `npm install` or `yarn install` 20 | - Run the development server by running `npm run dev` or `yarn dev` 21 | - Open your browser and navigate to `http://localhost:3000` 22 | 23 | ## Environment Variables 24 | 25 | You will need to create a `.env` file in the root of the project and add the following environment variables: 26 | 27 | DATABASE_URL=
28 | DIRECT_URL=
29 | AUTH_SECRET=
30 | GITHUB_CLIENT_ID=
31 | GITHUB_CLIENT_SECRET=
32 | GOOGLE_CLIENT_ID=
33 | GOOGLE_CLIENT_SECRET=
34 | RESEND_API_KEY=
35 | NEXT_PUBLIC_APP_URL="http://localhost:3000" or your production URL 36 | 37 | ## Live Demo 38 | 39 | [Auth v5 demo](https://nextjs-14-auth-v5-tutorial.vercel.app/) 40 | 41 | ## Deployment 42 | 43 | - Using [Vercel](https://vercel.com) 44 | 45 | ## Author 46 | 47 | 👤 **Yuriy Chamkoriyski** 48 | 49 | - GitHub: [@Hombre2014](https://github.com/Hombre2014) 50 | - Twitter: [@Chamkoriyski](https://twitter.com/Chamkoriyski) 51 | - LinkedIn: [axebit](https://linkedin.com/in/axebit) 52 | 53 | ## 🤝 Contributing 54 | 55 | Contributions, issues, and feature requests are welcome! 56 | 57 | Feel free to check the [issues page](https://github.com/Hombre/auth-v5/issues). 58 | 59 | ## Show your support 60 | 61 | Give a ⭐️ if you like this project! 62 | 63 | ## Acknowledgments 64 | 65 | Thanks to "Coding with Antonio" YouTube channel and [AntonioErdeljac](https://github.com/AntonioErdeljac) for his inspirational [tutorial](https://www.youtube.com/watch?v=1MTyCvS05V4&ab_channel=CodeWithAntonio). 66 | 67 | ## 📝 License 68 | 69 | This project is [MIT](./license.md) licensed. 70 | -------------------------------------------------------------------------------- /actions/admin.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { currentRole } from "@/lib/auth"; 4 | import { UserRole } from "@prisma/client"; 5 | 6 | export const admin = async () => { 7 | const role = await currentRole(); 8 | 9 | if (role === UserRole.ADMIN) { 10 | return { success: "Allowed Server Action!" }; 11 | } 12 | 13 | return { error: "Forbidden Server Action!" } 14 | }; 15 | -------------------------------------------------------------------------------- /actions/login.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | import { AuthError } from "next-auth"; 5 | 6 | import { db } from "@/lib/db"; 7 | import { signIn } from "@/auth"; 8 | import { LoginSchema } from "@/schemas"; 9 | import { getUserByEmail } from "@/data/user"; 10 | import { getTwoFactorTokenByEmail } from "@/data/two-factor-token"; 11 | import { 12 | sendVerificationEmail, 13 | sendTwoFactorTokenEmail, 14 | } from "@/lib/mail"; 15 | import { DEFAULT_LOGIN_REDIRECT } from "@/routes"; 16 | import { 17 | generateVerificationToken, 18 | generateTwoFactorToken 19 | } from "@/lib/tokens"; 20 | import { 21 | getTwoFactorConfirmationByUserId 22 | } from "@/data/two-factor-confirmation"; 23 | 24 | export const login = async ( 25 | values: z.infer, 26 | callbackUrl?: string | null, 27 | ) => { 28 | const validatedFields = LoginSchema.safeParse(values); 29 | 30 | if (!validatedFields.success) { 31 | return { error: "Invalid fields!" }; 32 | } 33 | 34 | const { email, password, code } = validatedFields.data; 35 | 36 | const existingUser = await getUserByEmail(email); 37 | 38 | if (!existingUser || !existingUser.email || !existingUser.password) { 39 | return { error: "Email does not exist!" } 40 | } 41 | 42 | if (!existingUser.emailVerified) { 43 | const verificationToken = await generateVerificationToken( 44 | existingUser.email, 45 | ); 46 | 47 | await sendVerificationEmail( 48 | verificationToken.email, 49 | verificationToken.token, 50 | ); 51 | 52 | return { success: "Confirmation email sent!" }; 53 | } 54 | 55 | if (existingUser.isTwoFactorEnabled && existingUser.email) { 56 | if (code) { 57 | const twoFactorToken = await getTwoFactorTokenByEmail( 58 | existingUser.email 59 | ); 60 | 61 | if (!twoFactorToken) { 62 | return { error: "Invalid code!" }; 63 | } 64 | 65 | if (twoFactorToken.token !== code) { 66 | return { error: "Invalid code!" }; 67 | } 68 | 69 | const hasExpired = new Date(twoFactorToken.expires) < new Date(); 70 | 71 | if (hasExpired) { 72 | return { error: "Code expired!" }; 73 | } 74 | 75 | await db.twoFactorToken.delete({ 76 | where: { id: twoFactorToken.id } 77 | }); 78 | 79 | const existingConfirmation = await getTwoFactorConfirmationByUserId( 80 | existingUser.id 81 | ); 82 | 83 | if (existingConfirmation) { 84 | await db.twoFactorConfirmation.delete({ 85 | where: { id: existingConfirmation.id } 86 | }); 87 | } 88 | 89 | await db.twoFactorConfirmation.create({ 90 | data: { 91 | userId: existingUser.id, 92 | } 93 | }); 94 | } else { 95 | const twoFactorToken = await generateTwoFactorToken(existingUser.email) 96 | await sendTwoFactorTokenEmail( 97 | twoFactorToken.email, 98 | twoFactorToken.token, 99 | ); 100 | 101 | return { twoFactor: true }; 102 | } 103 | } 104 | 105 | try { 106 | await signIn("credentials", { 107 | email, 108 | password, 109 | redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT, 110 | }) 111 | } catch (error) { 112 | if (error instanceof AuthError) { 113 | switch (error.type) { 114 | case "CredentialsSignin": 115 | return { error: "Invalid credentials!" } 116 | default: 117 | return { error: "Something went wrong!" } 118 | } 119 | } 120 | 121 | throw error; 122 | } 123 | }; 124 | -------------------------------------------------------------------------------- /actions/logout.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { signOut } from "@/auth"; 4 | 5 | export const logout = async () => { 6 | await signOut(); 7 | }; 8 | -------------------------------------------------------------------------------- /actions/new-password.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | import bcrypt from "bcryptjs"; 5 | 6 | import { NewPasswordSchema } from "@/schemas"; 7 | import { getPasswordResetTokenByToken } from "@/data/password-reset-token"; 8 | import { getUserByEmail } from "@/data/user"; 9 | import { db } from "@/lib/db"; 10 | 11 | export const newPassword = async ( 12 | values: z.infer , 13 | token?: string | null, 14 | ) => { 15 | if (!token) { 16 | return { error: "Missing token!" }; 17 | } 18 | 19 | const validatedFields = NewPasswordSchema.safeParse(values); 20 | 21 | if (!validatedFields.success) { 22 | return { error: "Invalid fields!" }; 23 | } 24 | 25 | const { password } = validatedFields.data; 26 | 27 | const existingToken = await getPasswordResetTokenByToken(token); 28 | 29 | if (!existingToken) { 30 | return { error: "Invalid token!" }; 31 | } 32 | 33 | const hasExpired = new Date(existingToken.expires) < new Date(); 34 | 35 | if (hasExpired) { 36 | return { error: "Token has expired!" }; 37 | } 38 | 39 | const existingUser = await getUserByEmail(existingToken.email); 40 | 41 | if (!existingUser) { 42 | return { error: "Email does not exist!" } 43 | } 44 | 45 | const hashedPassword = await bcrypt.hash(password, 10); 46 | 47 | await db.user.update({ 48 | where: { id: existingUser.id }, 49 | data: { password: hashedPassword }, 50 | }); 51 | 52 | await db.passwordResetToken.delete({ 53 | where: { id: existingToken.id } 54 | }); 55 | 56 | return { success: "Password updated!" }; 57 | }; 58 | -------------------------------------------------------------------------------- /actions/new-verification.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/lib/db"; 4 | import { getUserByEmail } from "@/data/user"; 5 | import { getVerificationTokenByToken } from "@/data/verificiation-token"; 6 | 7 | export const newVerification = async (token: string) => { 8 | const existingToken = await getVerificationTokenByToken(token); 9 | 10 | if (!existingToken) { 11 | return { error: "Token does not exist!" }; 12 | } 13 | 14 | const hasExpired = new Date(existingToken.expires) < new Date(); 15 | 16 | if (hasExpired) { 17 | return { error: "Token has expired!" }; 18 | } 19 | 20 | const existingUser = await getUserByEmail(existingToken.email); 21 | 22 | if (!existingUser) { 23 | return { error: "Email does not exist!" }; 24 | } 25 | 26 | await db.user.update({ 27 | where: { id: existingUser.id }, 28 | data: { 29 | emailVerified: new Date(), 30 | email: existingToken.email, 31 | } 32 | }); 33 | 34 | await db.verificationToken.delete({ 35 | where: { id: existingToken.id } 36 | }); 37 | 38 | return { success: "Email verified!" }; 39 | }; 40 | -------------------------------------------------------------------------------- /actions/register.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | import bcrypt from "bcryptjs"; 5 | 6 | import { db } from "@/lib/db"; 7 | import { RegisterSchema } from "@/schemas"; 8 | import { getUserByEmail } from "@/data/user"; 9 | import { sendVerificationEmail } from "@/lib/mail"; 10 | import { generateVerificationToken } from "@/lib/tokens"; 11 | 12 | export const register = async (values: z.infer) => { 13 | const validatedFields = RegisterSchema.safeParse(values); 14 | 15 | if (!validatedFields.success) { 16 | return { error: "Invalid fields!" }; 17 | } 18 | 19 | const { email, password, name } = validatedFields.data; 20 | const hashedPassword = await bcrypt.hash(password, 10); 21 | 22 | const existingUser = await getUserByEmail(email); 23 | 24 | if (existingUser) { 25 | return { error: "Email already in use!" }; 26 | } 27 | 28 | await db.user.create({ 29 | data: { 30 | name, 31 | email, 32 | password: hashedPassword, 33 | }, 34 | }); 35 | 36 | const verificationToken = await generateVerificationToken(email); 37 | await sendVerificationEmail( 38 | verificationToken.email, 39 | verificationToken.token, 40 | ); 41 | 42 | return { success: "Confirmation email sent!" }; 43 | }; 44 | -------------------------------------------------------------------------------- /actions/reset.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | 5 | import { ResetSchema } from "@/schemas"; 6 | import { getUserByEmail } from "@/data/user"; 7 | import { sendPasswordResetEmail } from "@/lib/mail"; 8 | import { generatePasswordResetToken } from "@/lib/tokens"; 9 | 10 | export const reset = async (values: z.infer) => { 11 | const validatedFields = ResetSchema.safeParse(values); 12 | 13 | if (!validatedFields.success) { 14 | return { error: "Invalid emaiL!" }; 15 | } 16 | 17 | const { email } = validatedFields.data; 18 | 19 | const existingUser = await getUserByEmail(email); 20 | 21 | if (!existingUser) { 22 | return { error: "Email not found!" }; 23 | } 24 | 25 | const passwordResetToken = await generatePasswordResetToken(email); 26 | await sendPasswordResetEmail( 27 | passwordResetToken.email, 28 | passwordResetToken.token, 29 | ); 30 | 31 | return { success: "Reset email sent!" }; 32 | } -------------------------------------------------------------------------------- /actions/settings.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import * as z from 'zod'; 4 | import bcrypt from 'bcryptjs'; 5 | 6 | import { auth } from '@/auth'; // Import the new `auth()` function 7 | import { db } from '@/lib/db'; 8 | import { SettingsSchema } from '@/schemas'; 9 | import { getUserByEmail, getUserById } from '@/data/user'; 10 | import { currentUser } from '@/lib/auth'; 11 | import { generateVerificationToken } from '@/lib/tokens'; 12 | import { sendVerificationEmail } from '@/lib/mail'; 13 | 14 | export const settings = async (values: z.infer) => { 15 | const user = await currentUser(); 16 | 17 | if (!user) { 18 | return { error: 'Unauthorized' }; 19 | } 20 | 21 | const dbUser = await getUserById(user.id!); 22 | 23 | if (!dbUser) { 24 | return { error: 'Unauthorized' }; 25 | } 26 | 27 | if (user.isOAuth) { 28 | values.email = undefined; 29 | values.password = undefined; 30 | values.newPassword = undefined; 31 | values.isTwoFactorEnabled = undefined; 32 | } 33 | 34 | if (values.email && values.email !== user.email) { 35 | const existingUser = await getUserByEmail(values.email); 36 | 37 | if (existingUser && existingUser.id !== user.id) { 38 | return { error: 'Email already in use!' }; 39 | } 40 | 41 | const verificationToken = await generateVerificationToken(values.email); 42 | await sendVerificationEmail( 43 | verificationToken.email, 44 | verificationToken.token 45 | ); 46 | 47 | return { success: 'Verification email sent!' }; 48 | } 49 | 50 | if (values.password && values.newPassword && dbUser.password) { 51 | const passwordsMatch = await bcrypt.compare( 52 | values.password, 53 | dbUser.password 54 | ); 55 | 56 | if (!passwordsMatch) { 57 | return { error: 'Incorrect password!' }; 58 | } 59 | 60 | const hashedPassword = await bcrypt.hash(values.newPassword, 10); 61 | values.password = hashedPassword; 62 | values.newPassword = undefined; 63 | } 64 | 65 | const updatedUser = await db.user.update({ 66 | where: { id: dbUser.id }, 67 | data: { 68 | ...values, 69 | }, 70 | }); 71 | 72 | // Custom session update logic using `auth()` 73 | const session = await auth(); 74 | if (session && session.user) { 75 | session.user = { 76 | ...session.user, 77 | name: updatedUser.name, 78 | email: updatedUser.email, 79 | isTwoFactorEnabled: updatedUser.isTwoFactorEnabled, 80 | role: updatedUser.role, 81 | }; 82 | } 83 | 84 | return { success: 'Settings Updated!' }; 85 | }; 86 | 87 | { 88 | /* 89 | Since NextAuth no longer provides an update function, you can use the getSession and useSession methods from next-auth to manually refresh or update the session after making changes to the user data. 90 | 91 | Explanation of Changes: 92 | 93 | Custom Session Update Logic: 94 | 95 | The getSession method from next-auth/react is used to fetch the current session. 96 | The session's user object is updated with the new user data (e.g., name, email, isTwoFactorEnabled, role). 97 | Removed update Import: 98 | 99 | The update function was removed from the imports since it no longer exists in auth.ts. 100 | Preserved Existing Logic: 101 | 102 | The rest of the logic for email verification, password updates, and database updates remains unchanged. 103 | 104 | */ 105 | } 106 | -------------------------------------------------------------------------------- /app/(protected)/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { UserButton } from "@/components/auth/user-button"; 8 | 9 | export const Navbar = () => { 10 | const pathname = usePathname(); 11 | 12 | return ( 13 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /app/(protected)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { admin } from "@/actions/admin"; 4 | import { RoleGate } from "@/components/auth/role-gate"; 5 | import { FormSuccess } from "@/components/form-success"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Card, CardContent, CardHeader } from "@/components/ui/card"; 8 | import { UserRole } from "@prisma/client"; 9 | import { toast } from "sonner"; 10 | 11 | const AdminPage = () => { 12 | const onServerActionClick = () => { 13 | admin() 14 | .then((data) => { 15 | if (data.error) { 16 | toast.error(data.error); 17 | } 18 | 19 | if (data.success) { 20 | toast.success(data.success); 21 | } 22 | }) 23 | } 24 | 25 | const onApiRouteClick = () => { 26 | fetch("/api/admin") 27 | .then((response) => { 28 | if (response.ok) { 29 | toast.success("Allowed API Route!"); 30 | } else { 31 | toast.error("Forbidden API Route!"); 32 | } 33 | }) 34 | } 35 | 36 | return ( 37 | 38 | 39 |

40 | 🔑 Admin 41 |

42 |
43 | 44 | 45 | 48 | 49 |
50 |

51 | Admin-only API Route 52 |

53 | 56 |
57 | 58 |
59 |

60 | Admin-only Server Action 61 |

62 | 65 |
66 |
67 |
68 | ); 69 | }; 70 | 71 | export default AdminPage; 72 | -------------------------------------------------------------------------------- /app/(protected)/client/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { UserInfo } from '@/components/user-info'; 4 | import { useCurrentUser } from '@/hooks/use-current-user'; 5 | 6 | const ClientPage = () => { 7 | const user = useCurrentUser(); 8 | 9 | return ; 10 | }; 11 | 12 | export default ClientPage; 13 | -------------------------------------------------------------------------------- /app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "./_components/navbar"; 2 | 3 | interface ProtectedLayoutProps { 4 | children: React.ReactNode; 5 | }; 6 | 7 | const ProtectedLayout = ({ children }: ProtectedLayoutProps) => { 8 | return ( 9 |
10 | 11 | {children} 12 |
13 | ); 14 | } 15 | 16 | export default ProtectedLayout; -------------------------------------------------------------------------------- /app/(protected)/server/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from '@/lib/auth'; 2 | import { UserInfo } from '@/components/user-info'; 3 | 4 | const ServerPage = async () => { 5 | const user = await currentUser(); 6 | 7 | return ; 8 | }; 9 | 10 | export default ServerPage; 11 | -------------------------------------------------------------------------------- /app/(protected)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as z from 'zod'; 4 | import { useForm } from 'react-hook-form'; 5 | import { zodResolver } from '@hookform/resolvers/zod'; 6 | import { useTransition, useState } from 'react'; 7 | import { useSession } from 'next-auth/react'; 8 | 9 | import { Switch } from '@/components/ui/switch'; 10 | import { 11 | Select, 12 | SelectContent, 13 | SelectItem, 14 | SelectTrigger, 15 | SelectValue, 16 | } from '@/components/ui/select'; 17 | import { SettingsSchema } from '@/schemas'; 18 | import { Card, CardHeader, CardContent } from '@/components/ui/card'; 19 | import { Button } from '@/components/ui/button'; 20 | import { settings } from '@/actions/settings'; 21 | import { 22 | Form, 23 | FormField, 24 | FormControl, 25 | FormItem, 26 | FormLabel, 27 | FormDescription, 28 | FormMessage, 29 | } from '@/components/ui/form'; 30 | import { Input } from '@/components/ui/input'; 31 | import { useCurrentUser } from '@/hooks/use-current-user'; 32 | import { FormError } from '@/components/form-error'; 33 | import { FormSuccess } from '@/components/form-success'; 34 | import { UserRole } from '@prisma/client'; 35 | 36 | const SettingsPage = () => { 37 | const user = useCurrentUser(); 38 | 39 | const [error, setError] = useState(); 40 | const [success, setSuccess] = useState(); 41 | const { update } = useSession(); 42 | const [isPending, startTransition] = useTransition(); 43 | 44 | const form = useForm>({ 45 | resolver: zodResolver(SettingsSchema), 46 | defaultValues: { 47 | password: undefined, 48 | newPassword: undefined, 49 | name: user?.name || undefined, 50 | email: user?.email || undefined, 51 | role: user?.role || undefined, 52 | isTwoFactorEnabled: user?.isTwoFactorEnabled || undefined, 53 | }, 54 | }); 55 | 56 | const onSubmit = (values: z.infer) => { 57 | startTransition(() => { 58 | settings(values) 59 | .then((data) => { 60 | if (data.error) { 61 | setError(data.error); 62 | } 63 | 64 | if (data.success) { 65 | update(); 66 | setSuccess(data.success); 67 | } 68 | }) 69 | .catch(() => setError('Something went wrong!')); 70 | }); 71 | }; 72 | 73 | return ( 74 | 75 | 76 |

⚙️ Settings

77 |
78 | 79 |
80 | 81 |
82 | ( 86 | 87 | Name 88 | 89 | 94 | 95 | 96 | 97 | )} 98 | /> 99 | {user?.isOAuth === false && ( 100 | <> 101 | ( 105 | 106 | Email 107 | 108 | 114 | 115 | 116 | 117 | )} 118 | /> 119 | ( 123 | 124 | Password 125 | 126 | 132 | 133 | 134 | 135 | )} 136 | /> 137 | ( 141 | 142 | New Password 143 | 144 | 150 | 151 | 152 | 153 | )} 154 | /> 155 | 156 | )} 157 | ( 161 | 162 | Role 163 | 178 | 179 | 180 | )} 181 | /> 182 | {user?.isOAuth === false && ( 183 | ( 187 | 188 |
189 | Two Factor Authentication 190 | 191 | Enable two factor authentication for your account 192 | 193 |
194 | 195 | 200 | 201 |
202 | )} 203 | /> 204 | )} 205 |
206 | 207 | 208 | 211 | 212 | 213 |
214 |
215 | ); 216 | }; 217 | 218 | export default SettingsPage; 219 | -------------------------------------------------------------------------------- /app/api/admin/route.ts: -------------------------------------------------------------------------------- 1 | import { currentRole } from "@/lib/auth"; 2 | import { UserRole } from "@prisma/client"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET() { 6 | const role = await currentRole(); 7 | 8 | if (role === UserRole.ADMIN) { 9 | return new NextResponse(null, { status: 200 }); 10 | } 11 | 12 | return new NextResponse(null, { status: 403 }); 13 | } 14 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/auth" 2 | -------------------------------------------------------------------------------- /app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorCard } from '@/components/auth/error-card'; 2 | 3 | const AuthErrorPage = () => { 4 | return ; 5 | }; 6 | 7 | export default AuthErrorPage; 8 | -------------------------------------------------------------------------------- /app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | const AuthLayout = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
4 | {children} 5 |
6 | ); 7 | }; 8 | 9 | export default AuthLayout; 10 | -------------------------------------------------------------------------------- /app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from '@/components/auth/login-form'; 2 | 3 | const LoginPage = () => { 4 | return ; 5 | }; 6 | 7 | export default LoginPage; 8 | -------------------------------------------------------------------------------- /app/auth/new-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { NewPasswordForm } from '@/components/auth/new-password-form'; 2 | 3 | const NewPasswordPage = () => { 4 | return ; 5 | }; 6 | 7 | export default NewPasswordPage; 8 | -------------------------------------------------------------------------------- /app/auth/new-verification/page.tsx: -------------------------------------------------------------------------------- 1 | import { NewVerificationForm } from '@/components/auth/new-verification-form'; 2 | 3 | const NewVerificationPage = () => { 4 | return ; 5 | }; 6 | 7 | export default NewVerificationPage; 8 | -------------------------------------------------------------------------------- /app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { RegisterForm } from '@/components/auth/register-form'; 2 | 3 | const RegisterPage = () => { 4 | return ; 5 | }; 6 | 7 | export default RegisterPage; 8 | -------------------------------------------------------------------------------- /app/auth/reset/page.tsx: -------------------------------------------------------------------------------- 1 | import { ResetForm } from "@/components/auth/reset-form"; 2 | 3 | const ResetPage = () => { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | export default ResetPage; -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hombre2014/nextjs-14-auth-v5-tutorial/ab0d352ebc30fa5b67cbc050645fe930098ed16d/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 222.2 84% 4.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 84% 4.9%; 18 | 19 | --popover: 0 0% 100%; 20 | --popover-foreground: 222.2 84% 4.9%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --muted: 210 40% 96.1%; 29 | --muted-foreground: 215.4 16.3% 46.9%; 30 | 31 | --accent: 210 40% 96.1%; 32 | --accent-foreground: 222.2 47.4% 11.2%; 33 | 34 | --destructive: 0 84.2% 60.2%; 35 | --destructive-foreground: 210 40% 98%; 36 | 37 | --border: 214.3 31.8% 91.4%; 38 | --input: 214.3 31.8% 91.4%; 39 | --ring: 222.2 84% 4.9%; 40 | 41 | --radius: 0.5rem; 42 | } 43 | 44 | .dark { 45 | --background: 222.2 84% 4.9%; 46 | --foreground: 210 40% 98%; 47 | 48 | --card: 222.2 84% 4.9%; 49 | --card-foreground: 210 40% 98%; 50 | 51 | --popover: 222.2 84% 4.9%; 52 | --popover-foreground: 210 40% 98%; 53 | 54 | --primary: 210 40% 98%; 55 | --primary-foreground: 222.2 47.4% 11.2%; 56 | 57 | --secondary: 217.2 32.6% 17.5%; 58 | --secondary-foreground: 210 40% 98%; 59 | 60 | --muted: 217.2 32.6% 17.5%; 61 | --muted-foreground: 215 20.2% 65.1%; 62 | 63 | --accent: 217.2 32.6% 17.5%; 64 | --accent-foreground: 210 40% 98%; 65 | 66 | --destructive: 0 62.8% 30.6%; 67 | --destructive-foreground: 210 40% 98%; 68 | 69 | --border: 217.2 32.6% 17.5%; 70 | --input: 217.2 32.6% 17.5%; 71 | --ring: 212.7 26.8% 83.9%; 72 | } 73 | } 74 | 75 | @layer base { 76 | * { 77 | @apply border-border; 78 | } 79 | body { 80 | @apply bg-background text-foreground; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Inter } from 'next/font/google' 3 | import { SessionProvider } from 'next-auth/react' 4 | import { auth } from '@/auth' 5 | import './globals.css' 6 | import { Toaster } from "@/components/ui/sonner"; 7 | 8 | const inter = Inter({ subsets: ['latin'] }) 9 | 10 | export const metadata: Metadata = { 11 | title: 'Create Next App', 12 | description: 'Generated by create next app', 13 | } 14 | 15 | export default async function RootLayout({ 16 | children, 17 | }: { 18 | children: React.ReactNode 19 | }) { 20 | const session = await auth(); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | {children} 28 | 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Poppins } from "next/font/google"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { Button } from "@/components/ui/button"; 5 | import { LoginButton } from "@/components/auth/login-button"; 6 | 7 | const font = Poppins({ 8 | subsets: ["latin"], 9 | weight: ["600"] 10 | }) 11 | 12 | export default function Home() { 13 | return ( 14 |
15 |
16 |

20 | 🔐 Auth 21 |

22 |

23 | A simple authentication service 24 |

25 |
26 | 27 | 30 | 31 |
32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs'; 2 | import type { NextAuthConfig } from 'next-auth'; 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 | 7 | import { LoginSchema } from '@/schemas'; 8 | import { getUserByEmail } from '@/data/user'; 9 | 10 | export default { 11 | providers: [ 12 | Google({ 13 | clientId: process.env.GOOGLE_CLIENT_ID, 14 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 15 | }), 16 | Github({ 17 | clientId: process.env.GITHUB_CLIENT_ID, 18 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 19 | }), 20 | Credentials({ 21 | async authorize(credentials) { 22 | const validatedFields = LoginSchema.safeParse(credentials); 23 | 24 | if (validatedFields.success) { 25 | const { email, password } = validatedFields.data; 26 | 27 | const user = await getUserByEmail(email); 28 | if (!user || !user.password) return null; 29 | 30 | const passwordsMatch = await bcrypt.compare(password, user.password); 31 | 32 | if (passwordsMatch) return user; 33 | } 34 | 35 | return null; 36 | }, 37 | }), 38 | ], 39 | } satisfies NextAuthConfig; 40 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { JWT } from 'next-auth/jwt'; 3 | import { UserRole } from '@prisma/client'; 4 | import { Account, User, Session } from 'next-auth'; 5 | import { PrismaAdapter } from '@auth/prisma-adapter'; 6 | 7 | import { db } from '@/lib/db'; 8 | import authConfig from '@/auth.config'; 9 | import { getUserById } from '@/data/user'; 10 | import { getAccountByUserId } from './data/account'; 11 | import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation'; 12 | 13 | export const authOptions: any = { 14 | pages: { 15 | signIn: '/auth/login', 16 | error: '/auth/error', 17 | }, 18 | events: { 19 | async linkAccount({ user }: { user: User }) { 20 | await db.user.update({ 21 | where: { id: user.id }, 22 | data: { emailVerified: new Date() }, 23 | }); 24 | }, 25 | }, 26 | callbacks: { 27 | async signIn({ user, account }: { user: User; account: Account | null }) { 28 | // Allow OAuth without email verification 29 | if (account?.provider !== 'credentials') return true; 30 | 31 | if (!user.id) { 32 | return false; // Reject sign-in if user ID is undefined 33 | } 34 | 35 | const existingUser = await getUserById(user.id); 36 | 37 | // Prevent sign in without email verification 38 | if (!existingUser?.emailVerified) return false; 39 | 40 | if (existingUser.isTwoFactorEnabled) { 41 | const twoFactorConfirmation = await getTwoFactorConfirmationByUserId( 42 | existingUser.id 43 | ); 44 | 45 | if (!twoFactorConfirmation) return false; 46 | 47 | // Delete two-factor confirmation for next sign in 48 | await db.twoFactorConfirmation.delete({ 49 | where: { id: twoFactorConfirmation.id }, 50 | }); 51 | } 52 | 53 | return true; 54 | }, 55 | async session({ session, token }: { session: Session; token: JWT }) { 56 | if (token.sub && session.user) { 57 | session.user.id = token.sub; 58 | } 59 | 60 | if (token.role && session.user) { 61 | session.user.role = token.role as UserRole; 62 | } 63 | 64 | if (session.user) { 65 | session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean; 66 | session.user.name = token.name; 67 | session.user.email = token.email; 68 | session.user.isOAuth = token.isOAuth as boolean; 69 | } 70 | 71 | return session; 72 | }, 73 | async jwt({ token }: { token: JWT }) { 74 | if (!token.sub) return token; 75 | 76 | const existingUser = await getUserById(token.sub); 77 | 78 | if (!existingUser) return token; 79 | 80 | const existingAccount = await getAccountByUserId(existingUser.id); 81 | 82 | token.isOAuth = !!existingAccount; 83 | token.name = existingUser.name; 84 | token.email = existingUser.email; 85 | token.role = existingUser.role; 86 | token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled; 87 | 88 | return token; 89 | }, 90 | }, 91 | adapter: PrismaAdapter(db), 92 | session: { strategy: 'jwt' }, 93 | ...authConfig, 94 | }; 95 | 96 | export const { 97 | handlers: { GET, POST }, 98 | auth, 99 | signIn, 100 | signOut, 101 | } = NextAuth(authOptions); 102 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/auth/back-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | 7 | interface BackButtonProps { 8 | href: string; 9 | label: string; 10 | }; 11 | 12 | export const BackButton = ({ 13 | href, 14 | label, 15 | }: BackButtonProps) => { 16 | return ( 17 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /components/auth/card-wrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardFooter, 7 | CardHeader 8 | } from "@/components/ui/card"; 9 | import { Header } from "@/components/auth/header"; 10 | import { Social } from "@/components/auth/social"; 11 | import { BackButton } from "@/components/auth/back-button"; 12 | 13 | interface CardWrapperProps { 14 | children: React.ReactNode; 15 | headerLabel: string; 16 | backButtonLabel: string; 17 | backButtonHref: string; 18 | showSocial?: boolean; 19 | }; 20 | 21 | export const CardWrapper = ({ 22 | children, 23 | headerLabel, 24 | backButtonLabel, 25 | backButtonHref, 26 | showSocial 27 | }: CardWrapperProps) => { 28 | return ( 29 | 30 | 31 |
32 | 33 | 34 | {children} 35 | 36 | {showSocial && ( 37 | 38 | 39 | 40 | )} 41 | 42 | 46 | 47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /components/auth/error-card.tsx: -------------------------------------------------------------------------------- 1 | import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; 2 | 3 | import { CardWrapper } from "@/components/auth/card-wrapper"; 4 | 5 | export const ErrorCard = () => { 6 | return ( 7 | 12 |
13 | 14 |
15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /components/auth/header.tsx: -------------------------------------------------------------------------------- 1 | import { Poppins } from "next/font/google"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const font = Poppins({ 6 | subsets: ["latin"], 7 | weight: ["600"], 8 | }); 9 | 10 | interface HeaderProps { 11 | label: string; 12 | }; 13 | 14 | export const Header = ({ 15 | label, 16 | }: HeaderProps) => { 17 | return ( 18 |
19 |

23 | 🔐 Auth 24 |

25 |

26 | {label} 27 |

28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /components/auth/login-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogTrigger, 9 | } from "@/components/ui/dialog"; 10 | import { LoginForm } from "@/components/auth/login-form"; 11 | 12 | interface LoginButtonProps { 13 | children: React.ReactNode; 14 | mode?: "modal" | "redirect", 15 | asChild?: boolean; 16 | }; 17 | 18 | export const LoginButton = ({ 19 | children, 20 | mode = "redirect", 21 | asChild 22 | }: LoginButtonProps) => { 23 | const router = useRouter(); 24 | 25 | const onClick = () => { 26 | router.push("/auth/login"); 27 | }; 28 | 29 | if (mode === "modal") { 30 | return ( 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | return ( 43 | 44 | {children} 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /components/auth/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { useForm } from "react-hook-form"; 5 | import { useState, useTransition } from "react"; 6 | import { useSearchParams } from "next/navigation"; 7 | import { zodResolver } from "@hookform/resolvers/zod"; 8 | import Link from "next/link"; 9 | 10 | import { LoginSchema } from "@/schemas"; 11 | import { Input } from "@/components/ui/input"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { CardWrapper } from "@/components/auth/card-wrapper" 21 | import { Button } from "@/components/ui/button"; 22 | import { FormError } from "@/components/form-error"; 23 | import { FormSuccess } from "@/components/form-success"; 24 | import { login } from "@/actions/login"; 25 | 26 | export const LoginForm = () => { 27 | const searchParams = useSearchParams(); 28 | const callbackUrl = searchParams.get("callbackUrl"); 29 | const urlError = searchParams.get("error") === "OAuthAccountNotLinked" 30 | ? "Email already in use with different provider!" 31 | : ""; 32 | 33 | const [showTwoFactor, setShowTwoFactor] = useState(false); 34 | const [error, setError] = useState(""); 35 | const [success, setSuccess] = useState(""); 36 | const [isPending, startTransition] = useTransition(); 37 | 38 | const form = useForm>({ 39 | resolver: zodResolver(LoginSchema), 40 | defaultValues: { 41 | email: "", 42 | password: "", 43 | }, 44 | }); 45 | 46 | const onSubmit = (values: z.infer) => { 47 | setError(""); 48 | setSuccess(""); 49 | 50 | startTransition(() => { 51 | login(values, callbackUrl) 52 | .then((data) => { 53 | if (data?.error) { 54 | form.reset(); 55 | setError(data.error); 56 | } 57 | 58 | if (data?.success) { 59 | form.reset(); 60 | setSuccess(data.success); 61 | } 62 | 63 | if (data?.twoFactor) { 64 | setShowTwoFactor(true); 65 | } 66 | }) 67 | .catch(() => setError("Something went wrong")); 68 | }); 69 | }; 70 | 71 | return ( 72 | 78 |
79 | 83 |
84 | {showTwoFactor && ( 85 | ( 89 | 90 | Two Factor Code 91 | 92 | 97 | 98 | 99 | 100 | )} 101 | /> 102 | )} 103 | {!showTwoFactor && ( 104 | <> 105 | ( 109 | 110 | Email 111 | 112 | 118 | 119 | 120 | 121 | )} 122 | /> 123 | ( 127 | 128 | Password 129 | 130 | 136 | 137 | 147 | 148 | 149 | )} 150 | /> 151 | 152 | )} 153 |
154 | 155 | 156 | 163 | 164 | 165 |
166 | ); 167 | }; 168 | -------------------------------------------------------------------------------- /components/auth/logout-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { logout } from "@/actions/logout"; 4 | 5 | interface LogoutButtonProps { 6 | children?: React.ReactNode; 7 | }; 8 | 9 | export const LogoutButton = ({ 10 | children 11 | }: LogoutButtonProps) => { 12 | const onClick = () => { 13 | logout(); 14 | }; 15 | 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /components/auth/new-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { useForm } from "react-hook-form"; 5 | import { useState, useTransition } from "react"; 6 | import { useSearchParams } from "next/navigation"; 7 | import { zodResolver } from "@hookform/resolvers/zod"; 8 | 9 | import { NewPasswordSchema } from "@/schemas"; 10 | import { Input } from "@/components/ui/input"; 11 | import { 12 | Form, 13 | FormControl, 14 | FormField, 15 | FormItem, 16 | FormLabel, 17 | FormMessage, 18 | } from "@/components/ui/form"; 19 | import { CardWrapper } from "@/components/auth/card-wrapper" 20 | import { Button } from "@/components/ui/button"; 21 | import { FormError } from "@/components/form-error"; 22 | import { FormSuccess } from "@/components/form-success"; 23 | import { newPassword } from "@/actions/new-password"; 24 | 25 | export const NewPasswordForm = () => { 26 | const searchParams = useSearchParams(); 27 | const token = searchParams.get("token"); 28 | 29 | const [error, setError] = useState(""); 30 | const [success, setSuccess] = useState(""); 31 | const [isPending, startTransition] = useTransition(); 32 | 33 | const form = useForm>({ 34 | resolver: zodResolver(NewPasswordSchema), 35 | defaultValues: { 36 | password: "", 37 | }, 38 | }); 39 | 40 | const onSubmit = (values: z.infer) => { 41 | setError(""); 42 | setSuccess(""); 43 | 44 | startTransition(() => { 45 | newPassword(values, token) 46 | .then((data) => { 47 | setError(data?.error); 48 | setSuccess(data?.success); 49 | }); 50 | }); 51 | }; 52 | 53 | return ( 54 | 59 |
60 | 64 |
65 | ( 69 | 70 | Password 71 | 72 | 78 | 79 | 80 | 81 | )} 82 | /> 83 |
84 | 85 | 86 | 93 | 94 | 95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /components/auth/new-verification-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { BeatLoader } from "react-spinners"; 5 | import { useSearchParams } from "next/navigation"; 6 | 7 | import { newVerification } from "@/actions/new-verification"; 8 | import { CardWrapper } from "@/components/auth/card-wrapper"; 9 | import { FormError } from "@/components/form-error"; 10 | import { FormSuccess } from "@/components/form-success"; 11 | 12 | export const NewVerificationForm = () => { 13 | const [error, setError] = useState(); 14 | const [success, setSuccess] = useState(); 15 | 16 | const searchParams = useSearchParams(); 17 | 18 | const token = searchParams.get("token"); 19 | 20 | const onSubmit = useCallback(() => { 21 | if (success || error) return; 22 | 23 | if (!token) { 24 | setError("Missing token!"); 25 | return; 26 | } 27 | 28 | newVerification(token) 29 | .then((data) => { 30 | setSuccess(data.success); 31 | setError(data.error); 32 | }) 33 | .catch(() => { 34 | setError("Something went wrong!"); 35 | }) 36 | }, [token, success, error]); 37 | 38 | useEffect(() => { 39 | onSubmit(); 40 | }, [onSubmit]); 41 | 42 | return ( 43 | 48 |
49 | {!success && !error && ( 50 | 51 | )} 52 | 53 | {!success && ( 54 | 55 | )} 56 |
57 |
58 | ) 59 | } -------------------------------------------------------------------------------- /components/auth/register-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { useState, useTransition } from "react"; 5 | import { useForm } from "react-hook-form"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | 8 | import { RegisterSchema } from "@/schemas"; 9 | import { Input } from "@/components/ui/input"; 10 | import { 11 | Form, 12 | FormControl, 13 | FormField, 14 | FormItem, 15 | FormLabel, 16 | FormMessage, 17 | } from "@/components/ui/form"; 18 | import { CardWrapper } from "@/components/auth/card-wrapper" 19 | import { Button } from "@/components/ui/button"; 20 | import { FormError } from "@/components/form-error"; 21 | import { FormSuccess } from "@/components/form-success"; 22 | import { register } from "@/actions/register"; 23 | 24 | export const RegisterForm = () => { 25 | const [error, setError] = useState(""); 26 | const [success, setSuccess] = useState(""); 27 | const [isPending, startTransition] = useTransition(); 28 | 29 | const form = useForm>({ 30 | resolver: zodResolver(RegisterSchema), 31 | defaultValues: { 32 | email: "", 33 | password: "", 34 | name: "", 35 | }, 36 | }); 37 | 38 | const onSubmit = (values: z.infer) => { 39 | setError(""); 40 | setSuccess(""); 41 | 42 | startTransition(() => { 43 | register(values) 44 | .then((data) => { 45 | setError(data.error); 46 | setSuccess(data.success); 47 | }); 48 | }); 49 | }; 50 | 51 | return ( 52 | 58 |
59 | 63 |
64 | ( 68 | 69 | Name 70 | 71 | 76 | 77 | 78 | 79 | )} 80 | /> 81 | ( 85 | 86 | Email 87 | 88 | 94 | 95 | 96 | 97 | )} 98 | /> 99 | ( 103 | 104 | Password 105 | 106 | 112 | 113 | 114 | 115 | )} 116 | /> 117 |
118 | 119 | 120 | 127 | 128 | 129 |
130 | ); 131 | }; 132 | -------------------------------------------------------------------------------- /components/auth/reset-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { useForm } from "react-hook-form"; 5 | import { useState, useTransition } from "react"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | 8 | import { ResetSchema } from "@/schemas"; 9 | import { Input } from "@/components/ui/input"; 10 | import { 11 | Form, 12 | FormControl, 13 | FormField, 14 | FormItem, 15 | FormLabel, 16 | FormMessage, 17 | } from "@/components/ui/form"; 18 | import { CardWrapper } from "@/components/auth/card-wrapper" 19 | import { Button } from "@/components/ui/button"; 20 | import { FormError } from "@/components/form-error"; 21 | import { FormSuccess } from "@/components/form-success"; 22 | import { reset } from "@/actions/reset"; 23 | 24 | export const ResetForm = () => { 25 | const [error, setError] = useState(""); 26 | const [success, setSuccess] = useState(""); 27 | const [isPending, startTransition] = useTransition(); 28 | 29 | const form = useForm>({ 30 | resolver: zodResolver(ResetSchema), 31 | defaultValues: { 32 | email: "", 33 | }, 34 | }); 35 | 36 | const onSubmit = (values: z.infer) => { 37 | setError(""); 38 | setSuccess(""); 39 | 40 | startTransition(() => { 41 | reset(values) 42 | .then((data) => { 43 | setError(data?.error); 44 | setSuccess(data?.success); 45 | }); 46 | }); 47 | }; 48 | 49 | return ( 50 | 55 |
56 | 60 |
61 | ( 65 | 66 | Email 67 | 68 | 74 | 75 | 76 | 77 | )} 78 | /> 79 |
80 | 81 | 82 | 89 | 90 | 91 |
92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /components/auth/role-gate.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UserRole } from "@prisma/client"; 4 | 5 | import { useCurrentRole } from "@/hooks/use-current-role"; 6 | import { FormError } from "@/components/form-error"; 7 | 8 | interface RoleGateProps { 9 | children: React.ReactNode; 10 | allowedRole: UserRole; 11 | }; 12 | 13 | export const RoleGate = ({ 14 | children, 15 | allowedRole, 16 | }: RoleGateProps) => { 17 | const role = useCurrentRole(); 18 | 19 | if (role !== allowedRole) { 20 | return ( 21 | 22 | ) 23 | } 24 | 25 | return ( 26 | <> 27 | {children} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /components/auth/social.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signIn } from "next-auth/react"; 4 | import { FcGoogle } from "react-icons/fc"; 5 | import { FaGithub } from "react-icons/fa"; 6 | import { useSearchParams } from "next/navigation"; 7 | 8 | import { Button } from "@/components/ui/button"; 9 | import { DEFAULT_LOGIN_REDIRECT } from "@/routes"; 10 | 11 | export const Social = () => { 12 | const searchParams = useSearchParams(); 13 | const callbackUrl = searchParams.get("callbackUrl"); 14 | 15 | const onClick = (provider: "google" | "github") => { 16 | signIn(provider, { 17 | callbackUrl: callbackUrl || DEFAULT_LOGIN_REDIRECT, 18 | }); 19 | } 20 | 21 | return ( 22 |
23 | 31 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /components/auth/user-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FaUser } from "react-icons/fa"; 4 | import { ExitIcon } from "@radix-ui/react-icons" 5 | 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { 13 | Avatar, 14 | AvatarImage, 15 | AvatarFallback, 16 | } from "@/components/ui/avatar"; 17 | import { useCurrentUser } from "@/hooks/use-current-user"; 18 | import { LogoutButton } from "@/components/auth/logout-button"; 19 | 20 | export const UserButton = () => { 21 | const user = useCurrentUser(); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | Logout 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /components/form-error.tsx: -------------------------------------------------------------------------------- 1 | import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; 2 | 3 | interface FormErrorProps { 4 | message?: string; 5 | }; 6 | 7 | export const FormError = ({ 8 | message, 9 | }: FormErrorProps) => { 10 | if (!message) return null; 11 | 12 | return ( 13 |
14 | 15 |

{message}

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /components/form-success.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircledIcon } from "@radix-ui/react-icons"; 2 | 3 | interface FormSuccessProps { 4 | message?: string; 5 | }; 6 | 7 | export const FormSuccess = ({ 8 | message, 9 | }: FormSuccessProps) => { 10 | if (!message) return null; 11 | 12 | return ( 13 |
14 | 15 |

{message}

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | success: "border-transparent bg-emerald-500 text-primary-foreground" 19 | }, 20 | }, 21 | defaultVariants: { 22 | variant: "default", 23 | }, 24 | } 25 | ) 26 | 27 | export interface BadgeProps 28 | extends React.HTMLAttributes, 29 | VariantProps {} 30 | 31 | function Badge({ className, variant, ...props }: BadgeProps) { 32 | return ( 33 |
34 | ) 35 | } 36 | 37 | export { Badge, badgeVariants } 38 | -------------------------------------------------------------------------------- /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 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /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 |

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

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

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { Cross2Icon } from "@radix-ui/react-icons" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { 6 | CheckIcon, 7 | ChevronRightIcon, 8 | DotFilledIcon, 9 | } from "@radix-ui/react-icons" 10 | 11 | import { cn } from "@/lib/utils" 12 | 13 | const DropdownMenu = DropdownMenuPrimitive.Root 14 | 15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 16 | 17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 18 | 19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 20 | 21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 22 | 23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 24 | 25 | const DropdownMenuSubTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef & { 28 | inset?: boolean 29 | } 30 | >(({ className, inset, children, ...props }, ref) => ( 31 | 40 | {children} 41 | 42 | 43 | )) 44 | DropdownMenuSubTrigger.displayName = 45 | DropdownMenuPrimitive.SubTrigger.displayName 46 | 47 | const DropdownMenuSubContent = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, ...props }, ref) => ( 51 | 59 | )) 60 | DropdownMenuSubContent.displayName = 61 | DropdownMenuPrimitive.SubContent.displayName 62 | 63 | const DropdownMenuContent = React.forwardRef< 64 | React.ElementRef, 65 | React.ComponentPropsWithoutRef 66 | >(({ className, sideOffset = 4, ...props }, ref) => ( 67 | 68 | 78 | 79 | )) 80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 81 | 82 | const DropdownMenuItem = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef & { 85 | inset?: boolean 86 | } 87 | >(({ className, inset, ...props }, ref) => ( 88 | 97 | )) 98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 99 | 100 | const DropdownMenuCheckboxItem = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, children, checked, ...props }, ref) => ( 104 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | )) 121 | DropdownMenuCheckboxItem.displayName = 122 | DropdownMenuPrimitive.CheckboxItem.displayName 123 | 124 | const DropdownMenuRadioItem = React.forwardRef< 125 | React.ElementRef, 126 | React.ComponentPropsWithoutRef 127 | >(({ className, children, ...props }, ref) => ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | )) 144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 145 | 146 | const DropdownMenuLabel = React.forwardRef< 147 | React.ElementRef, 148 | React.ComponentPropsWithoutRef & { 149 | inset?: boolean 150 | } 151 | >(({ className, inset, ...props }, ref) => ( 152 | 161 | )) 162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 163 | 164 | const DropdownMenuSeparator = React.forwardRef< 165 | React.ElementRef, 166 | React.ComponentPropsWithoutRef 167 | >(({ className, ...props }, ref) => ( 168 | 173 | )) 174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 175 | 176 | const DropdownMenuShortcut = ({ 177 | className, 178 | ...props 179 | }: React.HTMLAttributes) => { 180 | return ( 181 | 185 | ) 186 | } 187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 188 | 189 | export { 190 | DropdownMenu, 191 | DropdownMenuTrigger, 192 | DropdownMenuContent, 193 | DropdownMenuItem, 194 | DropdownMenuCheckboxItem, 195 | DropdownMenuRadioItem, 196 | DropdownMenuLabel, 197 | DropdownMenuSeparator, 198 | DropdownMenuShortcut, 199 | DropdownMenuGroup, 200 | DropdownMenuPortal, 201 | DropdownMenuSub, 202 | DropdownMenuSubContent, 203 | DropdownMenuSubTrigger, 204 | DropdownMenuRadioGroup, 205 | } 206 | -------------------------------------------------------------------------------- /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 |