├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── admin │ └── page.tsx ├── api │ ├── auth │ │ ├── [...nextauth] │ │ │ └── route.ts │ │ └── verify-password-reset-token │ │ │ └── route.ts │ └── subscribe │ │ └── route.ts ├── auth-error │ └── page.tsx ├── client │ └── page.tsx ├── course │ └── subscribe │ │ ├── email-sent │ │ └── page.tsx │ │ ├── error │ │ └── page.tsx │ │ ├── status │ │ └── page.tsx │ │ └── success │ │ └── page.tsx ├── favicon.ico ├── fonts │ ├── GeistMonoVF.woff │ └── GeistVF.woff ├── forgot-password │ ├── email-sent │ │ └── page.tsx │ └── page.tsx ├── globals.css ├── layout.tsx ├── page.tsx ├── password-reset-actions.ts ├── private │ └── page.tsx ├── reset-password │ ├── page.tsx │ ├── success │ │ └── page.tsx │ └── verify │ │ └── page.tsx ├── schema.ts ├── server │ └── page.tsx ├── signin-actions.ts ├── signin │ ├── email-sent │ │ └── page.tsx │ ├── page.tsx │ └── verify-email │ │ └── page.tsx └── verify-email │ └── page.tsx ├── auth.config.ts ├── auth.ts ├── components.json ├── components ├── email-signin-template.tsx ├── email-verification-template.tsx ├── forgot-password-form.tsx ├── icons.tsx ├── main-nav.tsx ├── mobile-nav.tsx ├── nav-item.tsx ├── password-reset-email-template.tsx ├── reset-passsword-form.tsx ├── sign-out.tsx ├── signin-email-form.tsx ├── signin-email-password-form.tsx ├── signin-form.tsx ├── signin-google-form.tsx ├── subscribe-template.tsx ├── subscription-form.tsx ├── ui │ ├── avatar.tsx │ ├── button.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ └── tabs.tsx └── user-account-nav.tsx ├── config └── navbar.ts ├── database.types.ts ├── hooks └── use-current-session.ts ├── lib ├── assign-user-role.ts ├── authorize-credentials-error.ts ├── authorize-credentials.ts ├── aws.ts ├── check-credentials-email-verification-status.ts ├── check-subscriber-exists-error.ts ├── check-subscriber-exists.ts ├── check-user-exists-error.ts ├── check-user-exists.ts ├── create-password-reset-token-error.ts ├── create-password-reset-token.ts ├── create-user-error.ts ├── create-user.ts ├── custom-email-provider.ts ├── error-message.tsx ├── get-user-role.ts ├── handle-auth-jwt.ts ├── handle-auth-redirect.ts ├── handle-auth-session.ts ├── reset-passsord.ts ├── reset-password-error.ts ├── send-credentials-email-verification-email.ts ├── send-email-signin-link.ts ├── send-password-reset-email-error.ts ├── send-password-reset-email.ts ├── send-subscribe-email.ts ├── subscribe-actions.ts ├── supabase.ts ├── utils.ts ├── verify-credential-email-error.ts ├── verify-credential-email.ts ├── verify-password-reset-token-error.ts └── verify-password-reset-token.ts ├── middleware.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.js ├── postcss.config.mjs ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── supabase ├── .gitignore └── config.toml ├── tailwind.config.ts ├── tsconfig.json └── types └── next-auth.d.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"], 3 | "rules": { 4 | "@typescript-eslint/no-unused-vars": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for committing if needed) 33 | .env* 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | An project that demonstrates how to implement 3 | 4 | - Sign in with Google 5 | - Sign in with Email 6 | - Sign in with Email and Password (including "Reset password" flow) 7 | 8 | in your Next.js (v15) app using Auth.js (v5). 9 | 10 | ## Course 11 | If you want to learn how Auth.js works in depth, check out [my course](https://www.hemantasundaray.com/courses/next-auth). 12 | -------------------------------------------------------------------------------- /app/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | 3 | export default async function AdminPage() { 4 | const session = await auth(); 5 | 6 | if (session?.user?.role === "user") { 7 | return ( 8 |
9 |

Admin Access Only

10 |

11 | This page is only accessible to authenticated users having 12 | "admin" status. 13 |

