├── .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 |
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 |
19 | Sign In
20 |
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 |
23 | Verify Email
24 |
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 |
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 |
25 |
29 |
33 |
37 |
41 |
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 |
28 |
29 | {items.map((item) => (
30 |
31 |
32 |
33 | ))}
34 |
35 |
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 |
91 |
97 | {navbarLinks.main.map((item) => (
98 |
99 |
106 | {item.title}
107 |
108 |
109 | ))}
110 |
111 |
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 |
179 | {isOpen ? "Close menu" : "Open menu"}
180 |
186 | {[-1, 0, 1].map((index) => (
187 |
199 | ))}
200 |
201 |
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 |
20 | Reset Password
21 |
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 |
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 |
30 | {isLoading ? (
31 | <>
32 |
33 | Sign out
34 | >
35 | ) : (
36 | <>
37 |
38 | Sign out
39 | >
40 | )}
41 |
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 |
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 |
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 |
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 |
19 | Subscribe
20 |
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 |
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 |
--------------------------------------------------------------------------------