├── .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 └── verification-token.ts ├── hooks ├── use-current-role.ts └── use-current-user.ts ├── lib ├── auth.ts ├── db.ts ├── mail.ts ├── token.ts └── utils.ts ├── middleware.ts ├── next-auth.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── next.svg └── vercel.svg ├── routes.ts ├── schemas └── index.ts ├── tailwind.config.ts └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 bcrypt from "bcryptjs"; 5 | import { AuthError } from "next-auth"; 6 | 7 | import { db } from "@/lib/db"; 8 | import { signIn } from "@/auth"; 9 | import { LoginSchema } from "@/schemas"; 10 | import { getUserByEmail } from "@/data/user"; 11 | import { getTwoFactorTokenByEmail } from "@/data/two-factor-token"; 12 | import { sendVerificationEmail, sendTwoFactorTokenEmail } from "@/lib/mail"; 13 | import { DEFAULT_LOGIN_REDIRECT } from "@/routes"; 14 | import { generateVerificationToken, generateTwoFactorToken } from "@/lib/token"; 15 | import { getTwoFactorConfirmationByUserId } from "@/data/two-factor-confirmation"; 16 | 17 | export const login = async ( 18 | values: z.infer, 19 | callbackUrl?: string | null 20 | ) => { 21 | const validatedFields = LoginSchema.safeParse(values); 22 | 23 | if (!validatedFields.success) { 24 | return { error: "Invalid fields!" }; 25 | } 26 | 27 | const { email, password, code } = validatedFields.data; 28 | 29 | const existingUser = await getUserByEmail(email); 30 | 31 | if (!existingUser || !existingUser.email || !existingUser.password) { 32 | return { error: "Email does not exist!" }; 33 | } 34 | 35 | if (!existingUser.emailVerified) { 36 | const verificationToken = await generateVerificationToken( 37 | existingUser.email 38 | ); 39 | 40 | await sendVerificationEmail( 41 | verificationToken.email, 42 | verificationToken.token 43 | ); 44 | 45 | return { success: "Confirmation email sent!" }; 46 | } 47 | 48 | if (existingUser.isTwoFactorEnabled && existingUser.email) { 49 | // Check password before proceeding with 2FA 50 | const isPasswordValid = await bcrypt.compare( 51 | password, 52 | existingUser.password 53 | ); 54 | 55 | if (!isPasswordValid) { 56 | return { error: "Invalid credentials!" }; 57 | } 58 | 59 | if (code) { 60 | const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email); 61 | 62 | if (!twoFactorToken) { 63 | return { error: "Invalid code!" }; 64 | } 65 | 66 | if (twoFactorToken.token !== code) { 67 | return { error: "Invalid code!" }; 68 | } 69 | 70 | const hasExpired = new Date(twoFactorToken.expires) < new Date(); 71 | 72 | if (hasExpired) { 73 | return { error: "Code expired!" }; 74 | } 75 | 76 | await db.twoFactorToken.delete({ 77 | where: { id: twoFactorToken.id }, 78 | }); 79 | 80 | const existingConfirmation = await getTwoFactorConfirmationByUserId( 81 | existingUser.id 82 | ); 83 | 84 | if (existingConfirmation) { 85 | await db.twoFactorConfirmation.delete({ 86 | where: { id: existingConfirmation.id }, 87 | }); 88 | } 89 | 90 | await db.twoFactorConfirmation.create({ 91 | data: { userId: existingUser.id }, 92 | }); 93 | } else { 94 | const twoFactorToken = await generateTwoFactorToken(existingUser.email); 95 | await sendTwoFactorTokenEmail(twoFactorToken.email, twoFactorToken.token); 96 | 97 | return { twoFactor: true }; 98 | } 99 | } 100 | 101 | try { 102 | await signIn("credentials", { 103 | email, 104 | password, 105 | redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT, 106 | }); 107 | } catch (error) { 108 | if (error instanceof AuthError) { 109 | switch (error.type) { 110 | case "CredentialsSignin": 111 | return { error: "Invalid credentials!" }; 112 | default: 113 | return { error: "Something went wrong!" }; 114 | } 115 | } 116 | 117 | throw error; 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /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 validateFields = NewPasswordSchema.safeParse(values); 20 | 21 | if (!validateFields.success) { 22 | return { error: "Invalid fields!" }; 23 | } 24 | 25 | const { password } = validateFields.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 expired!" }; 37 | } 38 | 39 | const existingUser = await getUserByEmail(existingToken.email); 40 | 41 | if (!existingUser) { 42 | return { error: "Email not found!" }; 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 { getUserByEmail } from "@/data/user"; 4 | import { getVerificationTokenByToken } from "@/data/verification-token"; 5 | import { db } from "@/lib/db"; 6 | 7 | export const newVerification = async (token: string) => { 8 | const existingToken = await getVerificationTokenByToken(token); 9 | 10 | if (!existingToken) { 11 | return { error: "Token does not exists!" }; 12 | } 13 | 14 | const hasExpired = new Date(existingToken.expires) < new Date(); 15 | 16 | if (hasExpired) { 17 | return { error: "Token expired!" }; 18 | } 19 | 20 | const existingUser = await getUserByEmail(existingToken.email); 21 | 22 | if (!existingUser) { 23 | return { error: "Email does not exists!" }; 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 | import { db } from "@/lib/db"; 6 | import { RegisterSchema } from "@/schemas"; 7 | import { getUserByEmail } from "@/data/user"; 8 | import { generateVerificationToken } from "@/lib/token"; 9 | import { sendVerificationEmail } from "@/lib/mail"; 10 | 11 | export const register = async (values: z.infer) => { 12 | const validatedFields = RegisterSchema.safeParse(values); 13 | 14 | if (!validatedFields.success) { 15 | return { error: "Invalid fields" }; 16 | } 17 | 18 | const { email, password, name } = validatedFields.data; 19 | const hashedPassword = await bcrypt.hash(password, 10); 20 | 21 | const existingUser = await getUserByEmail(email); 22 | 23 | if (existingUser) { 24 | return { error: "Email already in use!" }; 25 | } 26 | 27 | await db.user.create({ 28 | data: { 29 | name, 30 | email, 31 | password: hashedPassword, 32 | }, 33 | }); 34 | 35 | const verificationToken = await generateVerificationToken(email); 36 | await sendVerificationEmail(verificationToken.email, verificationToken.token); 37 | 38 | return { success: "Confirmation email sent!" }; 39 | }; 40 | -------------------------------------------------------------------------------- /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/token"; 9 | 10 | export const reset = async (values: z.infer) => { 11 | const validateFields = ResetSchema.safeParse(values); 12 | 13 | if (!validateFields.success) { 14 | return { error: "Invalid email!" }; 15 | } 16 | 17 | const { email } = validateFields.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 | }; 33 | -------------------------------------------------------------------------------- /actions/settings.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | import bcrypt from "bcryptjs"; 5 | 6 | import { update } from "@/auth"; 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/token"; 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 | update({ 73 | user: { 74 | name: updatedUser.name, 75 | email: updatedUser.email, 76 | isTwoFactorEnabled: updatedUser.isTwoFactorEnabled, 77 | role: updatedUser.role, 78 | }, 79 | }); 80 | 81 | return { success: "Settings Updated!" }; 82 | }; 83 | -------------------------------------------------------------------------------- /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 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /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; -------------------------------------------------------------------------------- /app/(protected)/client/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCurrentUser } from "@/hooks/use-current-user"; 4 | import { UserInfo } from "@/components/user-info"; 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; 17 | -------------------------------------------------------------------------------- /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 | 3 | -------------------------------------------------------------------------------- /app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorCard } from "@/components/auth/error-card"; 2 | 3 | const AuthErrorPage = () => { 4 | return ( 5 | 6 | ); 7 | }; 8 | 9 | export default AuthErrorPage; 10 | -------------------------------------------------------------------------------- /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 | }; 8 | 9 | export default LoginPage; 10 | -------------------------------------------------------------------------------- /app/auth/new-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { NewPasswordForm } from "@/components/auth/new-password-form"; 2 | 3 | const NewPasswordPage = () => { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | export default NewPasswordPage; -------------------------------------------------------------------------------- /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 | }; 8 | 9 | export default RegisterPage; 10 | -------------------------------------------------------------------------------- /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; 10 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hafizn07/next-auth-v5-advanced-guide-2024/63f5cecf6d2bb540e412dc1fce1adfe0b40a9b75/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 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { auth } from "@/auth"; 5 | import { SessionProvider } from "next-auth/react"; 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 | import { cn } from "@/lib/utils"; 3 | 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 |

22 | 🔐 Auth 23 |

24 |

A simple authentication service!

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 { UserRole } from "@prisma/client"; 3 | import { PrismaAdapter } from "@auth/prisma-adapter"; 4 | 5 | import { getUserById } from "@/data/user"; 6 | import { db } from "@/lib/db"; 7 | import authConfig from "@/auth.config"; 8 | import { getTwoFactorConfirmationByUserId } from "./data/two-factor-confirmation"; 9 | import { getAccountByUserId } from "@/data/account"; 10 | 11 | export const { 12 | handlers: { GET, POST }, 13 | auth, 14 | signIn, 15 | signOut, 16 | update, 17 | } = NextAuth({ 18 | pages: { 19 | signIn: "/auth/login", 20 | error: "/auth/error", 21 | }, 22 | events: { 23 | async linkAccount({ user }) { 24 | await db.user.update({ 25 | where: { id: user.id }, 26 | data: { emailVerified: new Date() }, 27 | }); 28 | }, 29 | }, 30 | callbacks: { 31 | async signIn({ user, account }) { 32 | //Allow OAuth without email verification 33 | if (account?.provider !== "credentials") return true; 34 | 35 | const existingUser = await getUserById(user.id); 36 | 37 | //Prevent sign in without email verification 38 | if (!existingUser?.emailVerified) return false; 39 | 40 | //2FA check 41 | if (existingUser.isTwoFactorEnabled) { 42 | const twoFactorConfirmation = await getTwoFactorConfirmationByUserId( 43 | existingUser.id 44 | ); 45 | 46 | if (!twoFactorConfirmation) return false; 47 | 48 | //Delete the two factor confirmation for next sign in 49 | await db.twoFactorConfirmation.delete({ 50 | where: { id: twoFactorConfirmation.id }, 51 | }); 52 | } 53 | 54 | return true; 55 | }, 56 | 57 | async session({ token, session }) { 58 | if (token.sub && session.user) { 59 | session.user.id = token.sub; 60 | } 61 | 62 | if (token.role && session.user) { 63 | session.user.role = token.role as UserRole; 64 | } 65 | 66 | if (session.user) { 67 | session.user.name = token.name; 68 | session.user.email = token.email; 69 | session.user.isOAuth = token.isOAuth as boolean; 70 | session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean; 71 | } 72 | 73 | return session; 74 | }, 75 | async jwt({ token }) { 76 | if (!token.sub) return token; 77 | 78 | const existingUser = await getUserById(token.sub); 79 | 80 | if (!existingUser) return token; 81 | 82 | const existingAccount = await getAccountByUserId(existingUser.id); 83 | 84 | token.isOAuth = !!existingAccount; 85 | token.name = existingUser.name; 86 | token.email = existingUser.email; 87 | token.role = existingUser.role; 88 | token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled; 89 | 90 | return token; 91 | }, 92 | }, 93 | adapter: PrismaAdapter(db), 94 | session: { strategy: "jwt" }, 95 | ...authConfig, 96 | }); 97 | -------------------------------------------------------------------------------- /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 = ({ href, label }: BackButtonProps) => { 13 | return ( 14 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /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 | {children} 34 | {showSocial && ( 35 | 36 | 37 | 38 | )} 39 | 40 | 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /components/auth/error-card.tsx: -------------------------------------------------------------------------------- 1 | import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; 2 | import { CardWrapper } from "@/components/auth/card-wrapper"; 3 | 4 | export const ErrorCard = () => { 5 | return ( 6 | 11 |
12 | 13 |
14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /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 = ({ label }: HeaderProps) => { 15 | return ( 16 |
17 |

🔐 Auth

18 |

{label}

19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /components/auth/login-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | 5 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 6 | import { LoginForm } from "@/components/auth/login-form"; 7 | 8 | interface LoginButtonProps { 9 | children: React.ReactNode; 10 | mode?: "modal" | "redirect"; 11 | asChild?: boolean; 12 | } 13 | 14 | export const LoginButton = ({ 15 | children, 16 | mode = "redirect", 17 | asChild, 18 | }: LoginButtonProps) => { 19 | const router = useRouter(); 20 | 21 | const onClick = () => { 22 | router.push("/auth/login"); 23 | }; 24 | 25 | if (mode === "modal") { 26 | return ( 27 | 28 | {children} 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /components/auth/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { useSearchParams } from "next/navigation"; 5 | import { useState, useTransition } from "react"; 6 | import { useForm } from "react-hook-form"; 7 | import { zodResolver } from "@hookform/resolvers/zod"; 8 | import Link from "next/link"; 9 | 10 | import { LoginSchema } from "@/schemas"; 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 { Input } from "@/components/ui/input"; 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 | 30 | const errorUrl = 31 | searchParams.get("error") === "OAuthAccountNotLinked" 32 | ? "Email already in use with a different provider" 33 | : ""; 34 | 35 | const [showTwoFactor, setShowTwoFactor] = useState(false); 36 | const [error, setError] = useState(""); 37 | const [success, setSuccess] = useState(""); 38 | const [isPending, startTransition] = useTransition(); 39 | 40 | const form = useForm>({ 41 | resolver: zodResolver(LoginSchema), 42 | defaultValues: { 43 | email: "", 44 | password: "", 45 | }, 46 | }); 47 | 48 | const onSubmit = (values: z.infer) => { 49 | setError(""); 50 | setSuccess(""); 51 | 52 | startTransition(() => { 53 | login(values, callbackUrl) 54 | .then((data) => { 55 | if (data?.error) { 56 | form.reset(); 57 | setError(data.error); 58 | } 59 | 60 | if (data?.success) { 61 | form.reset(); 62 | setSuccess(data.success); 63 | } 64 | 65 | if (data?.twoFactor) { 66 | setShowTwoFactor(true); 67 | } 68 | }) 69 | .catch(() => setError("Something went wrong!")); 70 | }); 71 | }; 72 | 73 | return ( 74 | 80 |
81 | 82 |
83 | {showTwoFactor && ( 84 | ( 88 | 89 | Two factor code 90 | 91 | 96 | 97 | 98 | 99 | )} 100 | /> 101 | )} 102 | {!showTwoFactor && ( 103 | <> 104 | ( 108 | 109 | Email 110 | 111 | 117 | 118 | 119 | 120 | )} 121 | /> 122 | ( 126 | 127 | Password 128 | 129 | 135 | 136 | 144 | 145 | 146 | )} 147 | /> 148 | 149 | )} 150 |
151 | 152 | 153 | 156 | 157 | 158 |
159 | ); 160 | }; 161 | -------------------------------------------------------------------------------- /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 = ({ children }: LogoutButtonProps) => { 10 | const onClick = () => { 11 | logout(); 12 | }; 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /components/auth/new-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { useSearchParams } from "next/navigation"; 5 | import { useState, useTransition } from "react"; 6 | import { useForm } from "react-hook-form"; 7 | import { zodResolver } from "@hookform/resolvers/zod"; 8 | 9 | import { NewPasswordSchema } from "@/schemas"; 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 { Input } from "@/components/ui/input"; 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 | 61 |
62 | ( 66 | 67 | Password 68 | 69 | 75 | 76 | 77 | 78 | )} 79 | /> 80 |
81 | 82 | 83 | 86 | 87 | 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /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 | 26 | return; 27 | } 28 | 29 | newVerification(token) 30 | .then((data) => { 31 | setSuccess(data.success); 32 | setError(data.error); 33 | }) 34 | .catch(() => { 35 | setError("Something went wrong!"); 36 | }); 37 | }, [token, success, error]); 38 | 39 | useEffect(() => { 40 | onSubmit(); 41 | }, [onSubmit]); 42 | 43 | return ( 44 | 49 |
50 | {!success && !error && } 51 | 52 | {!success && } 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /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 | import { RegisterSchema } from "@/schemas"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormField, 12 | FormItem, 13 | FormLabel, 14 | FormMessage, 15 | } from "@/components/ui/form"; 16 | import { CardWrapper } from "@/components/auth/card-wrapper"; 17 | import { Input } from "@/components/ui/input"; 18 | import { Button } from "@/components/ui/button"; 19 | import { FormError } from "@/components/form-error"; 20 | import { FormSuccess } from "@/components/form-success"; 21 | import { register } from "@/actions/register"; 22 | 23 | export const RegisterForm = () => { 24 | const [error, setError] = useState(""); 25 | const [success, setSuccess] = useState(""); 26 | const [isPending, startTransition] = useTransition(); 27 | 28 | const form = useForm>({ 29 | resolver: zodResolver(RegisterSchema), 30 | defaultValues: { 31 | email: "", 32 | password: "", 33 | name: "", 34 | }, 35 | }); 36 | 37 | const onSubmit = (values: z.infer) => { 38 | setError(""); 39 | setSuccess(""); 40 | 41 | startTransition(() => { 42 | register(values).then((data) => { 43 | setError(data.error); 44 | setSuccess(data.success); 45 | }); 46 | }); 47 | }; 48 | 49 | return ( 50 | 56 |
57 | 58 |
59 | ( 63 | 64 | Name 65 | 66 | 71 | 72 | 73 | 74 | )} 75 | /> 76 | ( 80 | 81 | Email 82 | 83 | 89 | 90 | 91 | 92 | )} 93 | /> 94 | ( 98 | 99 | Password 100 | 101 | 107 | 108 | 109 | 110 | )} 111 | /> 112 |
113 | 114 | 115 | 118 | 119 | 120 |
121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /components/auth/reset-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 { ResetSchema } from "@/schemas"; 9 | import { 10 | Form, 11 | FormControl, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "@/components/ui/form"; 17 | import { CardWrapper } from "@/components/auth/card-wrapper"; 18 | import { Input } from "@/components/ui/input"; 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).then((data) => { 42 | setError(data?.error); 43 | setSuccess(data?.success); 44 | }); 45 | }); 46 | }; 47 | 48 | return ( 49 | 54 |
55 | 56 |
57 | ( 61 | 62 | Email 63 | 64 | 70 | 71 | 72 | 73 | )} 74 | /> 75 |
76 | 77 | 78 | 81 | 82 | 83 |
84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /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 = ({ children, allowedRole }: RoleGateProps) => { 14 | const role = useCurrentRole(); 15 | 16 | if (role !== allowedRole) { 17 | return ( 18 | 19 | ); 20 | } 21 | 22 | return <>{children}; 23 | }; 24 | -------------------------------------------------------------------------------- /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 { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 13 | import { useCurrentUser } from "@/hooks/use-current-user"; 14 | import { LogoutButton } from "@/components/auth/logout-button"; 15 | 16 | export const UserButton = () => { 17 | const user = useCurrentUser(); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Logout 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /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 = ({ message }: FormErrorProps) => { 8 | if (!message) return null; 9 | 10 | return ( 11 |
12 | 13 |

{message}

14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /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 = ({ message }: FormSuccessProps) => { 8 | if (!message) return null; 9 | 10 | return ( 11 |
12 | 13 |

{message}

14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /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 |