14 |
15 | ); 16 | } 17 | 18 | return ( 19 |

20 | Welcome Admin 21 |

22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth"; 2 | 3 | export const { GET, POST } = handlers; 4 | -------------------------------------------------------------------------------- /app/api/auth/verify-password-reset-token/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from "next/server"; 2 | import { supabase } from "@/lib/supabase"; 3 | 4 | type TokenVerificationStatus = 5 | | "token-invalid" 6 | | "token-expired" 7 | | "internal-error" 8 | | "token-valid"; 9 | 10 | function getRedirectUrl(status: TokenVerificationStatus, token?: string) { 11 | if (status === "token-valid") { 12 | const path = `/reset-password?token=${token}`; 13 | console.log("Generated relative path:", path); 14 | return path; 15 | } 16 | const errorPath = `/reset-password/verify?error=${status}`; 17 | console.log("Generated error path:", errorPath); 18 | return errorPath; 19 | } 20 | 21 | export async function GET(request: NextRequest) { 22 | const searchParams = request.nextUrl.searchParams; 23 | const token = searchParams.get("token"); 24 | 25 | try { 26 | const { data: tokenData, error: tokenError } = await supabase 27 | .schema("next_auth") 28 | .from("reset_tokens") 29 | .select("*") 30 | .eq("token", token!) 31 | .single(); 32 | 33 | if (tokenError) { 34 | console.error("Error fetching password reset token:", tokenError); 35 | const redirectUrl = new URL( 36 | getRedirectUrl("token-invalid"), 37 | request.nextUrl.origin 38 | ); 39 | return NextResponse.redirect(redirectUrl); 40 | } 41 | 42 | // Check token expiration 43 | if (new Date(tokenData.expires) < new Date()) { 44 | const redirectUrl = new URL( 45 | getRedirectUrl("token-expired"), 46 | request.nextUrl.origin 47 | ); 48 | return NextResponse.redirect(redirectUrl); 49 | } 50 | 51 | // Valid token case - redirect to reset password page 52 | const redirectUrl = new URL( 53 | getRedirectUrl("token-valid", token!), 54 | request.nextUrl.origin 55 | ); 56 | return NextResponse.redirect(redirectUrl); 57 | } catch (error) { 58 | console.error("Unexpected error during token verification:", error); 59 | const redirectUrl = new URL( 60 | getRedirectUrl("internal-error"), 61 | request.nextUrl.origin 62 | ); 63 | return NextResponse.redirect(redirectUrl); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/api/subscribe/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from "next/server"; 2 | import { supabase } from "@/lib/supabase"; 3 | 4 | export async function GET(request: NextRequest) { 5 | const searchParams = request.nextUrl.searchParams; 6 | const email = searchParams.get("email")!; 7 | 8 | console.log("Subscriber email: ", email); 9 | 10 | try { 11 | // First, check if a user with this email already exists 12 | const { data: existingUser, error: lookupError } = await supabase 13 | .schema("next_auth") 14 | .from("subscribers") 15 | .select("*") 16 | .eq("email", email) 17 | .single(); 18 | 19 | // If user exists, redirect to status page 20 | if (existingUser) { 21 | return NextResponse.redirect( 22 | new URL("/course/subscribe/status", request.nextUrl.origin) 23 | ); 24 | } 25 | 26 | // Create user 27 | const { error: userError } = await supabase 28 | .schema("next_auth") 29 | .from("subscribers") 30 | .insert({ 31 | email, 32 | }) 33 | .select("*") 34 | .single(); 35 | 36 | if (userError) { 37 | console.error("Error inserting new user:", userError); 38 | throw new Error("Failed to create user"); 39 | } 40 | 41 | return NextResponse.redirect( 42 | new URL("/course/subscribe/success", request.nextUrl.origin) 43 | ); 44 | } catch (error) { 45 | return NextResponse.redirect( 46 | new URL("/course/subscribe/error", request.nextUrl.origin) 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/auth-error/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | 4 | export default async function AuthErrorPage({ 5 | searchParams, 6 | }: { 7 | searchParams: Promise<{ error?: string }>; 8 | }) { 9 | const params = await searchParams; 10 | 11 | function getErrorMessage(error: string | undefined) { 12 | switch (error) { 13 | case "Verification": 14 | return "The sign in link has expired. Please request a new one."; 15 | case "Default": 16 | default: 17 | return "An error occurred during authentication. Please try again."; 18 | } 19 | } 20 | const errorMessage = getErrorMessage(params.error); 21 | 22 | return ( 23 |
27 |

Authentication Error

28 |

{errorMessage}

29 | 33 | 34 | Sign in 35 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/client/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Icons } from "@/components/icons"; 4 | import { useCurrentSession } from "@/hooks/use-current-session"; 5 | 6 | export default function ClientPage() { 7 | const { session, status } = useCurrentSession(); 8 | 9 | if (status === "loading") { 10 | return ( 11 |
12 | 13 |

Loading...

14 |
15 | ); 16 | } 17 | 18 | if (status === "unauthenticated") { 19 | return ( 20 |
21 |

User Not Authenticated

22 |

User email: Not available

23 |

User role: Not available

24 |

25 | This is a Client Component. 26 |

27 |
28 | ); 29 | } 30 | 31 | return ( 32 |
33 |

User Authenticated

34 |

User email: {session?.user.email}

35 |

User role: {session?.user.role}

36 |

37 | This is a Client Component. 38 |

39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/course/subscribe/email-sent/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | 4 | export default function EmailSentPage() { 5 | return ( 6 |
7 |

Check Inbox

8 |

9 | I've sent a subscription email. Click the link in the email to 10 | subscribe. 11 |

12 |

13 | Didn't receive the email? Check your spam or junk folder. 14 |

15 | 19 | 20 | Home 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/course/subscribe/error/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | 4 | export default function SubscriptionErrorPage() { 5 | return ( 6 |
7 |

Something Went Wrong

8 |

Please subscribe again.

9 | 13 | 14 | Home 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/course/subscribe/status/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | 4 | export default function SubscriptionStatusPage() { 5 | return ( 6 |
7 |

Already Subscribed

8 |

You are already subscribed.

9 | 13 | 14 | Home 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/course/subscribe/success/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | 4 | export default function SubscriptionSuccessPage() { 5 | return ( 6 |
7 |

Subscribed

8 |

You're all set!

9 |

10 | I'll send you an email when the course is launched. 11 |

12 | 16 | 17 | Home 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sundaray/next-auth/0fd7be563d04b6326ee9b49b9a677f035488924f/app/favicon.ico -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sundaray/next-auth/0fd7be563d04b6326ee9b49b9a677f035488924f/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sundaray/next-auth/0fd7be563d04b6326ee9b49b9a677f035488924f/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/forgot-password/email-sent/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | 4 | export default function CheckEmailPage() { 5 | return ( 6 |
7 |

Check Inbox

8 |

9 | We've sent a password reset email. 10 |

11 |

12 | Didn't receive the email? Check your spam or junk folder. 13 |

14 | 18 | 19 | Forgot password 20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ForgotPasswordForm } from "@/components/forgot-password-form"; 2 | 3 | export default function ForgotPasswordPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 224 71.4% 4.1%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 224 71.4% 4.1%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 224 71.4% 4.1%; 13 | --primary: 220.9 39.3% 11%; 14 | --primary-foreground: 210 20% 98%; 15 | --secondary: 220 14.3% 95.9%; 16 | --secondary-foreground: 220.9 39.3% 11%; 17 | --muted: 220 14.3% 95.9%; 18 | --muted-foreground: 220 8.9% 46.1%; 19 | --accent: 220 14.3% 95.9%; 20 | --accent-foreground: 220.9 39.3% 11%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 20% 98%; 23 | --border: 220 13% 91%; 24 | --input: 220 13% 91%; 25 | --ring: 224 71.4% 4.1%; 26 | --chart-1: 12 76% 61%; 27 | --chart-2: 173 58% 39%; 28 | --chart-3: 197 37% 24%; 29 | --chart-4: 43 74% 66%; 30 | --chart-5: 27 87% 67%; 31 | --radius: 0.5rem; 32 | } 33 | .dark { 34 | --background: 224 71.4% 4.1%; 35 | --foreground: 210 20% 98%; 36 | --card: 224 71.4% 4.1%; 37 | --card-foreground: 210 20% 98%; 38 | --popover: 224 71.4% 4.1%; 39 | --popover-foreground: 210 20% 98%; 40 | --primary: 210 20% 98%; 41 | --primary-foreground: 220.9 39.3% 11%; 42 | --secondary: 215 27.9% 16.9%; 43 | --secondary-foreground: 210 20% 98%; 44 | --muted: 215 27.9% 16.9%; 45 | --muted-foreground: 217.9 10.6% 64.9%; 46 | --accent: 215 27.9% 16.9%; 47 | --accent-foreground: 210 20% 98%; 48 | --destructive: 0 62.8% 30.6%; 49 | --destructive-foreground: 210 20% 98%; 50 | --border: 215 27.9% 16.9%; 51 | --input: 215 27.9% 16.9%; 52 | --ring: 216 12.2% 83.9%; 53 | --chart-1: 220 70% 50%; 54 | --chart-2: 160 60% 45%; 55 | --chart-3: 30 80% 55%; 56 | --chart-4: 280 65% 60%; 57 | --chart-5: 340 75% 55%; 58 | } 59 | } 60 | 61 | @layer base { 62 | * { 63 | @apply border-border; 64 | } 65 | body { 66 | @apply bg-background text-foreground; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import NextTopLoader from "nextjs-toploader"; 3 | import { navbarLinks } from "@/config/navbar"; 4 | import { MainNav } from "@/components/main-nav"; 5 | 6 | import "@/app/globals.css"; 7 | 8 | const inter = Inter({ 9 | subsets: ["latin"], 10 | }); 11 | 12 | type RootLayoutProps = { 13 | children: React.ReactNode; 14 | }; 15 | 16 | export default async function RootLayout({ children }: RootLayoutProps) { 17 | return ( 18 | 19 | 20 | 21 |
22 | 23 |
24 |
{children}
25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { SubscriptionForm } from "@/components/subscription-form"; 2 | 3 | export default async function Home() { 4 | return ( 5 |
6 |

7 | Next.js(v15) + Auth.js(v5) Course 8 |

9 |

10 | Implementing authentication and authorization in a Next.js app using 11 | Auth.js is no easy task. You might spend weeks and even then there is no 12 | guarantee of success. You're most likely to get stuck trying to 13 | figure out an obscure error, but never really manage to fix it, leaving 14 | all the hard work in vain. 15 |

16 |

17 | Like many developers, I spent weeks, but managed to solve all 18 | authetication challenges after much trial and error. Now I'm 19 | building a course to share these hard-earned lessons, so you can 20 | implement authentication and authorization in just a few hours. 21 |

22 |

23 | In the course, you'll learn how to implement: 24 |

25 |
    26 |
  • Sign in with Google
  • 27 |
  • Sign in with email
  • 28 |
  • Sign in with email and password
  • 29 |
30 |

31 | I am planning to finish the course in the next two weeks. It will be a 32 | text based course and will be released in my{" "} 33 | 39 | personal website 40 | 41 | . 42 |

43 |

44 | If you want to get notified as soon as the course is released, subscribe 45 | below: 46 |

47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/password-reset-actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { parseWithZod } from "@conform-to/zod"; 4 | import { redirect } from "next/navigation"; 5 | import { checkUserExists } from "@/lib/check-user-exists"; 6 | import { sendPasswordResetEmail } from "@/lib/send-password-reset-email"; 7 | import { createPasswordResetToken } from "@/lib/create-password-reset-token"; 8 | import { randomBytes } from "node:crypto"; 9 | import { forgotPasswordSchema, resetPasswordSchema } from "@/app/schema"; 10 | import { verifyPasswordResetToken } from "@/lib/verify-password-reset-token"; 11 | import { resetPassword } from "@/lib/reset-passsord"; 12 | import { VerifyPasswordResetTokenError } from "@/lib/verify-password-reset-token-error"; 13 | import { ResetPasswordError } from "@/lib/reset-password-error"; 14 | import { CheckUserExistsError } from "@/lib/check-user-exists-error"; 15 | 16 | export async function requestPasswordReset( 17 | prevState: unknown, 18 | formData: FormData 19 | ) { 20 | // First, validate the form data 21 | const submission = parseWithZod(formData, { 22 | schema: forgotPasswordSchema, 23 | }); 24 | 25 | if (submission.status !== "success") { 26 | return submission.reply(); 27 | } 28 | 29 | const email = submission.value.email; 30 | const resetPasswordToken = randomBytes(32).toString("hex"); 31 | 32 | let errorOccured = false; 33 | 34 | try { 35 | // Check if user exists 36 | await checkUserExists(email); 37 | 38 | // Create reset token 39 | await createPasswordResetToken(email, resetPasswordToken); 40 | 41 | // Send email 42 | await sendPasswordResetEmail(email, resetPasswordToken); 43 | } catch (error) { 44 | errorOccured = true; 45 | if (error instanceof CheckUserExistsError) { 46 | return submission.reply({ 47 | formErrors: [CheckUserExistsError.getErrorMessage(error.code)], 48 | }); 49 | } 50 | return submission.reply({ 51 | formErrors: ["Something went wrong."], 52 | }); 53 | } finally { 54 | if (!errorOccured) { 55 | redirect("/forgot-password/email-sent"); 56 | } 57 | } 58 | } 59 | 60 | // Reset user password 61 | export async function resetUserPassword( 62 | token: string, 63 | prevState: unknown, 64 | formData: FormData 65 | ) { 66 | const submission = parseWithZod(formData, { 67 | schema: resetPasswordSchema, 68 | }); 69 | 70 | if (submission.status !== "success") { 71 | return submission.reply(); 72 | } 73 | 74 | if (!token) { 75 | return submission.reply({ 76 | formErrors: ["Password reset token not found."], 77 | }); 78 | } 79 | 80 | let errorOccured = false; 81 | 82 | try { 83 | const { email } = await verifyPasswordResetToken(token); 84 | await resetPassword(email, submission.value.newPassword); 85 | } catch (error) { 86 | errorOccured = true; 87 | 88 | if (error instanceof VerifyPasswordResetTokenError) { 89 | return submission.reply({ 90 | formErrors: [VerifyPasswordResetTokenError.getErrorMessage(error.code)], 91 | }); 92 | } else if (error instanceof ResetPasswordError) { 93 | return submission.reply({ 94 | formErrors: [ResetPasswordError.getErrorMessage(error.code)], 95 | }); 96 | } else { 97 | return submission.reply({ 98 | formErrors: ["Something went wrong."], 99 | }); 100 | } 101 | } finally { 102 | if (!errorOccured) { 103 | redirect("/reset-password/success"); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /app/private/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | 3 | export default async function ProtectedPage() { 4 | const session = await auth(); 5 | 6 | return ( 7 |
8 |

User Authenticated

9 |

User email: {session?.user.email}

10 |

User role: {session?.user.role}

11 |

12 | This is a private page, only accessible to authenticated users. 13 |

14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { ResetPasswordForm } from "@/components/reset-passsword-form"; 3 | 4 | export default function ResetPasswordPage() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/reset-password/success/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | 4 | export default function ResetPasswordSuccessPage() { 5 | return ( 6 |
7 |

Password Reset

8 |

9 | Your password has been reset successfully. 10 |

11 | 15 | 16 | Sign in 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/reset-password/verify/page.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from "@/components/icons"; 2 | import Link from "next/link"; 3 | 4 | type TokenVerificationStatus = 5 | | "token-invalid" 6 | | "token-expired" 7 | | "internal-error"; 8 | 9 | type ErrorContent = { 10 | title: string; 11 | message: string; 12 | }; 13 | 14 | const defaultErrorContent: ErrorContent = { 15 | title: "Something Went Wrong", 16 | message: "Please try again.", 17 | }; 18 | 19 | const statusContent: Record = { 20 | "token-invalid": { 21 | title: "Invalid Token", 22 | message: "Please request a new one.", 23 | }, 24 | "token-expired": { 25 | title: "Invalid Token", 26 | message: "Please request a new one.", 27 | }, 28 | "internal-error": { 29 | title: "Something Went Wrong", 30 | message: "Please try again.", 31 | }, 32 | }; 33 | 34 | function isValidErrorType(error: string): error is TokenVerificationStatus { 35 | return error in statusContent; 36 | } 37 | 38 | function ErrorMessage({ error }: { error: string }) { 39 | const content = isValidErrorType(error) 40 | ? statusContent[error] 41 | : defaultErrorContent; 42 | 43 | return ( 44 |
45 |

{content.title}

46 |

{content.message}

47 | 51 | 52 | Sign In 53 | 54 |
55 | ); 56 | } 57 | 58 | export default async function CredentialsEmailVerificationStatusPage({ 59 | searchParams, 60 | }: { 61 | searchParams: Promise<{ [key: string]: string | undefined }>; 62 | }) { 63 | const errorType = (await searchParams).error; 64 | 65 | if (errorType) { 66 | return ; 67 | } 68 | 69 | return ( 70 |
71 |

Email Verified

72 | 76 | 77 | Sign In 78 | 79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /app/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const subscribeSchema = z.object({ 4 | email: z 5 | .string({ required_error: "Email is required" }) 6 | .min(1, "Email is required") 7 | .email("Invalid email"), 8 | }); 9 | 10 | export const signInWithEmailSchema = z.object({ 11 | email: z 12 | .string({ required_error: "Email is required" }) 13 | .min(1, "Email is required") 14 | .email("Invalid email"), 15 | }); 16 | 17 | export const signInWithEmailAndPasswordSchema = z.object({ 18 | email: z 19 | .string({ required_error: "Email is required" }) 20 | .min(1, "Email is required") 21 | .email("Invalid email"), 22 | password: z 23 | .string({ required_error: "Password is required" }) 24 | .min(1, "Password is required"), 25 | }); 26 | 27 | export const forgotPasswordSchema = z.object({ 28 | email: z 29 | .string({ required_error: "Email is required" }) 30 | .min(1, "Email is required") 31 | .email("Invalid email"), 32 | }); 33 | 34 | export const resetPasswordSchema = z 35 | .object({ 36 | newPassword: z 37 | .string({ required_error: "Password is required" }) 38 | .min(1, "Password is required"), 39 | confirmNewPassword: z 40 | .string({ required_error: "Password is required" }) 41 | .min(1, "Password is required"), 42 | }) 43 | .refine((data) => data.newPassword === data.confirmNewPassword, { 44 | message: "Passwords do not match.", 45 | // This tells Zod which field to attach the error to 46 | path: ["confirmNewPassword"], 47 | }); 48 | -------------------------------------------------------------------------------- /app/server/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | 3 | export default async function ServerPage() { 4 | const session = await auth(); 5 | 6 | if (!session) { 7 | return ( 8 |
9 |

User Not Authenticated

10 |

User email: Not available

11 |

User role: Not available

12 |

13 | This is a Server Component. 14 |

15 |
16 | ); 17 | } 18 | return ( 19 |
20 |

User Authenticated

21 |

User email: {session.user.email}

22 |

User role: {session.user.role}

23 |

24 | This is a Server Component. 25 |

26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/signin-actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { signIn, signOut } from "@/auth"; 4 | import { CallbackRouteError } from "@auth/core/errors"; 5 | import { parseWithZod } from "@conform-to/zod"; 6 | import { 7 | signInWithEmailSchema, 8 | signInWithEmailAndPasswordSchema, 9 | } from "@/app/schema"; 10 | import { redirect } from "next/navigation"; 11 | 12 | export async function signInWithEmail(prevState: unknown, formData: FormData) { 13 | // Validate the form data 14 | const submission = parseWithZod(formData, { 15 | schema: signInWithEmailSchema, 16 | }); 17 | 18 | if (submission.status !== "success") { 19 | return submission.reply(); 20 | } 21 | 22 | let errorOccurred = false; 23 | 24 | try { 25 | await signIn("ses", { 26 | email: formData.get("email"), 27 | redirect: false, 28 | }); 29 | } catch (error) { 30 | errorOccurred = true; 31 | return submission.reply({ 32 | formErrors: ["Something went wrong."], 33 | }); 34 | } finally { 35 | if (!errorOccurred) { 36 | redirect("/signin/email-sent"); 37 | } 38 | } 39 | } 40 | 41 | export async function signInWithEmailAndPassword( 42 | from: string, 43 | prevState: unknown, 44 | formData: FormData 45 | ) { 46 | // Validate the form data 47 | const submission = parseWithZod(formData, { 48 | schema: signInWithEmailAndPasswordSchema, 49 | }); 50 | 51 | if (submission.status !== "success") { 52 | return submission.reply(); 53 | } 54 | 55 | let errorOccured = false; 56 | 57 | try { 58 | await signIn("credentials", { 59 | email: formData.get("email"), 60 | password: formData.get("password"), 61 | callbackUrl: from, 62 | redirect: false, 63 | }); 64 | } catch (error) { 65 | if ( 66 | error instanceof CallbackRouteError && 67 | error.cause && 68 | error.cause.err instanceof Error && 69 | error.cause.provider === "credentials" 70 | ) { 71 | errorOccured = true; 72 | 73 | // Check if this is our verification pending case 74 | if (error.cause.err.message === "Verification email sent") { 75 | redirect(`/signin/verify-email`); 76 | } 77 | // If it's not verification pending, return the error message 78 | return submission.reply({ 79 | formErrors: [error.cause.err.message], 80 | }); 81 | } 82 | 83 | // Handle unexpected errors 84 | return submission.reply({ 85 | formErrors: ["Something went wrong."], 86 | }); 87 | } finally { 88 | if (!errorOccured) { 89 | redirect(from); 90 | } 91 | } 92 | } 93 | 94 | export async function signInWithGoogle(from: string) { 95 | try { 96 | await signIn("google", { 97 | redirectTo: from, 98 | }); 99 | } catch (error) { 100 | if (error instanceof Error && error.message === "NEXT_REDIRECT") { 101 | throw error; 102 | } 103 | return { 104 | error: true, 105 | message: "Something went wrong.", 106 | }; 107 | } 108 | } 109 | 110 | export async function handleSignOut() { 111 | await signOut({ redirectTo: "/" }); 112 | } 113 | -------------------------------------------------------------------------------- /app/signin/email-sent/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | 4 | export default function EmailSentPage() { 5 | return ( 6 |
7 |

Check Inbox

8 |

9 | I've sent a sign in link to your email address. 10 |

11 |

12 | Didn't receive the email? Check your spam or junk folder. 13 |

14 | 18 | 19 | Sign In 20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/signin/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { SigninForm } from "@/components/signin-form"; 3 | 4 | export default function SigninPage() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/signin/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | 4 | export default function VerifyEmailPage() { 5 | return ( 6 |
7 |

Verify Your Email

8 |

9 | We've sent a verification link to your email. 10 |

11 |

12 | Didn't receive the email? Check your spam or junk folder. 13 |

14 | 18 | 19 | Sign In 20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | import { ErrorMessage } from "@/lib/error-message"; 4 | import { verifyCredentialEmail } from "@/lib/verify-credential-email"; 5 | import { VerifyCredentialEmailError } from "@/lib/verify-credential-email-error"; 6 | 7 | export default async function VerifyEmailPage({ 8 | searchParams, 9 | }: { 10 | searchParams: Promise<{ token?: string }>; 11 | }) { 12 | const token = (await searchParams).token; 13 | 14 | // First handle the case where no token is provided 15 | if (!token) { 16 | return ; 17 | } 18 | 19 | try { 20 | await verifyCredentialEmail(token); 21 | } catch (error) { 22 | if (error instanceof VerifyCredentialEmailError) { 23 | switch (error.code) { 24 | case "TOKEN_EXPIRED": 25 | return ; 26 | break; 27 | case "TOKEN_INVALID": 28 | return ; 29 | break; 30 | case "INTERNAL_ERROR": 31 | return ; 32 | break; 33 | } 34 | } 35 | } 36 | 37 | // Verification successful 38 | return ( 39 |
40 |

41 | Email Verification Successful 42 |

43 |

44 | Your email has been successfully verified. 45 |

46 | 50 | 51 | Sign In 52 | 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextAuthConfig } from "next-auth"; 2 | 3 | export const authConfig = { 4 | pages: { 5 | signIn: "/signin", 6 | }, 7 | providers: [], 8 | } satisfies NextAuthConfig; 9 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import Google from "next-auth/providers/google"; 3 | import Credentials from "next-auth/providers/credentials"; 4 | import { authConfig } from "@/auth.config"; 5 | import { SupabaseAdapter } from "@auth/supabase-adapter"; 6 | import { customEmailProvider } from "@/lib/custom-email-provider"; 7 | import { authorizeCredentials } from "@/lib/authorize-credentials"; 8 | import { handleAuthRedirect } from "@/lib/handle-auth-redirect"; 9 | import { assignUserRole } from "@/lib/assign-user-role"; 10 | import { handleAuthJwt } from "@/lib/handle-auth-jwt"; 11 | import { handleAuthSession } from "@/lib/handle-auth-session"; 12 | 13 | export const { handlers, signIn, signOut, auth } = NextAuth({ 14 | ...authConfig, 15 | debug: false, 16 | session: { strategy: "jwt" }, 17 | events: { createUser: assignUserRole }, 18 | callbacks: { 19 | redirect: handleAuthRedirect, 20 | jwt: handleAuthJwt, 21 | session: handleAuthSession, 22 | }, 23 | providers: [ 24 | Google({ allowDangerousEmailAccountLinking: true }), 25 | customEmailProvider(), 26 | Credentials({ 27 | authorize: authorizeCredentials, 28 | }), 29 | ], 30 | adapter: SupabaseAdapter({ 31 | url: process.env.SUPABASE_URL!, 32 | secret: process.env.SUPABASE_SERVICE_ROLE_KEY!, 33 | }), 34 | pages: { error: "/auth-error" }, 35 | }); 36 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "gray", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/email-signin-template.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button } from "@react-email/button"; 3 | import { Html } from "@react-email/html"; 4 | import { Tailwind } from "@react-email/tailwind"; 5 | import { Text } from "@react-email/text"; 6 | 7 | export function EmailSignInTemplate({ url }: { url: string }) { 8 | return ( 9 | 10 | 11 | Hey, 12 | 13 | Click the link below to sign in to your account: 14 | 15 | 21 | 22 | Note that the link will expire in 1 hour. 23 | 24 | 25 | If you did not try to log in to your account, you can safely ignore 26 | this email. 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/email-verification-template.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button } from "@react-email/button"; 3 | import { Html } from "@react-email/html"; 4 | import { Tailwind } from "@react-email/tailwind"; 5 | import { Text } from "@react-email/text"; 6 | 7 | export function EmailVerificationTemplate({ 8 | verificationUrl, 9 | }: { 10 | verificationUrl: string; 11 | }) { 12 | return ( 13 | 14 | 15 | Hey, 16 | 17 | Click the link below to verify your account: 18 | 19 | 25 | 26 | Note that the link will expire in 1 hour. 27 | 28 | 29 | 30 | If you did not try to log in to your account, you can safely ignore 31 | this email. 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/forgot-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Label } from "@/components/ui/label"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Button } from "@/components/ui/button"; 6 | import { useForm } from "@conform-to/react"; 7 | import { parseWithZod } from "@conform-to/zod"; 8 | import { useActionState } from "react"; 9 | import { requestPasswordReset } from "@/app/password-reset-actions"; 10 | import { forgotPasswordSchema } from "@/app/schema"; 11 | 12 | export function ForgotPasswordForm() { 13 | const [lastResult, action, isPending] = useActionState( 14 | requestPasswordReset, 15 | undefined 16 | ); 17 | 18 | const [form, fields] = useForm({ 19 | lastResult, 20 | onValidate({ formData }) { 21 | return parseWithZod(formData, { schema: forgotPasswordSchema }); 22 | }, 23 | }); 24 | 25 | return ( 26 |
27 |

28 | Forgot Password? 29 |

30 |
37 | {form.errors && ( 38 |
39 | {form.errors} 40 |
41 | )} 42 | 43 |
44 | 45 | 51 | {fields.email.errors && ( 52 |
53 | {fields.email.errors} 54 |
55 | )} 56 |
57 | 58 | 61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ArrowLeft, 3 | Eye, 4 | EyeOff, 5 | LogOut, 6 | Loader, 7 | LucideProps, 8 | } from "lucide-react"; 9 | 10 | export const Icons = { 11 | arrowLeft: ArrowLeft, 12 | eye: Eye, 13 | eyeOff: EyeOff, 14 | logOut: LogOut, 15 | loader: Loader, 16 | google: ({ ...props }: LucideProps) => ( 17 | 42 | ), 43 | }; 44 | -------------------------------------------------------------------------------- /components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | 4 | import { NavItem } from "@/components/nav-item"; 5 | import { MobileNav } from "@/components/mobile-nav"; 6 | import { UserAccountNav } from "@/components/user-account-nav"; 7 | 8 | type NavItemType = { 9 | title: string; 10 | href: string; 11 | }; 12 | 13 | type MainNavProps = { 14 | items: NavItemType[]; // Array of navigation items 15 | }; 16 | 17 | export function MainNav({ items }: MainNavProps) { 18 | return ( 19 |
20 |
21 | 22 | 23 | NextAuth 24 | 25 |
26 | {items?.length ? ( 27 | 36 | ) : null} 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /components/mobile-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createContext, Suspense, useContext, useEffect, useRef } from "react"; 4 | import Link from "next/link"; 5 | import { usePathname, useSearchParams } from "next/navigation"; 6 | import { 7 | Dialog, 8 | DialogBackdrop, 9 | DialogPanel, 10 | TransitionChild, 11 | } from "@headlessui/react"; 12 | import { motion } from "framer-motion"; 13 | import { create } from "zustand"; 14 | 15 | import { navbarLinks } from "@/config/navbar"; 16 | import { cn } from "@/lib/utils"; 17 | import { Button } from "@/components/ui/button"; 18 | 19 | const IsInsideMobileNavigationContext = createContext(false); 20 | 21 | function MobileNavigationDialog({ 22 | isOpen, 23 | close, 24 | }: { 25 | isOpen: boolean; 26 | close: () => void; 27 | }) { 28 | const pathname = usePathname(); 29 | const searchParams = useSearchParams(); 30 | const initialPathname = useRef(pathname).current; 31 | const initialSearchParams = useRef(searchParams).current; 32 | 33 | useEffect(() => { 34 | if (pathname !== initialPathname || searchParams !== initialSearchParams) { 35 | close(); 36 | } 37 | }, [pathname, searchParams, close, initialPathname, initialSearchParams]); 38 | 39 | function onClickDialog(event: React.MouseEvent) { 40 | if (!(event.target instanceof HTMLElement)) { 41 | return; 42 | } 43 | 44 | const link = event.target.closest("a"); 45 | if ( 46 | link && 47 | link.pathname + link.search + link.hash === 48 | window.location.pathname + window.location.search + window.location.hash 49 | ) { 50 | close(); 51 | } 52 | } 53 | 54 | const containerVariants = { 55 | hidden: { opacity: 0 }, 56 | visible: { 57 | opacity: 1, 58 | transition: { 59 | staggerChildren: 0.1, 60 | delayChildren: 0.2, 61 | }, 62 | }, 63 | }; 64 | 65 | const itemVariants = { 66 | hidden: { x: -20, opacity: 0 }, 67 | visible: { 68 | x: 0, 69 | opacity: 1, 70 | }, 71 | }; 72 | 73 | return ( 74 | 80 | 84 | 85 | 86 | 90 | 112 | 113 | 114 | 115 | 116 | ); 117 | } 118 | 119 | export function useIsInsideMobileNavigation() { 120 | return useContext(IsInsideMobileNavigationContext); 121 | } 122 | 123 | export const useMobileNavigationStore = create<{ 124 | isOpen: boolean; 125 | open: () => void; 126 | close: () => void; 127 | toggle: () => void; 128 | }>()((set) => ({ 129 | isOpen: false, 130 | open: () => set({ isOpen: true }), 131 | close: () => set({ isOpen: false }), 132 | toggle: () => set((state) => ({ isOpen: !state.isOpen })), 133 | })); 134 | 135 | export function MobileNav() { 136 | const isInsideMobileNavigation = useIsInsideMobileNavigation(); 137 | const { isOpen, toggle, close } = useMobileNavigationStore(); 138 | 139 | const pathname = usePathname(); 140 | 141 | useEffect(() => { 142 | close(); 143 | }, [pathname, close]); 144 | 145 | const barVariants = { 146 | closed: (custom: number) => ({ 147 | y: custom * 8, 148 | rotate: 0, 149 | opacity: 1, 150 | transition: { 151 | y: { delay: 0.2, duration: 0.2 }, 152 | rotate: { duration: 0.2 }, 153 | opacity: { delay: 0.2, duration: 0.1 }, 154 | }, 155 | }), 156 | open: (custom: number) => ({ 157 | y: 0, 158 | rotate: custom * 45, 159 | opacity: custom === 0 ? 0 : 1, 160 | transition: { 161 | y: { type: "spring", stiffness: 300, damping: 15, duration: 0.2 }, 162 | rotate: { delay: 0.2, duration: 0.2 }, 163 | opacity: { delay: 0.1, duration: 0.2 }, 164 | }, 165 | }), 166 | }; 167 | 168 | return ( 169 | 170 | 202 | {!isInsideMobileNavigation && ( 203 | 204 | 205 | 206 | )} 207 | 208 | ); 209 | } 210 | -------------------------------------------------------------------------------- /components/nav-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | type NavItemProps = { 9 | href: string; 10 | title: string; 11 | }; 12 | 13 | export function NavItem({ href, title }: NavItemProps) { 14 | const pathname = usePathname(); 15 | const isActive = href === pathname; 16 | 17 | return ( 18 | 25 | {title} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/password-reset-email-template.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button } from "@react-email/button"; 3 | import { Html } from "@react-email/html"; 4 | import { Tailwind } from "@react-email/tailwind"; 5 | import { Text } from "@react-email/text"; 6 | 7 | export function PasswordResetTemplate({ resetUrl }: { resetUrl: string }) { 8 | return ( 9 | 10 | 11 | Hello, 12 | 13 | We received a request to reset your password. Click the link below to 14 | create a new password. 15 | 16 | 22 | 23 | Note that the link will epire in 1 hour. 24 | 25 | 26 | If you didn't request a password reset, you can safely ignore 27 | this email. This link will expire in 24 hours. 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /components/reset-passsword-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Label } from "@/components/ui/label"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Button } from "@/components/ui/button"; 6 | import { useForm } from "@conform-to/react"; 7 | import { useSearchParams } from "next/navigation"; 8 | import { parseWithZod } from "@conform-to/zod"; 9 | import { useActionState } from "react"; 10 | import { resetUserPassword } from "@/app/password-reset-actions"; 11 | import { resetPasswordSchema } from "@/app/schema"; 12 | 13 | export function ResetPasswordForm() { 14 | const searchParams = useSearchParams(); 15 | const token = searchParams.get("token"); 16 | 17 | // Bind the token to the action 18 | const boundResetPassword = resetUserPassword.bind(null, token!); 19 | const [lastResult, action, isPending] = useActionState( 20 | boundResetPassword, 21 | undefined 22 | ); 23 | 24 | const [form, fields] = useForm({ 25 | lastResult, 26 | onValidate({ formData }) { 27 | return parseWithZod(formData, { schema: resetPasswordSchema }); 28 | }, 29 | }); 30 | 31 | return ( 32 |
33 |

Reset Password

34 |
41 | {form.errors && ( 42 |
43 | {form.errors} 44 |
45 | )} 46 | 47 |
48 | 51 | 52 | {fields.newPassword.errors && ( 53 |
54 | {fields.newPassword.errors} 55 |
56 | )} 57 |
58 |
59 | 60 | 65 | {fields.confirmNewPassword.errors && ( 66 |
67 | {fields.confirmNewPassword.errors} 68 |
69 | )} 70 |
71 | 74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /components/sign-out.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { handleSignOut } from "@/app/signin-actions"; 6 | import { Icons } from "@/components/icons"; 7 | 8 | export function SignOut() { 9 | const [isLoading, setIsLoading] = useState(false); 10 | 11 | const handleClick = async () => { 12 | try { 13 | setIsLoading(true); 14 | await handleSignOut(); 15 | } catch (error) { 16 | // Implement React Hot Toast 17 | console.error("Unable to sign out:", error); 18 | } finally { 19 | setIsLoading(false); 20 | } 21 | }; 22 | 23 | return ( 24 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/signin-email-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useActionState } from "react"; 4 | import { useForm } from "@conform-to/react"; 5 | import { parseWithZod } from "@conform-to/zod"; 6 | import { Icons } from "@/components/icons"; 7 | import { Button } from "@/components/ui/button"; 8 | import { Input } from "@/components/ui/input"; 9 | import { Label } from "@/components/ui/label"; 10 | import { signInWithEmail } from "@/app/signin-actions"; 11 | import { signInWithEmailSchema } from "@/app/schema"; 12 | 13 | export function SignInEmailForm() { 14 | const [lastResult, formAction, isPending] = useActionState( 15 | signInWithEmail, 16 | undefined 17 | ); 18 | 19 | const [form, fields] = useForm({ 20 | lastResult, 21 | onValidate({ formData }) { 22 | return parseWithZod(formData, { schema: signInWithEmailSchema }); 23 | }, 24 | }); 25 | 26 | return ( 27 |
34 | {form.errors && ( 35 |
36 | {form.errors} 37 |
38 | )} 39 |
40 | 43 | 50 |
{fields.email.errors}
51 |
52 | 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /components/signin-email-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Input } from "@/components/ui/input"; 6 | import { Label } from "@/components/ui/label"; 7 | import { Icons } from "@/components/icons"; 8 | import { useForm } from "@conform-to/react"; 9 | import { parseWithZod } from "@conform-to/zod"; 10 | import { useState } from "react"; 11 | import { useActionState } from "react"; 12 | import { signInWithEmailAndPassword } from "@/app/signin-actions"; 13 | import { signInWithEmailAndPasswordSchema } from "@/app/schema"; 14 | 15 | export function SignInEmailPasswordForm({ from }: { from: string }) { 16 | const boundSignInWithEmailAndPassword = signInWithEmailAndPassword.bind( 17 | null, 18 | from 19 | ); 20 | 21 | const [lastResult, formAction, isPending] = useActionState( 22 | boundSignInWithEmailAndPassword, 23 | undefined 24 | ); 25 | 26 | const [form, fields] = useForm({ 27 | lastResult, 28 | onValidate({ formData }) { 29 | return parseWithZod(formData, { 30 | schema: signInWithEmailAndPasswordSchema, 31 | }); 32 | }, 33 | }); 34 | 35 | const [isPasswordVisible, setIsPasswordVisible] = useState(false); 36 | 37 | function togglePasswordVisibility() { 38 | setIsPasswordVisible((prevState) => !prevState); 39 | } 40 | 41 | return ( 42 |
43 |
44 | {form.errors && ( 45 |
46 | {form.errors} 47 |
48 | )} 49 |
50 | 53 | 60 |
{fields.email.errors}
61 |
62 |
63 |
64 | 67 |
68 | 72 | Forgot password? 73 | 74 |
75 |
76 |
77 | 83 | 96 |
97 |
{fields.password.errors}
98 |
99 | 109 |
110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /components/signin-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSearchParams } from "next/navigation"; 4 | import { SignInGoogleForm } from "@/components/signin-google-form"; 5 | import { SignInEmailForm } from "@/components/signin-email-form"; 6 | import { SignInEmailPasswordForm } from "@/components/signin-email-password-form"; 7 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 8 | 9 | export function SigninForm() { 10 | const searchParams = useSearchParams(); 11 | const from = searchParams.get("from") || "/"; 12 | 13 | return ( 14 |
15 |

16 | Sign in to your account 17 |

18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 | Or continue with 26 |
27 |
28 | 29 | 30 | Email Link 31 | Password 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /components/signin-google-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useActionState } from "react"; 4 | 5 | import { signInWithGoogle } from "@/app/signin-actions"; 6 | import { Icons } from "@/components/icons"; 7 | 8 | export function SignInGoogleForm({ from }: { from: string }) { 9 | const boundGoogleSignIn = signInWithGoogle.bind(null, from); 10 | 11 | const [formState, formAction, isPending] = useActionState( 12 | boundGoogleSignIn, 13 | undefined 14 | ); 15 | 16 | return ( 17 |
18 | {formState !== undefined && formState?.error && ( 19 |
20 | {formState?.message} 21 |
22 | )} 23 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /components/subscribe-template.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button } from "@react-email/button"; 3 | import { Html } from "@react-email/html"; 4 | import { Tailwind } from "@react-email/tailwind"; 5 | import { Text } from "@react-email/text"; 6 | 7 | export function SubscribeTemplate({ url }: { url: string }) { 8 | return ( 9 | 10 | 11 | Hey, 12 | 13 | Click the link below to subscribe: 14 | 15 | 21 | 22 | If you did not try to subscribe, you can safely ignore this email. 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /components/subscription-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useActionState } from "react"; 4 | import { useForm } from "@conform-to/react"; 5 | import { parseWithZod } from "@conform-to/zod"; 6 | import { Icons } from "@/components/icons"; 7 | import { Button } from "@/components/ui/button"; 8 | import { Input } from "@/components/ui/input"; 9 | import { Label } from "@/components/ui/label"; 10 | import { subscribe } from "@/lib/subscribe-actions"; 11 | import { subscribeSchema } from "@/app/schema"; 12 | 13 | export function SubscriptionForm() { 14 | const [lastResult, formAction, isPending] = useActionState( 15 | subscribe, 16 | undefined 17 | ); 18 | 19 | const [form, fields] = useForm({ 20 | lastResult, 21 | onValidate({ formData }) { 22 | return parseWithZod(formData, { schema: subscribeSchema }); 23 | }, 24 | }); 25 | 26 | return ( 27 |
28 |

Subscribe

29 |

30 | Be first to know when the course launches. 31 |

32 |
38 | {form.errors && ( 39 |
40 | {form.errors} 41 |
42 | )} 43 |
44 | 47 |
48 | 56 | 69 |
70 |
71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /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/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 gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /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 { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ); 18 | } 19 | ); 20 | Input.displayName = "Input"; 21 | 22 | export { Input }; 23 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Tabs = TabsPrimitive.Root; 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | TabsList.displayName = TabsPrimitive.List.displayName; 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )); 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )); 53 | TabsContent.displayName = TabsPrimitive.Content.displayName; 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 56 | -------------------------------------------------------------------------------- /components/user-account-nav.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { auth } from "@/auth"; 3 | import { buttonVariants } from "@/components/ui/button"; 4 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 5 | import Link from "next/link"; 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { SignOut } from "@/components/sign-out"; 13 | 14 | export async function UserAccountNav() { 15 | const session = await auth(); 16 | 17 | if (!session?.user) { 18 | return ( 19 | 25 | Sign in 26 | 27 | ); 28 | } 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | {session?.user.email!.slice(0, 2).toUpperCase()} 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /config/navbar.ts: -------------------------------------------------------------------------------- 1 | export const navbarLinks = { 2 | main: [ 3 | { title: "Home", href: "/" }, 4 | { title: "Client", href: "/client" }, 5 | { title: "Server", href: "/server" }, 6 | { title: "Private", href: "/private" }, 7 | { title: "Admin", href: "/admin" }, 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /database.types.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json | undefined } 7 | | Json[] 8 | 9 | export type Database = { 10 | next_auth: { 11 | Tables: { 12 | accounts: { 13 | Row: { 14 | access_token: string | null 15 | expires_at: number | null 16 | id: string 17 | id_token: string | null 18 | oauth_token: string | null 19 | oauth_token_secret: string | null 20 | provider: string 21 | providerAccountId: string 22 | refresh_token: string | null 23 | scope: string | null 24 | session_state: string | null 25 | token_type: string | null 26 | type: string 27 | userId: string | null 28 | } 29 | Insert: { 30 | access_token?: string | null 31 | expires_at?: number | null 32 | id?: string 33 | id_token?: string | null 34 | oauth_token?: string | null 35 | oauth_token_secret?: string | null 36 | provider: string 37 | providerAccountId: string 38 | refresh_token?: string | null 39 | scope?: string | null 40 | session_state?: string | null 41 | token_type?: string | null 42 | type: string 43 | userId?: string | null 44 | } 45 | Update: { 46 | access_token?: string | null 47 | expires_at?: number | null 48 | id?: string 49 | id_token?: string | null 50 | oauth_token?: string | null 51 | oauth_token_secret?: string | null 52 | provider?: string 53 | providerAccountId?: string 54 | refresh_token?: string | null 55 | scope?: string | null 56 | session_state?: string | null 57 | token_type?: string | null 58 | type?: string 59 | userId?: string | null 60 | } 61 | Relationships: [ 62 | { 63 | foreignKeyName: "accounts_userId_fkey" 64 | columns: ["userId"] 65 | isOneToOne: false 66 | referencedRelation: "users" 67 | referencedColumns: ["id"] 68 | }, 69 | ] 70 | } 71 | reset_tokens: { 72 | Row: { 73 | expires: string 74 | identifier: string | null 75 | token: string 76 | } 77 | Insert: { 78 | expires: string 79 | identifier?: string | null 80 | token: string 81 | } 82 | Update: { 83 | expires?: string 84 | identifier?: string | null 85 | token?: string 86 | } 87 | Relationships: [] 88 | } 89 | sessions: { 90 | Row: { 91 | expires: string 92 | id: string 93 | sessionToken: string 94 | userId: string | null 95 | } 96 | Insert: { 97 | expires: string 98 | id?: string 99 | sessionToken: string 100 | userId?: string | null 101 | } 102 | Update: { 103 | expires?: string 104 | id?: string 105 | sessionToken?: string 106 | userId?: string | null 107 | } 108 | Relationships: [ 109 | { 110 | foreignKeyName: "sessions_userId_fkey" 111 | columns: ["userId"] 112 | isOneToOne: false 113 | referencedRelation: "users" 114 | referencedColumns: ["id"] 115 | }, 116 | ] 117 | } 118 | subscribers: { 119 | Row: { 120 | created_at: string 121 | email: string 122 | id: string 123 | } 124 | Insert: { 125 | created_at?: string 126 | email: string 127 | id?: string 128 | } 129 | Update: { 130 | created_at?: string 131 | email?: string 132 | id?: string 133 | } 134 | Relationships: [] 135 | } 136 | users: { 137 | Row: { 138 | credentials_email_verified: boolean | null 139 | email: string | null 140 | emailVerified: string | null 141 | id: string 142 | image: string | null 143 | name: string | null 144 | password: string | null 145 | role: string 146 | } 147 | Insert: { 148 | credentials_email_verified?: boolean | null 149 | email?: string | null 150 | emailVerified?: string | null 151 | id?: string 152 | image?: string | null 153 | name?: string | null 154 | password?: string | null 155 | role?: string 156 | } 157 | Update: { 158 | credentials_email_verified?: boolean | null 159 | email?: string | null 160 | emailVerified?: string | null 161 | id?: string 162 | image?: string | null 163 | name?: string | null 164 | password?: string | null 165 | role?: string 166 | } 167 | Relationships: [] 168 | } 169 | verification_tokens: { 170 | Row: { 171 | expires: string 172 | identifier: string | null 173 | token: string 174 | } 175 | Insert: { 176 | expires: string 177 | identifier?: string | null 178 | token: string 179 | } 180 | Update: { 181 | expires?: string 182 | identifier?: string | null 183 | token?: string 184 | } 185 | Relationships: [] 186 | } 187 | } 188 | Views: { 189 | [_ in never]: never 190 | } 191 | Functions: { 192 | uid: { 193 | Args: Record 194 | Returns: string 195 | } 196 | } 197 | Enums: { 198 | [_ in never]: never 199 | } 200 | CompositeTypes: { 201 | [_ in never]: never 202 | } 203 | } 204 | } 205 | 206 | type PublicSchema = Database[Extract] 207 | 208 | export type Tables< 209 | PublicTableNameOrOptions extends 210 | | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) 211 | | { schema: keyof Database }, 212 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 213 | ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 214 | Database[PublicTableNameOrOptions["schema"]]["Views"]) 215 | : never = never, 216 | > = PublicTableNameOrOptions extends { schema: keyof Database } 217 | ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 218 | Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { 219 | Row: infer R 220 | } 221 | ? R 222 | : never 223 | : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & 224 | PublicSchema["Views"]) 225 | ? (PublicSchema["Tables"] & 226 | PublicSchema["Views"])[PublicTableNameOrOptions] extends { 227 | Row: infer R 228 | } 229 | ? R 230 | : never 231 | : never 232 | 233 | export type TablesInsert< 234 | PublicTableNameOrOptions extends 235 | | keyof PublicSchema["Tables"] 236 | | { schema: keyof Database }, 237 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 238 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 239 | : never = never, 240 | > = PublicTableNameOrOptions extends { schema: keyof Database } 241 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 242 | Insert: infer I 243 | } 244 | ? I 245 | : never 246 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 247 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 248 | Insert: infer I 249 | } 250 | ? I 251 | : never 252 | : never 253 | 254 | export type TablesUpdate< 255 | PublicTableNameOrOptions extends 256 | | keyof PublicSchema["Tables"] 257 | | { schema: keyof Database }, 258 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 259 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 260 | : never = never, 261 | > = PublicTableNameOrOptions extends { schema: keyof Database } 262 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 263 | Update: infer U 264 | } 265 | ? U 266 | : never 267 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 268 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 269 | Update: infer U 270 | } 271 | ? U 272 | : never 273 | : never 274 | 275 | export type Enums< 276 | PublicEnumNameOrOptions extends 277 | | keyof PublicSchema["Enums"] 278 | | { schema: keyof Database }, 279 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } 280 | ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] 281 | : never = never, 282 | > = PublicEnumNameOrOptions extends { schema: keyof Database } 283 | ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] 284 | : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] 285 | ? PublicSchema["Enums"][PublicEnumNameOrOptions] 286 | : never 287 | 288 | export type CompositeTypes< 289 | PublicCompositeTypeNameOrOptions extends 290 | | keyof PublicSchema["CompositeTypes"] 291 | | { schema: keyof Database }, 292 | CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { 293 | schema: keyof Database 294 | } 295 | ? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] 296 | : never = never, 297 | > = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } 298 | ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] 299 | : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] 300 | ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] 301 | : never 302 | -------------------------------------------------------------------------------- /hooks/use-current-session.ts: -------------------------------------------------------------------------------- 1 | import { Session } from "next-auth"; 2 | import { getSession } from "next-auth/react"; 3 | import { useState, useEffect } from "react"; 4 | 5 | type AuthStatus = "loading" | "authenticated" | "unauthenticated"; 6 | 7 | export function useCurrentSession() { 8 | const [session, setSession] = useState(null); 9 | const [status, setStatus] = useState("loading"); 10 | 11 | useEffect(() => { 12 | async function retrieveSession() { 13 | try { 14 | const sessionData = await getSession(); 15 | if (sessionData) { 16 | setSession(sessionData); 17 | setStatus("authenticated"); 18 | return; 19 | } 20 | setStatus("unauthenticated"); 21 | } catch (error) { 22 | setSession(null); 23 | setStatus("unauthenticated"); 24 | } 25 | } 26 | 27 | retrieveSession(); 28 | }, []); 29 | 30 | return { session, status }; 31 | } 32 | -------------------------------------------------------------------------------- /lib/assign-user-role.ts: -------------------------------------------------------------------------------- 1 | import { supabase } from "@/lib/supabase"; 2 | import { User } from "next-auth"; 3 | 4 | export async function assignUserRole({ user }: { user: User }) { 5 | const ADMIN_EMAILS = ["rawgrittt@gmail.com"]; 6 | 7 | // Determine if this email should have admin privileges 8 | const role = ADMIN_EMAILS.includes(user.email!) ? "admin" : "user"; 9 | 10 | try { 11 | const { error } = await supabase 12 | .schema("next_auth") 13 | .from("users") 14 | .update({ role: role }) 15 | .eq("email", user.email!); 16 | 17 | if (error) { 18 | console.error("Error updating user role:", error); 19 | throw error; 20 | } 21 | } catch (error) { 22 | console.log("Failed to assign role to new user:", error); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/authorize-credentials-error.ts: -------------------------------------------------------------------------------- 1 | export class AuthorizeCredentialsError extends Error { 2 | public static readonly errorMessages = { 3 | EMAIL_SENT: "Verification email sent.", 4 | INVALID_CREDENTIALS: "Invalid email or password.", 5 | INTERNAL_ERROR: "Something went wrong.", 6 | } as const; 7 | 8 | public readonly code: keyof typeof AuthorizeCredentialsError.errorMessages; 9 | 10 | constructor(code: keyof typeof AuthorizeCredentialsError.errorMessages) { 11 | const message = AuthorizeCredentialsError.errorMessages[code]; 12 | super(message); 13 | 14 | this.code = code; 15 | this.name = "AuthorizeCredentialsError"; 16 | } 17 | 18 | public static getErrorMessage( 19 | code: keyof typeof AuthorizeCredentialsError.errorMessages 20 | ) { 21 | return this.errorMessages[code]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/authorize-credentials.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import { checkUserExists } from "@/lib/check-user-exists"; 3 | import { createUser } from "@/lib/create-user"; 4 | import { sendCredentialEmailVerificationEmail } from "@/lib/send-credentials-email-verification-email"; 5 | import { checkCredentialsEmailVerificationStatus } from "@/lib/check-credentials-email-verification-status"; 6 | import { AuthorizeCredentialsError } from "@/lib/authorize-credentials-error"; 7 | import { User } from "next-auth"; 8 | 9 | export async function authorizeCredentials( 10 | credentials: Partial> 11 | ): Promise { 12 | const email = credentials.email as string; 13 | const password = credentials.password as string; 14 | 15 | // Try to find an existing user 16 | const user = await checkUserExists(email); 17 | 18 | const verificationToken = crypto.randomUUID(); 19 | 20 | // Handle new user signup flow 21 | if (!user) { 22 | const [userCreationStatus, emailSendingStatus] = await Promise.all([ 23 | createUser(email, password, verificationToken), 24 | sendCredentialEmailVerificationEmail(email, verificationToken), 25 | ]); 26 | 27 | if (userCreationStatus.success && emailSendingStatus.success) { 28 | throw new AuthorizeCredentialsError("EMAIL_SENT"); 29 | } 30 | } 31 | 32 | // Verify password 33 | const passwordsMatch = await bcrypt.compare(password, user!.password!); 34 | 35 | if (!passwordsMatch) { 36 | throw new AuthorizeCredentialsError("INVALID_CREDENTIALS"); 37 | } 38 | 39 | // Check email verification 40 | const verificationStatus = 41 | await checkCredentialsEmailVerificationStatus(email); 42 | 43 | if (!verificationStatus.verified) { 44 | await sendCredentialEmailVerificationEmail(email, verificationToken); 45 | throw new AuthorizeCredentialsError("EMAIL_SENT"); 46 | } 47 | 48 | return { 49 | email: user?.email, 50 | role: user?.role, 51 | } as User; 52 | } 53 | -------------------------------------------------------------------------------- /lib/aws.ts: -------------------------------------------------------------------------------- 1 | import { SESClient } from "@aws-sdk/client-ses"; 2 | 3 | const sesClient = new SESClient({ 4 | region: process.env.AWS_REGION!, 5 | credentials: { 6 | accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 7 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 8 | }, 9 | }); 10 | 11 | export { sesClient }; 12 | -------------------------------------------------------------------------------- /lib/check-credentials-email-verification-status.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { supabase } from "@/lib/supabase"; 3 | 4 | export async function checkCredentialsEmailVerificationStatus(email: string) { 5 | // First, get the user's verification status from the users table 6 | const { data: user, error: userError } = await supabase 7 | .schema("next_auth") 8 | .from("users") 9 | .select("credentials_email_verified") 10 | .eq("email", email) 11 | .single(); 12 | 13 | if (userError) { 14 | console.error("Error checking user verification status:", userError); 15 | throw new Error("Failed to check credentials email verification status"); 16 | } 17 | 18 | // If credentials email is already verified, we're good to go 19 | if (user.credentials_email_verified) { 20 | return { verified: true }; 21 | } 22 | 23 | // At this point, the user exists, but the email is not confirmed 24 | // Check the expiry of the verification token 25 | const { data: token, error: tokenError } = await supabase 26 | .schema("next_auth") 27 | .from("verification_tokens") 28 | .select("expires") 29 | .eq("identifier", email) 30 | .single(); 31 | 32 | if (tokenError) { 33 | console.error("Error checking verification token:", tokenError); 34 | throw new Error("Failed to check verification token"); 35 | } 36 | 37 | // If the token has expired, we should allow sending a new one 38 | if (new Date(token.expires) < new Date()) { 39 | return { verified: false, canResendVerificationMail: true }; 40 | } 41 | 42 | // Token exists and hasn't expired 43 | return { 44 | verified: false, 45 | canResendVerificationMail: false, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /lib/check-subscriber-exists-error.ts: -------------------------------------------------------------------------------- 1 | export class CheckSubscriberExistsError extends Error { 2 | public static readonly errorMessages = { 3 | USER_NOT_FOUND: "User not found.", 4 | INTERNAL_ERROR: "Something went wrong.", 5 | } as const; 6 | 7 | public readonly code: keyof typeof CheckSubscriberExistsError.errorMessages; 8 | 9 | constructor(code: keyof typeof CheckSubscriberExistsError.errorMessages) { 10 | const message = CheckSubscriberExistsError.errorMessages[code]; 11 | super(message); 12 | 13 | this.code = code; 14 | this.name = "CheckUserExistsError"; 15 | } 16 | 17 | public static getErrorMessage( 18 | code: keyof typeof CheckSubscriberExistsError.errorMessages 19 | ) { 20 | return this.errorMessages[code]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/check-subscriber-exists.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { supabase } from "@/lib/supabase"; 3 | import { CheckSubscriberExistsError } from "@/lib/check-subscriber-exists-error"; 4 | 5 | export async function checkSubscriberExists(email: string) { 6 | try { 7 | const { error: userError } = await supabase 8 | .schema("next_auth") 9 | .from("subscribers") 10 | .select("*") 11 | .eq("email", email) 12 | .single(); 13 | 14 | if (userError) { 15 | console.log("User exists error: ", userError); 16 | throw new CheckSubscriberExistsError("USER_NOT_FOUND"); 17 | } 18 | return { 19 | success: true, 20 | }; 21 | } catch (error) { 22 | return { 23 | error: true, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/check-user-exists-error.ts: -------------------------------------------------------------------------------- 1 | export class CheckUserExistsError extends Error { 2 | public static readonly errorMessages = { 3 | USER_NOT_FOUND: "User not found.", 4 | INTERNAL_ERROR: "Something went wrong.", 5 | } as const; 6 | 7 | public readonly code: keyof typeof CheckUserExistsError.errorMessages; 8 | 9 | constructor(code: keyof typeof CheckUserExistsError.errorMessages) { 10 | const message = CheckUserExistsError.errorMessages[code]; 11 | super(message); 12 | 13 | this.code = code; 14 | this.name = "CheckUserExistsError"; 15 | } 16 | 17 | public static getErrorMessage( 18 | code: keyof typeof CheckUserExistsError.errorMessages 19 | ) { 20 | return this.errorMessages[code]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/check-user-exists.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { supabase } from "@/lib/supabase"; 3 | 4 | export async function checkUserExists(email: string) { 5 | try { 6 | const { data, error } = await supabase 7 | .schema("next_auth") 8 | .from("users") 9 | .select("*") 10 | .eq("email", email) 11 | .single(); 12 | 13 | if (error) { 14 | console.log("Failed to check user existence:", error.message); 15 | } 16 | 17 | return data; 18 | } catch (error) { 19 | console.error("Unexpected error while checking user existence:", error); 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/create-password-reset-token-error.ts: -------------------------------------------------------------------------------- 1 | export class CreatePasswordResetTokenError extends Error { 2 | public static readonly errorMessages = { 3 | TOKEN_CREATION_FAILED: "Failed to create password reset token.", 4 | TOKEN_DELETION_FAILED: "Failed to delete password reset token.", 5 | } as const; 6 | 7 | public readonly code: keyof typeof CreatePasswordResetTokenError.errorMessages; 8 | 9 | constructor(code: keyof typeof CreatePasswordResetTokenError.errorMessages) { 10 | const message = CreatePasswordResetTokenError.errorMessages[code]; 11 | super(message); 12 | 13 | this.code = code; 14 | this.name = "CreatePasswordResetTokenError"; 15 | } 16 | 17 | public static getErrorMessage( 18 | code: keyof typeof CreatePasswordResetTokenError.errorMessages 19 | ) { 20 | return this.errorMessages[code]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/create-password-reset-token.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { supabase } from "@/lib/supabase"; 3 | import { CreatePasswordResetTokenError } from "@/lib/create-password-reset-token-error"; 4 | 5 | export async function createPasswordResetToken( 6 | email: string, 7 | resetPasswordToken: string 8 | ) { 9 | try { 10 | // Delete existing token 11 | const { error: deleteTokenError } = await supabase 12 | .schema("next_auth") 13 | .from("reset_tokens") 14 | .delete() 15 | .eq("identifier", email); 16 | 17 | if (deleteTokenError) { 18 | console.error("Failed to delete password reset token:", deleteTokenError); 19 | throw new CreatePasswordResetTokenError("TOKEN_DELETION_FAILED"); 20 | } 21 | 22 | // Create new token 23 | const { error: createTokenError } = await supabase 24 | .schema("next_auth") 25 | .from("reset_tokens") 26 | .insert({ 27 | identifier: email, 28 | token: resetPasswordToken, 29 | expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), 30 | }); 31 | 32 | if (createTokenError) { 33 | console.error("Failed to create password reset token:", deleteTokenError); 34 | throw new CreatePasswordResetTokenError("TOKEN_DELETION_FAILED"); 35 | } 36 | } catch (error) { 37 | if (error instanceof CreatePasswordResetTokenError) { 38 | throw error; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/create-user-error.ts: -------------------------------------------------------------------------------- 1 | export class CreateUserError extends Error { 2 | public static readonly errorMessages = { 3 | USER_CREATION_FAILED: "Failed to create user.", 4 | USER_DELETION_FAILED: "Failed to delete user.", 5 | TOKEN_CREATION_FAILED: "Failed to insert token.", 6 | INTERNAL_ERROR: "Something went wrong.", 7 | } as const; 8 | 9 | public readonly code: keyof typeof CreateUserError.errorMessages; 10 | 11 | constructor(code: keyof typeof CreateUserError.errorMessages) { 12 | const message = CreateUserError.errorMessages[code]; 13 | super(message); 14 | 15 | this.code = code; 16 | this.name = "CreateUserError"; 17 | } 18 | 19 | public static getErrorMessage( 20 | code: keyof typeof CreateUserError.errorMessages 21 | ) { 22 | return this.errorMessages[code]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/create-user.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import bcrypt from "bcrypt"; 3 | import { supabase } from "@/lib/supabase"; 4 | import { CreateUserError } from "@/lib/create-user-error"; 5 | 6 | const adminEmails = ["rawgrittt@gmail.com"]; 7 | 8 | export async function createUser( 9 | email: string, 10 | password: string, 11 | verificationToken: string 12 | ) { 13 | const hashedPassword = await bcrypt.hash(password, 10); 14 | 15 | const role = adminEmails.includes(email.toLowerCase()) ? "admin" : "user"; 16 | 17 | try { 18 | // Create user 19 | const { error: userError } = await supabase 20 | .schema("next_auth") 21 | .from("users") 22 | .insert({ 23 | email, 24 | password: hashedPassword, 25 | credentials_email_verified: false, 26 | role, 27 | }) 28 | .select("*") 29 | .single(); 30 | 31 | if (userError) { 32 | console.error("Error inserting new user:", userError); 33 | throw new CreateUserError("USER_CREATION_FAILED"); 34 | } 35 | 36 | // Insert the verification token into the verification_tokens table 37 | const { error: tokenError } = await supabase 38 | .schema("next_auth") 39 | .from("verification_tokens") 40 | .insert({ 41 | token: verificationToken, 42 | identifier: email, 43 | expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour 44 | }); 45 | 46 | if (tokenError) { 47 | // If token insertion fails, delete the user we just created 48 | const { error: userDeletionError } = await supabase 49 | .schema("next_auth") 50 | .from("users") 51 | .delete() 52 | .eq("email", email); 53 | 54 | if (userDeletionError) { 55 | console.error("Error deleting user:", userDeletionError); 56 | throw new CreateUserError("USER_DELETION_FAILED"); 57 | } 58 | 59 | // If we successfully deleted the user, we should still throw an error 60 | // about the original token creation failure 61 | throw new CreateUserError("TOKEN_CREATION_FAILED"); 62 | } 63 | 64 | return { 65 | success: true, 66 | message: "User created", 67 | }; 68 | } catch (error) { 69 | if (error instanceof CreateUserError) { 70 | throw error; 71 | } 72 | throw new CreateUserError("INTERNAL_ERROR"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/custom-email-provider.ts: -------------------------------------------------------------------------------- 1 | import type { EmailConfig } from "next-auth/providers"; 2 | import { sendEmailSignInLink } from "@/lib/send-email-signin-link"; 3 | 4 | export function customEmailProvider(): EmailConfig { 5 | return { 6 | id: "ses", 7 | type: "email", 8 | name: "Email", 9 | maxAge: 60 * 60, 10 | 11 | async sendVerificationRequest({ identifier: email, url }) { 12 | await sendEmailSignInLink(email, url); 13 | }, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /lib/error-message.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components/icons"; 3 | import { VerifyCredentialEmailError } from "@/lib/verify-credential-email-error"; 4 | 5 | export function ErrorMessage({ 6 | code, 7 | }: { 8 | code: keyof typeof VerifyCredentialEmailError.errorMessages; 9 | }) { 10 | const message = VerifyCredentialEmailError.getErrorMessage(code); 11 | return ( 12 |
13 |

14 | Token Verification Error 15 |

16 |

{message}

17 | 21 | 22 | Sign In 23 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /lib/get-user-role.ts: -------------------------------------------------------------------------------- 1 | import { supabase } from "@/lib/supabase"; 2 | import { User } from "next-auth"; 3 | 4 | export async function getUserRole(user: User) { 5 | try { 6 | const { data: userData, error } = await supabase 7 | .schema("next_auth") 8 | .from("users") 9 | .select("role") 10 | .eq("email", user.email!) 11 | .single(); 12 | 13 | if (error) { 14 | return { success: false }; 15 | } 16 | 17 | return { 18 | success: true, 19 | role: userData.role, 20 | }; 21 | } catch (error) { 22 | return { 23 | success: false, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/handle-auth-jwt.ts: -------------------------------------------------------------------------------- 1 | import { JWT } from "next-auth/jwt"; 2 | import { User } from "next-auth"; 3 | import { getUserRole } from "@/lib/get-user-role"; 4 | 5 | export async function handleAuthJwt({ 6 | token, 7 | user, 8 | }: { 9 | token: JWT; 10 | user: User | undefined; 11 | }) { 12 | if (user) { 13 | const response = await getUserRole(user); 14 | if (response.success) { 15 | token.role = response.role!; 16 | } 17 | } 18 | 19 | return token; 20 | } 21 | -------------------------------------------------------------------------------- /lib/handle-auth-redirect.ts: -------------------------------------------------------------------------------- 1 | export async function handleAuthRedirect({ 2 | url, 3 | baseUrl, 4 | }: { 5 | url: string; 6 | baseUrl: string; 7 | }) { 8 | try { 9 | // Only attempt URL parsing if the path contains /signin 10 | if (url.includes("/signin")) { 11 | // Create a URL object from the incoming url 12 | const redirectUrl = new URL( 13 | url.startsWith("/") ? `${baseUrl}${url}` : url 14 | ); 15 | const pathname = redirectUrl.pathname; 16 | const destination = redirectUrl.searchParams.get("from"); 17 | 18 | // Handle signin page scenarios 19 | if (pathname === "/signin") { 20 | // If no destination is specified, redirect to homepage 21 | if (!destination) { 22 | return baseUrl; 23 | } 24 | 25 | // If destination is specified, redirect there 26 | if (destination) { 27 | return `${baseUrl}${destination}`; 28 | } 29 | } 30 | } 31 | 32 | // Default case: return the original URL with proper base handling 33 | const finalUrl = url.startsWith("/") ? `${baseUrl}${url}` : url; 34 | return finalUrl; 35 | } catch (error) { 36 | console.error("Error in handleAuthRedirect:", error); 37 | return baseUrl; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/handle-auth-session.ts: -------------------------------------------------------------------------------- 1 | import { Session } from "next-auth"; 2 | import { JWT } from "next-auth/jwt"; 3 | 4 | export async function handleAuthSession({ 5 | session, 6 | token, 7 | }: { 8 | session: Session; 9 | token: JWT; 10 | }) { 11 | if (session.user) { 12 | session.user.role = token.role; 13 | } 14 | return session; 15 | } 16 | -------------------------------------------------------------------------------- /lib/reset-passsord.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import bcrypt from "bcrypt"; 3 | import { supabase } from "@/lib/supabase"; 4 | import { ResetPasswordError } from "@/lib/reset-password-error"; 5 | 6 | export async function resetPassword(email: string, newPassword: string) { 7 | try { 8 | // Hash the new password 9 | const hashedPassword = await bcrypt.hash(newPassword, 10); 10 | 11 | // Update the user's password 12 | const { data: user, error: updateError } = await supabase 13 | .schema("next_auth") 14 | .from("users") 15 | .update({ password: hashedPassword }) 16 | .eq("email", email) 17 | .select() 18 | .single(); 19 | 20 | if (updateError) { 21 | console.error("Failed to update password:", updateError); 22 | throw new ResetPasswordError("PASSWORD_RESET_FAILED"); 23 | } 24 | 25 | // Clean up the reset token after successful password update 26 | const { error: deleteError } = await supabase 27 | .schema("next_auth") 28 | .from("reset_tokens") 29 | .delete() 30 | .eq("identifier", email); 31 | 32 | if (deleteError) { 33 | // Log but don't throw - token cleanup isn't critical to password update success 34 | console.error("Warning: Could not delete used reset token:", deleteError); 35 | } 36 | 37 | return { success: true }; 38 | } catch (error) { 39 | // If it's our known error type, rethrow it 40 | if (error instanceof ResetPasswordError) { 41 | throw error; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/reset-password-error.ts: -------------------------------------------------------------------------------- 1 | export class ResetPasswordError extends Error { 2 | public static readonly errorMessages = { 3 | PASSWORD_RESET_FAILED: "Failed to reset password.", 4 | } as const; 5 | 6 | public readonly code: keyof typeof ResetPasswordError.errorMessages; 7 | 8 | constructor(code: keyof typeof ResetPasswordError.errorMessages) { 9 | const message = ResetPasswordError.errorMessages[code]; 10 | super(message); 11 | 12 | this.code = code; 13 | this.name = "ResetPasswordError"; 14 | } 15 | public static getErrorMessage( 16 | code: keyof typeof ResetPasswordError.errorMessages 17 | ) { 18 | return this.errorMessages[code]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/send-credentials-email-verification-email.ts: -------------------------------------------------------------------------------- 1 | import { render } from "@react-email/render"; 2 | import { SendEmailCommand } from "@aws-sdk/client-ses"; 3 | import { sesClient } from "@/lib/aws"; 4 | import { EmailVerificationTemplate } from "@/components/email-verification-template"; 5 | 6 | export async function sendCredentialEmailVerificationEmail( 7 | email: string, 8 | verificationToken: string 9 | ) { 10 | // Create verification URL with base64url encoded token for safer URLs 11 | const verificationUrl = new URL("/verify-email", process.env.NEXTAUTH_URL); 12 | verificationUrl.searchParams.set("token", verificationToken); 13 | 14 | try { 15 | // Render the email template 16 | const emailHtml = await render( 17 | EmailVerificationTemplate({ 18 | verificationUrl: verificationUrl.toString(), 19 | }) 20 | ); 21 | 22 | const sendEmailCommand = new SendEmailCommand({ 23 | Destination: { 24 | ToAddresses: [email], 25 | }, 26 | Message: { 27 | Body: { 28 | Html: { 29 | Charset: "UTF-8", 30 | Data: emailHtml, 31 | }, 32 | }, 33 | Subject: { 34 | Charset: "UTF-8", 35 | Data: "Verify your email address", 36 | }, 37 | }, 38 | Source: process.env.AWS_SES_FROM_EMAIL, 39 | }); 40 | 41 | // Send the email 42 | await sesClient.send(sendEmailCommand); 43 | 44 | return { 45 | success: true, 46 | }; 47 | } catch (error) { 48 | console.error("Failed to send verification email:", error); 49 | 50 | return { 51 | success: false, 52 | message: "Failed to send verification email", 53 | }; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/send-email-signin-link.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { SendEmailCommand } from "@aws-sdk/client-ses"; 3 | import { render } from "@react-email/render"; 4 | import { sesClient } from "@/lib/aws"; 5 | import { EmailSignInTemplate } from "@/components/email-signin-template"; 6 | 7 | export async function sendEmailSignInLink(email: string, url: string) { 8 | try { 9 | // Render the email using react-email 10 | const emailHtml = await render(EmailSignInTemplate({ url })); 11 | 12 | const sendEmailCommand = new SendEmailCommand({ 13 | Destination: { 14 | ToAddresses: [email], 15 | }, 16 | Message: { 17 | Body: { 18 | Html: { 19 | Charset: "UTF-8", 20 | Data: emailHtml, 21 | }, 22 | }, 23 | Subject: { 24 | Charset: "UTF-8", 25 | Data: "Sign in link for your account", 26 | }, 27 | }, 28 | Source: process.env.AWS_SES_FROM_EMAIL, 29 | }); 30 | 31 | await sesClient.send(sendEmailCommand); 32 | return { success: true }; 33 | } catch (error) { 34 | console.error("Failed to send email login link:", error); 35 | return { success: false, error }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/send-password-reset-email-error.ts: -------------------------------------------------------------------------------- 1 | export class SendPasswordResetEmailError extends Error { 2 | public static readonly errorMessages = { 3 | EMAIL_SEND_FAILED: "Unable to send password reset email.", 4 | } as const; 5 | 6 | public readonly code: keyof typeof SendPasswordResetEmailError.errorMessages; 7 | 8 | constructor(code: keyof typeof SendPasswordResetEmailError.errorMessages) { 9 | const message = SendPasswordResetEmailError.errorMessages[code]; 10 | super(message); 11 | 12 | this.code = code; 13 | this.name = "SendPasswordResetEmailError"; 14 | } 15 | 16 | public static getErrorMessage( 17 | code: keyof typeof SendPasswordResetEmailError.errorMessages 18 | ) { 19 | return this.errorMessages[code]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/send-password-reset-email.ts: -------------------------------------------------------------------------------- 1 | import { render } from "@react-email/render"; 2 | import { SendEmailCommand } from "@aws-sdk/client-ses"; 3 | import { sesClient } from "@/lib/aws"; 4 | import { PasswordResetTemplate } from "@/components/password-reset-email-template"; 5 | import { SendPasswordResetEmailError } from "@/lib/send-password-reset-email-error"; 6 | 7 | export async function sendPasswordResetEmail( 8 | email: string, 9 | resetPasswordToken: string 10 | ) { 11 | // Create reset URL with the token 12 | const resetUrl = new URL( 13 | "/api/auth/verify-password-reset-token", 14 | process.env.NEXTAUTH_URL 15 | ); 16 | resetUrl.searchParams.set("token", resetPasswordToken); 17 | 18 | try { 19 | // Render the email template 20 | const emailHtml = await render( 21 | PasswordResetTemplate({ 22 | resetUrl: resetUrl.toString(), 23 | }) 24 | ); 25 | 26 | const sendEmailCommand = new SendEmailCommand({ 27 | Destination: { 28 | ToAddresses: [email], 29 | }, 30 | Message: { 31 | Body: { 32 | Html: { 33 | Charset: "UTF-8", 34 | Data: emailHtml, 35 | }, 36 | }, 37 | Subject: { 38 | Charset: "UTF-8", 39 | Data: "Reset your password", 40 | }, 41 | }, 42 | Source: process.env.AWS_SES_FROM_EMAIL, 43 | }); 44 | 45 | const result = await sesClient.send(sendEmailCommand); 46 | 47 | if (!result.MessageId) { 48 | throw new SendPasswordResetEmailError("EMAIL_SEND_FAILED"); 49 | } 50 | } catch (error) { 51 | if (error instanceof SendPasswordResetEmailError) { 52 | throw error; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/send-subscribe-email.ts: -------------------------------------------------------------------------------- 1 | import { render } from "@react-email/render"; 2 | import { SendEmailCommand } from "@aws-sdk/client-ses"; 3 | import { sesClient } from "@/lib/aws"; 4 | import { SubscribeTemplate } from "@/components/subscribe-template"; 5 | 6 | export async function sendSubscribeEmail(email: string) { 7 | // Create a URL with the email 8 | const subscribeUrl = new URL("/api/subscribe", process.env.NEXTAUTH_URL); 9 | subscribeUrl.searchParams.set("email", email); 10 | 11 | try { 12 | // Render the email template 13 | const emailHtml = await render( 14 | SubscribeTemplate({ 15 | url: subscribeUrl.toString(), 16 | }) 17 | ); 18 | 19 | const sendEmailCommand = new SendEmailCommand({ 20 | Destination: { 21 | ToAddresses: [email], 22 | }, 23 | Message: { 24 | Body: { 25 | Html: { 26 | Charset: "UTF-8", 27 | Data: emailHtml, 28 | }, 29 | }, 30 | Subject: { 31 | Charset: "UTF-8", 32 | Data: "Next.js + Auth.js Course Subscription", 33 | }, 34 | }, 35 | Source: process.env.AWS_SES_FROM_EMAIL, 36 | }); 37 | 38 | const result = await sesClient.send(sendEmailCommand); 39 | 40 | if (!result.MessageId) { 41 | throw new Error("Failed to send subscribe email."); 42 | } 43 | } catch (error) { 44 | if ( 45 | error instanceof Error && 46 | error.message === "Failed to send subscribe email." 47 | ) { 48 | throw error; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/subscribe-actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { parseWithZod } from "@conform-to/zod"; 4 | import { subscribeSchema } from "@/app/schema"; 5 | import { sendSubscribeEmail } from "@/lib/send-subscribe-email"; 6 | import { checkSubscriberExists } from "@/lib/check-subscriber-exists"; 7 | import { redirect } from "next/navigation"; 8 | 9 | export async function subscribe(prevState: unknown, formData: FormData) { 10 | // Validate the form data 11 | const submission = parseWithZod(formData, { 12 | schema: subscribeSchema, 13 | }); 14 | 15 | if (submission.status !== "success") { 16 | return submission.reply(); 17 | } 18 | 19 | const email = submission.value.email; 20 | let errorOccured = false; 21 | 22 | try { 23 | const response = await checkSubscriberExists(email); 24 | if (response.success) { 25 | errorOccured = true; 26 | return submission.reply({ 27 | formErrors: ["Already subscribed."], 28 | }); 29 | } 30 | await sendSubscribeEmail(email); 31 | } catch (error) { 32 | errorOccured = true; 33 | return submission.reply({ 34 | formErrors: ["Something went wrong."], 35 | }); 36 | } finally { 37 | if (!errorOccured) { 38 | redirect("/course/subscribe/email-sent"); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | import { Database } from "@/database.types"; 3 | 4 | export const supabase = createClient( 5 | process.env.SUPABASE_URL!, 6 | process.env.SUPABASE_SERVICE_ROLE_KEY! 7 | ); 8 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /lib/verify-credential-email-error.ts: -------------------------------------------------------------------------------- 1 | export class VerifyCredentialEmailError extends Error { 2 | public static readonly errorMessages = { 3 | TOKEN_NOT_FOUND: "Verification token not found.", 4 | TOKEN_EXPIRED: "Verification token has expired.", 5 | TOKEN_INVALID: "Verification token is invalid.", 6 | INTERNAL_ERROR: "Something went wrong.", 7 | } as const; 8 | 9 | public readonly code: keyof typeof VerifyCredentialEmailError.errorMessages; 10 | 11 | constructor(code: keyof typeof VerifyCredentialEmailError.errorMessages) { 12 | const message = VerifyCredentialEmailError.errorMessages[code]; 13 | super(message); 14 | 15 | this.code = code; 16 | this.name = "VerifyCredentialEmailError"; 17 | } 18 | 19 | public static getErrorMessage( 20 | code: keyof typeof VerifyCredentialEmailError.errorMessages 21 | ) { 22 | return this.errorMessages[code]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/verify-credential-email.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { supabase } from "@/lib/supabase"; 3 | import { VerifyCredentialEmailError } from "@/lib/verify-credential-email-error"; 4 | 5 | export async function verifyCredentialEmail(token: string) { 6 | try { 7 | // Fetch the verification token record 8 | const { data: tokenData, error: tokenError } = await supabase 9 | .schema("next_auth") 10 | .from("verification_tokens") 11 | .select("*") 12 | .eq("token", token) 13 | .single(); 14 | 15 | if (tokenError) { 16 | console.error("Error fetching verification token:", tokenError); 17 | throw new VerifyCredentialEmailError("TOKEN_INVALID"); 18 | } 19 | 20 | // Check the token expiration 21 | if (new Date(tokenData.expires) < new Date()) { 22 | throw new VerifyCredentialEmailError("TOKEN_EXPIRED"); 23 | } 24 | 25 | // Update the user's verification status 26 | const { data: userData, error: updateError } = await supabase 27 | .schema("next_auth") 28 | .from("users") 29 | .update({ credentials_email_verified: true }) 30 | .eq("email", tokenData.identifier!) 31 | .select() 32 | .single(); 33 | 34 | if (updateError) { 35 | throw new VerifyCredentialEmailError("INTERNAL_ERROR"); 36 | } 37 | 38 | // If verification succeeded, clean up the used token 39 | const { error: deleteError } = await supabase 40 | .schema("next_auth") 41 | .from("verification_tokens") 42 | .delete() 43 | .eq("token", token); 44 | 45 | if (deleteError) { 46 | throw new VerifyCredentialEmailError("INTERNAL_ERROR"); 47 | } 48 | 49 | return userData; 50 | } catch (error) { 51 | if (error instanceof VerifyCredentialEmailError) { 52 | throw error; 53 | } 54 | 55 | throw new VerifyCredentialEmailError("INTERNAL_ERROR"); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/verify-password-reset-token-error.ts: -------------------------------------------------------------------------------- 1 | export class VerifyPasswordResetTokenError extends Error { 2 | public static readonly errorMessages = { 3 | TOKEN_NOT_FOUND: "Reset token not found.", 4 | TOKEN_EXPIRED: "Reset token has expired.", 5 | TOKEN_INVALID: "Reset token is invalid.", 6 | INTERNAL_ERROR: "Something went wrong.", 7 | } as const; 8 | 9 | public readonly code: keyof typeof VerifyPasswordResetTokenError.errorMessages; 10 | 11 | constructor(code: keyof typeof VerifyPasswordResetTokenError.errorMessages) { 12 | const message = VerifyPasswordResetTokenError.errorMessages[code]; 13 | super(message); 14 | 15 | this.code = code; 16 | this.name = "VerifyPasswordResetTokenError"; 17 | } 18 | 19 | // Add a method to get the message for a specific code 20 | public static getErrorMessage( 21 | code: keyof typeof VerifyPasswordResetTokenError.errorMessages 22 | ) { 23 | return this.errorMessages[code]; 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /lib/verify-password-reset-token.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | import { supabase } from "@/lib/supabase"; 3 | import { VerifyPasswordResetTokenError } from "@/lib/verify-password-reset-token-error"; 4 | 5 | type TokenVerificationResult = { 6 | success: true; 7 | email: string; 8 | }; 9 | 10 | export async function verifyPasswordResetToken( 11 | token: string 12 | ): Promise { 13 | try { 14 | // Fetch the reset token record 15 | const { data: tokenData, error: tokenError } = await supabase 16 | .schema("next_auth") 17 | .from("reset_tokens") 18 | .select("*") 19 | .eq("token", token) 20 | .single(); 21 | 22 | if (tokenError) { 23 | console.error("Error fetching reset token:", tokenError); 24 | throw new VerifyPasswordResetTokenError("TOKEN_INVALID"); 25 | } 26 | 27 | // Check token expiration 28 | if (new Date(tokenData.expires) < new Date()) { 29 | throw new VerifyPasswordResetTokenError("TOKEN_EXPIRED"); 30 | } 31 | 32 | return { 33 | success: true, 34 | email: tokenData.identifier!, 35 | }; 36 | } catch (error) { 37 | // If it's our known error type, rethrow it 38 | if (error instanceof VerifyPasswordResetTokenError) { 39 | throw error; 40 | } 41 | throw new VerifyPasswordResetTokenError("INTERNAL_ERROR"); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { authConfig } from "@/auth.config"; 3 | import { NextResponse } from "next/server"; 4 | 5 | const { auth } = NextAuth(authConfig); 6 | 7 | export default auth((req) => { 8 | // Get the pathname from the request URL 9 | const { nextUrl } = req; 10 | const path = nextUrl.pathname; 11 | 12 | // Get authentication status from the req.auth object 13 | const isAuthenticated = !!req.auth; 14 | 15 | // If authenticated users try to access the "/signin" route, redirect them to the home page 16 | if (isAuthenticated && path === "/signin") { 17 | return Response.redirect(new URL("/", nextUrl)); 18 | } 19 | 20 | // Redirect unauthenticated users to signin page 21 | if (!isAuthenticated) { 22 | const signInUrl = new URL("/signin", nextUrl); 23 | // Store the original URL as a query param to redirect after signin 24 | signInUrl.searchParams.set("from", path); 25 | return Response.redirect(signInUrl); 26 | } 27 | 28 | // Allow the request to proceed normally for other routes 29 | return NextResponse.next(); 30 | }); 31 | 32 | // Configure which routes use the middleware 33 | export const config = { 34 | matcher: ["/private/:path*", "/admin/:path*"], 35 | }; 36 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-auth-course", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@auth/supabase-adapter": "^1.7.4", 13 | "@aws-sdk/client-ses": "^3.699.0", 14 | "@conform-to/react": "^1.2.2", 15 | "@conform-to/zod": "^1.2.2", 16 | "@headlessui/react": "^2.2.0", 17 | "@radix-ui/react-avatar": "^1.1.1", 18 | "@radix-ui/react-dropdown-menu": "^2.1.2", 19 | "@radix-ui/react-label": "^2.1.0", 20 | "@radix-ui/react-slot": "^1.1.0", 21 | "@radix-ui/react-tabs": "^1.1.1", 22 | "@react-email/components": "^0.0.30", 23 | "@supabase/supabase-js": "^2.46.2", 24 | "bcrypt": "^5.1.1", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "framer-motion": "^12.0.0-alpha.2", 28 | "lucide-react": "^0.462.0", 29 | "next": "^15.1.0", 30 | "next-auth": "^5.0.0-beta.25", 31 | "nextjs-toploader": "^3.7.15", 32 | "react": "^19.0.0", 33 | "react-dom": "^19.0.0", 34 | "react-hot-toast": "^2.4.1", 35 | "server-only": "^0.0.1", 36 | "supabase": "^2.0.0", 37 | "tailwind-merge": "^2.5.5", 38 | "tailwindcss-animate": "^1.0.7", 39 | "zod": "^3.23.8", 40 | "zustand": "^5.0.2" 41 | }, 42 | "devDependencies": { 43 | "@types/bcrypt": "^5.0.2", 44 | "@types/node": "^20", 45 | "@types/react": "^18", 46 | "@types/react-dom": "^18", 47 | "autoprefixer": "^10.4.20", 48 | "eslint": "^8", 49 | "eslint-config-next": "15.0.3", 50 | "postcss": "^8.4.49", 51 | "tailwindcss": "^3.4.15", 52 | "typescript": "^5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | .env 5 | -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # For detailed configuration reference documentation, visit: 2 | # https://supabase.com/docs/guides/local-development/cli/config 3 | # A string used to distinguish different Supabase projects on the same host. Defaults to the 4 | # working directory name when running `supabase init`. 5 | project_id = "next-auth-course" 6 | 7 | [api] 8 | enabled = true 9 | # Port to use for the API URL. 10 | port = 54321 11 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 12 | # endpoints. `public` is always included. 13 | schemas = ["public", "graphql_public"] 14 | # Extra schemas to add to the search_path of every request. `public` is always included. 15 | extra_search_path = ["public", "extensions"] 16 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 17 | # for accidental or malicious requests. 18 | max_rows = 1000 19 | 20 | [api.tls] 21 | enabled = false 22 | 23 | [db] 24 | # Port to use for the local database URL. 25 | port = 54322 26 | # Port used by db diff command to initialize the shadow database. 27 | shadow_port = 54320 28 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 29 | # server_version;` on the remote database to check. 30 | major_version = 15 31 | 32 | [db.pooler] 33 | enabled = false 34 | # Port to use for the local connection pooler. 35 | port = 54329 36 | # Specifies when a server connection can be reused by other clients. 37 | # Configure one of the supported pooler modes: `transaction`, `session`. 38 | pool_mode = "transaction" 39 | # How many server connections to allow per user/database pair. 40 | default_pool_size = 20 41 | # Maximum number of client connections allowed. 42 | max_client_conn = 100 43 | 44 | [db.seed] 45 | # If enabled, seeds the database after migrations during a db reset. 46 | enabled = true 47 | # Specifies an ordered list of seed files to load during db reset. 48 | # Supports glob patterns relative to supabase directory. For example: 49 | # sql_paths = ['./seeds/*.sql', '../project-src/seeds/*-load-testing.sql'] 50 | sql_paths = ['./seed.sql'] 51 | 52 | [realtime] 53 | enabled = true 54 | # Bind realtime via either IPv4 or IPv6. (default: IPv4) 55 | # ip_version = "IPv6" 56 | # The maximum length in bytes of HTTP request headers. (default: 4096) 57 | # max_header_length = 4096 58 | 59 | [studio] 60 | enabled = true 61 | # Port to use for Supabase Studio. 62 | port = 54323 63 | # External URL of the API server that frontend connects to. 64 | api_url = "http://127.0.0.1" 65 | # OpenAI API Key to use for Supabase AI in the Supabase Studio. 66 | openai_api_key = "env(OPENAI_API_KEY)" 67 | 68 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 69 | # are monitored, and you can view the emails that would have been sent from the web interface. 70 | [inbucket] 71 | enabled = true 72 | # Port to use for the email testing server web interface. 73 | port = 54324 74 | # Uncomment to expose additional ports for testing user applications that send emails. 75 | # smtp_port = 54325 76 | # pop3_port = 54326 77 | # admin_email = "admin@email.com" 78 | # sender_name = "Admin" 79 | 80 | [storage] 81 | enabled = true 82 | # The maximum file size allowed (e.g. "5MB", "500KB"). 83 | file_size_limit = "50MiB" 84 | 85 | [storage.image_transformation] 86 | enabled = true 87 | 88 | # Uncomment to configure local storage buckets 89 | # [storage.buckets.images] 90 | # public = false 91 | # file_size_limit = "50MiB" 92 | # allowed_mime_types = ["image/png", "image/jpeg"] 93 | # objects_path = "./images" 94 | 95 | [auth] 96 | enabled = true 97 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 98 | # in emails. 99 | site_url = "http://127.0.0.1:3000" 100 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 101 | additional_redirect_urls = ["https://127.0.0.1:3000"] 102 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). 103 | jwt_expiry = 3600 104 | # If disabled, the refresh token will never expire. 105 | enable_refresh_token_rotation = true 106 | # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. 107 | # Requires enable_refresh_token_rotation = true. 108 | refresh_token_reuse_interval = 10 109 | # Allow/disallow new user signups to your project. 110 | enable_signup = true 111 | # Allow/disallow anonymous sign-ins to your project. 112 | enable_anonymous_sign_ins = false 113 | # Allow/disallow testing manual linking of accounts 114 | enable_manual_linking = false 115 | # Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. 116 | minimum_password_length = 6 117 | # Passwords that do not meet the following requirements will be rejected as weak. Supported values 118 | # are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` 119 | password_requirements = "" 120 | 121 | [auth.email] 122 | # Allow/disallow new user signups via email to your project. 123 | enable_signup = true 124 | # If enabled, a user will be required to confirm any email change on both the old, and new email 125 | # addresses. If disabled, only the new email is required to confirm. 126 | double_confirm_changes = true 127 | # If enabled, users need to confirm their email address before signing in. 128 | enable_confirmations = false 129 | # If enabled, users will need to reauthenticate or have logged in recently to change their password. 130 | secure_password_change = false 131 | # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. 132 | max_frequency = "1s" 133 | # Number of characters used in the email OTP. 134 | otp_length = 6 135 | # Number of seconds before the email OTP expires (defaults to 1 hour). 136 | otp_expiry = 3600 137 | 138 | # Use a production-ready SMTP server 139 | # [auth.email.smtp] 140 | # host = "smtp.sendgrid.net" 141 | # port = 587 142 | # user = "apikey" 143 | # pass = "env(SENDGRID_API_KEY)" 144 | # admin_email = "admin@email.com" 145 | # sender_name = "Admin" 146 | 147 | # Uncomment to customize email template 148 | # [auth.email.template.invite] 149 | # subject = "You have been invited" 150 | # content_path = "./supabase/templates/invite.html" 151 | 152 | [auth.sms] 153 | # Allow/disallow new user signups via SMS to your project. 154 | enable_signup = false 155 | # If enabled, users need to confirm their phone number before signing in. 156 | enable_confirmations = false 157 | # Template for sending OTP to users 158 | template = "Your code is {{ .Code }}" 159 | # Controls the minimum amount of time that must pass before sending another sms otp. 160 | max_frequency = "5s" 161 | 162 | # Use pre-defined map of phone number to OTP for testing. 163 | # [auth.sms.test_otp] 164 | # 4152127777 = "123456" 165 | 166 | # Configure logged in session timeouts. 167 | # [auth.sessions] 168 | # Force log out after the specified duration. 169 | # timebox = "24h" 170 | # Force log out if the user has been inactive longer than the specified duration. 171 | # inactivity_timeout = "8h" 172 | 173 | # This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. 174 | # [auth.hook.custom_access_token] 175 | # enabled = true 176 | # uri = "pg-functions:////" 177 | 178 | # Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. 179 | [auth.sms.twilio] 180 | enabled = false 181 | account_sid = "" 182 | message_service_sid = "" 183 | # DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: 184 | auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" 185 | 186 | [auth.mfa] 187 | # Control how many MFA factors can be enrolled at once per user. 188 | max_enrolled_factors = 10 189 | 190 | # Control use of MFA via App Authenticator (TOTP) 191 | [auth.mfa.totp] 192 | enroll_enabled = true 193 | verify_enabled = true 194 | 195 | # Configure Multi-factor-authentication via Phone Messaging 196 | [auth.mfa.phone] 197 | enroll_enabled = false 198 | verify_enabled = false 199 | otp_length = 6 200 | template = "Your code is {{ .Code }}" 201 | max_frequency = "5s" 202 | 203 | # Configure Multi-factor-authentication via WebAuthn 204 | # [auth.mfa.web_authn] 205 | # enroll_enabled = true 206 | # verify_enabled = true 207 | 208 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 209 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, 210 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`. 211 | [auth.external.apple] 212 | enabled = false 213 | client_id = "" 214 | # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: 215 | secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" 216 | # Overrides the default auth redirectUrl. 217 | redirect_uri = "" 218 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 219 | # or any other third-party OIDC providers. 220 | url = "" 221 | # If enabled, the nonce check will be skipped. Required for local sign in with Google auth. 222 | skip_nonce_check = false 223 | 224 | # Use Firebase Auth as a third-party provider alongside Supabase Auth. 225 | [auth.third_party.firebase] 226 | enabled = false 227 | # project_id = "my-firebase-project" 228 | 229 | # Use Auth0 as a third-party provider alongside Supabase Auth. 230 | [auth.third_party.auth0] 231 | enabled = false 232 | # tenant = "my-auth0-tenant" 233 | # tenant_region = "us" 234 | 235 | # Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. 236 | [auth.third_party.aws_cognito] 237 | enabled = false 238 | # user_pool_id = "my-user-pool-id" 239 | # user_pool_region = "us-east-1" 240 | 241 | [edge_runtime] 242 | enabled = true 243 | # Configure one of the supported request policies: `oneshot`, `per_worker`. 244 | # Use `oneshot` for hot reload, or `per_worker` for load testing. 245 | policy = "oneshot" 246 | # Port to attach the Chrome inspector for debugging edge functions. 247 | inspector_port = 8083 248 | 249 | # Use these configurations to customize your Edge Function. 250 | # [functions.MY_FUNCTION_NAME] 251 | # enabled = true 252 | # verify_jwt = true 253 | # import_map = "./functions/MY_FUNCTION_NAME/deno.json" 254 | # Uncomment to specify a custom file path to the entrypoint. 255 | # Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx 256 | # entrypoint = "./functions/MY_FUNCTION_NAME/index.ts" 257 | 258 | [analytics] 259 | enabled = true 260 | port = 54327 261 | # Configure one of the supported backends: `postgres`, `bigquery`. 262 | backend = "postgres" 263 | 264 | # Experimental features may be deprecated any time 265 | [experimental] 266 | # Configures Postgres storage engine to use OrioleDB (S3) 267 | orioledb_version = "" 268 | # Configures S3 bucket URL, eg. .s3-.amazonaws.com 269 | s3_host = "env(S3_HOST)" 270 | # Configures S3 bucket region, eg. us-east-1 271 | s3_region = "env(S3_REGION)" 272 | # Configures AWS_ACCESS_KEY_ID for S3 bucket 273 | s3_access_key = "env(S3_ACCESS_KEY)" 274 | # Configures AWS_SECRET_ACCESS_KEY for S3 bucket 275 | s3_secret_key = "env(S3_SECRET_KEY)" 276 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)' 58 | } 59 | } 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | } satisfies Config; 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { type DefaultSession } from "next-auth"; 2 | 3 | declare module "next-auth" { 4 | // First extend the User interface 5 | interface User { 6 | role?: string; 7 | } 8 | 9 | // Then extend the Session interface, being explicit about the user property 10 | interface Session { 11 | user: { 12 | role?: string; 13 | } & DefaultSession["user"]; // Merge with the default user properties 14 | } 15 | } 16 | 17 | // Extend JWT in a separate module declaration 18 | declare module "next-auth/jwt" { 19 | interface JWT { 20 | role?: string; 21 | } 22 | } 23 | --------------------------------------------------------------------------------