├── .env.example
├── .eslintrc.json
├── .gitignore
├── .vscode
└── settings.json
├── README.md
├── bin
└── cli.js
├── components.json
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── prisma
└── schema.prisma
├── public
├── icons
│ └── google.svg
├── next.svg
└── vercel.svg
├── src
├── app-config.tsx
├── app
│ ├── (auth)
│ │ ├── reset-password
│ │ │ ├── actions.ts
│ │ │ └── page.tsx
│ │ ├── sign-in
│ │ │ ├── actions.ts
│ │ │ ├── email
│ │ │ │ ├── actions.ts
│ │ │ │ └── page.tsx
│ │ │ ├── forgot-password
│ │ │ │ ├── actions.ts
│ │ │ │ └── page.tsx
│ │ │ ├── magic-link-form.tsx
│ │ │ ├── magic
│ │ │ │ ├── error
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ └── sign-up
│ │ │ ├── actions.ts
│ │ │ └── page.tsx
│ ├── (root)
│ │ └── page.tsx
│ ├── api
│ │ ├── auth
│ │ │ └── [...nextauth]
│ │ │ │ └── route.ts
│ │ └── login
│ │ │ └── magic
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── globals.css
│ └── layout.tsx
├── auth.ts
├── components
│ ├── auth
│ │ └── layout.tsx
│ └── ui
│ │ ├── alert.tsx
│ │ ├── button.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ └── sonner.tsx
├── data-access
│ ├── magic-links.ts
│ ├── reset-tokens.ts
│ ├── sessions.ts
│ ├── users.ts
│ └── utils.ts
├── emails
│ ├── magic-link.tsx
│ └── reset-password.tsx
├── lib
│ ├── db.ts
│ ├── safe-action.ts
│ ├── send-email.ts
│ ├── session.ts
│ └── utils.ts
├── middleware.ts
├── routes.ts
├── styles
│ └── icons.ts
└── use-cases
│ ├── errors.ts
│ ├── magic-link.tsx
│ ├── users.tsx
│ └── utils.ts
├── tailwind.config.ts
└── tsconfig.json
/.env.example:
--------------------------------------------------------------------------------
1 | DATABASE_URL="file:./dev.db"
2 | AUTH_SECRET=
3 |
4 | AUTH_GOOGLE_ID=
5 | AUTH_GOOGLE_SECRET=
6 |
7 | AUTH_RESEND_KEY=
8 |
9 | NEXT_PUBLIC_NODEMAILER_PW=
10 | NEXT_PUBLIC_NODEMAILER_EMAIL=
11 |
12 |
13 | HOST_NAME="http://localhost:3000"
14 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | /prisma/dev.db
39 | pnpm-lock.yaml
40 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "workbench.colorCustomizations": {
3 | "editorSuggestWidget.selectedBackground": "#231739",
4 | "sideBar.background": "#191521",
5 | "list.activeSelectionBackground": "#231739",
6 | "list.inactiveSelectionBackground": "#231739",
7 | "list.focusBackground": "#231739",
8 | "list.hoverBackground": "#231739",
9 | "terminalCursor.foreground": "#C45DFF",
10 | "activityBar.background": "#0F3418",
11 | "titleBar.activeBackground": "#164922",
12 | "titleBar.activeForeground": "#F3FCF5"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # NextAuth GitHub Template
3 |
4 | This is a GitHub template project using the latest version of NextAuth.js. It implements the following authentication methods:
5 |
6 | - Google Sign-in
7 | - Magic Link Sign-in
8 | - Sign-in with Email
9 | - Forgot Password
10 | - Reset Password
11 |
12 | This template provides a quick and easy way to get started with authentication in your Next.js projects.
13 |
14 | ---
15 |
16 | ## 🚀 Quick Start
17 |
18 | To get the project running locally, follow these steps:
19 |
20 | 1. Install dependencies:
21 | ```bash
22 | pnpm i
23 | ```
24 |
25 | 2. Generate Prisma client:
26 | ```bash
27 | npx prisma generate
28 | ```
29 |
30 | 3. Push the Prisma schema to your database:
31 | ```bash
32 | npx prisma db push
33 | ```
34 |
35 | 4. Start the development server:
36 | ```bash
37 | pnpm run dev
38 | ```
39 |
40 | ---
41 |
42 | ## 📁 Project Structure
43 |
44 | The project follows a structured approach with the latest Next.js layout:
45 |
46 | - **data-access/**: This folder contains all files interacting with the database. It includes Prisma models and database operations.
47 |
48 | - **use-cases/**: This folder contains logic implementations for different functionalities. Business logic is separated from data access to ensure modularity.
49 |
50 | - **emails/**: This folder contains templates for sending emails, such as reset password emails and magic link emails.
51 |
52 | The rest of the files follow the latest Next.js structure, leveraging App Router for an optimized developer experience.
53 |
54 | ---
55 |
56 | ## 🖼️ Screenshots
57 |
58 |
59 |
60 |  |
61 |  |
62 |
63 |
64 |  |
65 |  |
66 |
67 |
68 |
69 |
70 |
71 |
72 | ---
73 |
74 | ## 🛠️ Tech Stack
75 |
76 | - **Next.js**
77 | - **NextAuth.js**
78 | - **Prisma**
79 | - **PNPM**
80 |
81 | Feel free to modify this template according to your project's needs. Contributions and suggestions are always welcome!
82 |
--------------------------------------------------------------------------------
/bin/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const { execSync } = require("child_process");
4 | const readline = require("readline");
5 |
6 | const rl = readline.createInterface({
7 | input: process.stdin,
8 | output: process.stdout
9 | });
10 |
11 | const runCommand = (command) => {
12 | try {
13 | execSync(`${command}`, { stdio: "inherit" });
14 | return true;
15 | } catch (error) {
16 | console.error(`Failed to execute ${command}`, error);
17 | return false;
18 | }
19 | };
20 |
21 | const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve));
22 |
23 | const main = async () => {
24 | const repoName = process.argv[2];
25 | if (!repoName) {
26 | console.error("Please specify the project name");
27 | process.exit(1);
28 | }
29 |
30 | const packageManager = await askQuestion("Would you like to use npm or pnpm? (npm/pnpm): ");
31 | if (packageManager !== "npm" && packageManager !== "pnpm") {
32 | console.error("Invalid package manager. Please choose either npm or pnpm.");
33 | process.exit(1);
34 | }
35 |
36 | const gitCheckoutCommand = `git clone --depth 1 https://github.com/Mihir2423/edit_bridge ${repoName}`;
37 | const installDepsCommand = `cd ${repoName} && ${packageManager} install`;
38 |
39 | console.log(`Cloning repository with the name ${repoName}`);
40 |
41 | if (runCommand(gitCheckoutCommand)) {
42 | console.log(`Installing dependencies for ${repoName} using ${packageManager}`);
43 |
44 | if (runCommand(installDepsCommand)) {
45 | console.log(`
46 | Successfully cloned and installed dependencies for ${repoName}
47 | To start the project, run the following commands:
48 | cd ${repoName}
49 | ${packageManager} start
50 | `);
51 | }
52 | } else {
53 | console.error(`Failed to clone repository ${repoName}`);
54 | }
55 |
56 | rl.close();
57 | };
58 |
59 | main();
60 |
--------------------------------------------------------------------------------
/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": "src/app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "**",
8 | port: "",
9 | pathname: "**/*",
10 | },
11 | ],
12 | },
13 | }
14 |
15 | export default nextConfig;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-auth-starter",
3 | "version": "0.1.2",
4 | "bin": "./bin/cli.js",
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "postinstall": "npx prisma generate"
11 | },
12 | "dependencies": {
13 | "@auth/prisma-adapter": "^2.4.2",
14 | "@hookform/resolvers": "^3.9.0",
15 | "@prisma/client": "5.18.0",
16 | "@radix-ui/react-label": "^2.1.0",
17 | "@radix-ui/react-slot": "^1.1.0",
18 | "@react-email/components": "^0.0.22",
19 | "axios": "^1.7.4",
20 | "class-variance-authority": "^0.7.0",
21 | "clsx": "^2.1.1",
22 | "crypto": "^1.0.1",
23 | "lucide-react": "^0.427.0",
24 | "next": "14.2.5",
25 | "next-auth": "5.0.0-beta.20",
26 | "next-themes": "^0.3.0",
27 | "nodemailer": "^6.9.14",
28 | "react": "^18",
29 | "react-dom": "^18",
30 | "react-email": "^2.1.6",
31 | "react-hook-form": "^7.52.2",
32 | "resend": "^4.0.0",
33 | "sonner": "^1.5.0",
34 | "tailwind-merge": "^2.4.0",
35 | "tailwindcss-animate": "^1.0.7",
36 | "zod": "^3.23.8",
37 | "zsa": "^0.6.0",
38 | "zsa-react": "^0.2.2"
39 | },
40 | "devDependencies": {
41 | "@types/node": "^20",
42 | "@types/nodemailer": "^6.4.15",
43 | "@types/react": "^18",
44 | "@types/react-dom": "^18",
45 | "eslint": "^8",
46 | "eslint-config-next": "14.2.5",
47 | "postcss": "^8",
48 | "prisma": "^5.18.0",
49 | "tailwindcss": "^3.4.1",
50 | "typescript": "^5"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "sqlite"
10 | url = env("DATABASE_URL")
11 | }
12 |
13 | model User {
14 | id String @id @default(cuid())
15 | name String?
16 | email String @unique
17 | emailVerified DateTime?
18 | image String?
19 | password String?
20 | role String?
21 | salt String?
22 | accounts Account[]
23 | sessions Session[]
24 | resetToken ResetToken?
25 | verifyEmailToken VerifyEmailToken?
26 | createdAt DateTime @default(now())
27 | updatedAt DateTime @updatedAt
28 |
29 | @@map("users")
30 | }
31 |
32 | model Account {
33 | userId String
34 | type String
35 | provider String
36 | providerAccountId String
37 | refresh_token String?
38 | access_token String?
39 | expires_at Int?
40 | token_type String?
41 | scope String?
42 | id_token String?
43 | session_state String?
44 |
45 | createdAt DateTime @default(now())
46 | updatedAt DateTime @updatedAt
47 |
48 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
49 |
50 | @@id([provider, providerAccountId])
51 | @@map("accounts")
52 | }
53 |
54 | model Session {
55 | sessionToken String @unique
56 | userId String
57 | expires DateTime
58 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
59 |
60 | createdAt DateTime @default(now())
61 | updatedAt DateTime @updatedAt
62 |
63 | @@map("sessions")
64 | }
65 |
66 | model VerificationToken {
67 | identifier String
68 | token String
69 | expires DateTime
70 |
71 | @@id([identifier, token])
72 | @@map("verification_tokens")
73 | }
74 |
75 | model MagicLink {
76 | id Int @id @default(autoincrement())
77 | email String @unique
78 | token String?
79 | tokenExpiresAt DateTime?
80 |
81 | @@map("magic_links")
82 | }
83 |
84 | model ResetToken {
85 | id Int @id @default(autoincrement())
86 | userId String @unique
87 | token String?
88 | tokenExpiresAt DateTime?
89 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
90 |
91 | @@map("reset_tokens")
92 | }
93 |
94 | model VerifyEmailToken {
95 | id Int @id @default(autoincrement())
96 | userId String @unique
97 | token String?
98 | tokenExpiresAt DateTime?
99 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
100 |
101 | @@map("verify_email_tokens")
102 | }
103 |
--------------------------------------------------------------------------------
/public/icons/google.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/app-config.tsx:
--------------------------------------------------------------------------------
1 | export const TOKEN_LENGTH = 32;
2 | export const TOKEN_TTL = 1000 * 60 * 5; // 5 min
3 | export const VERIFY_EMAIL_TTL = 1000 * 60 * 60 * 24 * 7; // 7 days
4 |
5 | export const applicationName = "StarterKit";
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { unauthenticatedAction } from "@/lib/safe-action";
4 | import { changePasswordUseCase } from "@/use-cases/users";
5 | import { z } from "zod";
6 |
7 | export const changePasswordAction = unauthenticatedAction
8 | .createServerAction()
9 | .input(
10 | z.object({
11 | token: z.string(),
12 | password: z.string().min(8),
13 | })
14 | )
15 | .handler(async ({ input: { token, password } }) => {
16 | await changePasswordUseCase(token, password);
17 | });
18 |
--------------------------------------------------------------------------------
/src/app/(auth)/reset-password/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AuthLayout } from "@/components/auth/layout";
4 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Form,
8 | FormControl,
9 | FormField,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form";
14 | import { Input } from "@/components/ui/input";
15 | import { zodResolver } from "@hookform/resolvers/zod";
16 | import { Loader2, Terminal } from "lucide-react";
17 | import Link from "next/link";
18 | import { useForm } from "react-hook-form";
19 | import { z } from "zod";
20 | import { useServerAction } from "zsa-react";
21 | import { changePasswordAction } from "./actions";
22 |
23 | const resetPasswordSchema = z
24 | .object({
25 | password: z.string().min(8),
26 | token: z.string(),
27 | passwordConfirmation: z.string().min(8),
28 | })
29 | .refine((data) => data.password === data.passwordConfirmation, {
30 | message: "Passwords don't match",
31 | path: ["passwordConfirmation"],
32 | });
33 |
34 | const ResetPasswordPage = ({
35 | searchParams,
36 | }: {
37 | searchParams: { token: string };
38 | }) => {
39 | const form = useForm>({
40 | resolver: zodResolver(resetPasswordSchema),
41 | defaultValues: {
42 | password: "",
43 | token: searchParams.token,
44 | passwordConfirmation: "",
45 | },
46 | });
47 | const { execute, isPending, isSuccess, error } =
48 | useServerAction(changePasswordAction);
49 |
50 | function onSubmit(values: z.infer) {
51 | execute({
52 | token: values.token,
53 | password: values.password,
54 | });
55 | }
56 | return (
57 |
58 | {isSuccess && (
59 | <>
60 |
61 |
62 | Password updated
63 |
64 | Your password has been successfully updated.
65 |
66 |
67 |
68 |
71 | >
72 | )}
73 | {!isSuccess && (
74 |
132 | )}
133 |
134 | );
135 | };
136 |
137 | export default ResetPasswordPage;
138 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/actions.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { unauthenticatedAction } from "@/lib/safe-action"
4 | import { sendMagicLinkUseCase } from "@/use-cases/magic-link"
5 | import { redirect } from "next/navigation"
6 | import { z } from "zod"
7 |
8 | export const signInLinkMagicAction = unauthenticatedAction.createServerAction()
9 | .input(
10 | z.object({
11 | email: z.string().email(),
12 | })
13 | ).handler(async ({ input }) => {
14 | await sendMagicLinkUseCase(input.email);
15 | redirect("/sign-in/magic");
16 | })
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/email/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { unauthenticatedAction } from "@/lib/safe-action";
4 | import { createSessionUseCase, signInUseCase } from "@/use-cases/users";
5 | import { redirect } from "next/navigation";
6 | import { z } from "zod";
7 |
8 | export const signInAction = unauthenticatedAction
9 | .createServerAction()
10 | .input(
11 | z.object({
12 | email: z.string().email(),
13 | password: z.string().min(8),
14 | })
15 | )
16 | .handler(async ({ input }) => {
17 | const user = await signInUseCase(input.email, input.password);
18 | await createSessionUseCase(user.id, user.salt);
19 | redirect("/");
20 | });
21 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/email/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AuthLayout } from "@/components/auth/layout";
4 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Form,
8 | FormControl,
9 | FormField,
10 | FormItem,
11 | FormLabel,
12 | FormMessage,
13 | } from "@/components/ui/form";
14 | import { Input } from "@/components/ui/input";
15 | import { zodResolver } from "@hookform/resolvers/zod";
16 | import { Loader2, Terminal } from "lucide-react";
17 | import Link from "next/link";
18 | import { useForm } from "react-hook-form";
19 | import { toast } from "sonner";
20 | import { z } from "zod";
21 | import { useServerAction } from "zsa-react";
22 | import { signInAction } from "./actions";
23 |
24 | const signInSchema = z.object({
25 | email: z.string().email(),
26 | password: z.string().min(8),
27 | });
28 |
29 | type Props = {};
30 |
31 | const EmailPage = (props: Props) => {
32 | const form = useForm>({
33 | resolver: zodResolver(signInSchema),
34 | defaultValues: {
35 | email: "",
36 | password: "",
37 | },
38 | });
39 | const { execute, isPending, error } = useServerAction(signInAction, {
40 | onError({ err }) {
41 | toast.error("Something went wrong");
42 | },
43 | });
44 | function onSubmit(values: z.infer) {
45 | execute(values);
46 | }
47 | return (
48 |
49 |
96 |
97 |
98 |
99 |
102 |
103 |
104 |
105 |
106 |
or
107 |
108 |
109 |
110 |
111 | );
112 | };
113 |
114 | export default EmailPage;
115 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/forgot-password/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { resetPasswordUseCase } from "@/use-cases/users";
4 | import { unauthenticatedAction } from "@/lib/safe-action";
5 | import { z } from "zod";
6 |
7 | export const resetPasswordAction = unauthenticatedAction
8 | .createServerAction()
9 | .input(
10 | z.object({
11 | email: z.string().email(),
12 | })
13 | )
14 | .handler(async ({ input }) => {
15 | await resetPasswordUseCase(input.email);
16 | });
17 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/forgot-password/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AuthLayout } from "@/components/auth/layout";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import React from "react";
6 | import { useForm } from "react-hook-form";
7 | import { toast } from "sonner";
8 | import { z } from "zod";
9 | import { useServerAction } from "zsa-react";
10 | import { signInAction } from "../email/actions";
11 | import { Button } from "@/components/ui/button";
12 | import {
13 | Form,
14 | FormControl,
15 | FormField,
16 | FormItem,
17 | FormLabel,
18 | FormMessage,
19 | } from "@/components/ui/form";
20 | import { Input } from "@/components/ui/input";
21 | import { Loader2, Terminal } from "lucide-react";
22 | import { resetPasswordAction } from "./actions";
23 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
24 |
25 | const fortgotPasswordSchema = z.object({
26 | email: z.string().email(),
27 | });
28 |
29 | type Props = {};
30 |
31 | const ForgotPasswordPage = (props: Props) => {
32 | const { execute, isPending, isSuccess } = useServerAction(
33 | resetPasswordAction,
34 | {
35 | onError({ err }) {
36 | toast.error("An error occurred. Please try again.");
37 | },
38 | }
39 | );
40 |
41 | const form = useForm>({
42 | resolver: zodResolver(fortgotPasswordSchema),
43 | defaultValues: {
44 | email: "",
45 | },
46 | });
47 |
48 | function onSubmit(values: z.infer) {
49 | execute(values);
50 | console.log(values);
51 | }
52 | return (
53 |
57 | {isSuccess && (
58 |
59 |
60 | Reset link sent
61 |
62 | We have sent you an email with a link to reset your password.
63 |
64 |
65 | )}
66 |
93 |
94 |
95 | );
96 | };
97 |
98 | export default ForgotPasswordPage;
99 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/magic-link-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Form,
6 | FormControl,
7 | FormField,
8 | FormItem,
9 | FormLabel,
10 | FormMessage,
11 | } from "@/components/ui/form";
12 | import { Input } from "@/components/ui/input";
13 | import { zodResolver } from "@hookform/resolvers/zod";
14 | import { Loader2 } from "lucide-react";
15 | import { useForm } from "react-hook-form";
16 | import { toast } from "sonner";
17 | import { z } from "zod";
18 | import { useServerAction } from "zsa-react";
19 | import { signInLinkMagicAction } from "./actions";
20 |
21 | const magicLinkSchema = z.object({
22 | email: z.string().email({
23 | message: "Enter a valid email address",
24 | }),
25 | });
26 |
27 | type Props = {};
28 |
29 | export const MagicLinkForm = (props: Props) => {
30 | const form = useForm>({
31 | resolver: zodResolver(magicLinkSchema),
32 | defaultValues: {
33 | email: "",
34 | },
35 | });
36 | const { execute, isPending } = useServerAction(signInLinkMagicAction, {
37 | onError({ err }) {
38 | toast.message("Something went wrong");
39 | },
40 | });
41 | async function onSubmit(values: z.infer) {
42 | if (!values.email || !values.email.trim()) {
43 | return;
44 | }
45 | execute(values);
46 | }
47 | return (
48 |
49 |
76 |
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/magic/error/page.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { ExternalLink } from "lucide-react";
3 | import Link from "next/link";
4 |
5 | export default function MagicLinkPage() {
6 | return (
7 |
8 |
15 |
16 | Something went wrong
17 |
18 |
19 | {
20 | "Sorry, this token was either expired or already used. Please try logging in again"
21 | }
22 |
23 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/magic/page.tsx:
--------------------------------------------------------------------------------
1 | import { MailIcon } from "lucide-react";
2 |
3 | export default function MagicLinkPage() {
4 | return (
5 |
6 |
12 |
13 |
14 |
21 |
22 | Check your email
23 |
24 |
25 | {"You're almost there! We've sent you a magic link to sign in."}
26 |
27 |
28 |
29 | Thank you!
30 |
31 |
32 |
33 | {"Just click the link in the email to sign in."}If you {"don't"} see
34 | the email, check your spam{" "}
35 | folder.
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-in/page.tsx:
--------------------------------------------------------------------------------
1 | import { Fingerprint, Mail } from "lucide-react";
2 | import Image from "next/image";
3 | import Link from "next/link";
4 | import { MagicLinkForm } from "./magic-link-form";
5 | import { signIn } from "@/auth";
6 | import { AuthLayout } from "@/components/auth/layout";
7 | type Props = {};
8 |
9 | const SignInPage = (props: Props) => {
10 | return (
11 |
12 |
32 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | Other options
45 |
46 |
47 |
48 |
49 |
53 |
54 | Sign in with Email
55 |
56 |
57 |
58 | );
59 | };
60 |
61 | export default SignInPage;
62 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { unauthenticatedAction } from "@/lib/safe-action";
4 | import { createSessionUseCase, registerUserUseCase } from "@/use-cases/users";
5 | import { redirect } from "next/navigation";
6 | import { z } from "zod";
7 |
8 | export const signUpAction = unauthenticatedAction
9 | .createServerAction()
10 | .input(
11 | z.object({
12 | email: z.string().email(),
13 | password: z.string().min(8),
14 | })
15 | )
16 | .handler(async ({ input }) => {
17 | const user = await registerUserUseCase(input.email, input.password);
18 | await createSessionUseCase(user.id, user.salt);
19 | return redirect("/sign-in/magic");
20 | });
21 |
--------------------------------------------------------------------------------
/src/app/(auth)/sign-up/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AuthLayout } from "@/components/auth/layout";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import React from "react";
6 | import { useForm } from "react-hook-form";
7 | import { z } from "zod";
8 | import { Button } from "@/components/ui/button";
9 | import {
10 | Form,
11 | FormControl,
12 | FormField,
13 | FormItem,
14 | FormLabel,
15 | FormMessage,
16 | } from "@/components/ui/form";
17 | import { Input } from "@/components/ui/input";
18 | import { useServerAction } from "zsa-react";
19 | import { signUpAction } from "./actions";
20 | import { toast } from "sonner";
21 | import { Loader2, Terminal } from "lucide-react";
22 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
23 |
24 | const registrationSchema = z
25 | .object({
26 | email: z.string().email(),
27 | password: z.string().min(8),
28 | passwordConfirmation: z.string().min(8),
29 | })
30 | .refine((data) => data.password === data.passwordConfirmation, {
31 | message: "Passwords don't match",
32 | path: ["passwordConfirmation"],
33 | });
34 |
35 | type Props = {};
36 |
37 | const SignUpPage = (props: Props) => {
38 | const form = useForm>({
39 | resolver: zodResolver(registrationSchema),
40 | defaultValues: {
41 | email: "",
42 | password: "",
43 | passwordConfirmation: "",
44 | },
45 | });
46 | const { execute, isPending, error } = useServerAction(signUpAction, {
47 | onError({ err }) {
48 | toast.error("Something went wrong");
49 | },
50 | });
51 |
52 | function onSubmit(values: z.infer) {
53 | execute(values);
54 | }
55 | return (
56 |
57 |
124 |
125 | );
126 | };
127 |
128 | export default SignUpPage;
129 |
--------------------------------------------------------------------------------
/src/app/(root)/page.tsx:
--------------------------------------------------------------------------------
1 | import { signOut } from "@/auth";
2 | import { Button } from "@/components/ui/button";
3 |
4 | export default function Home() {
5 | return (
6 |
7 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | import { handlers } from "@/auth"
2 | export const { GET, POST } = handlers
--------------------------------------------------------------------------------
/src/app/api/login/magic/route.ts:
--------------------------------------------------------------------------------
1 | import { auth, signIn } from "@/auth";
2 | import prisma from "@/lib/db";
3 | import { loginWithMagicLinkUseCase } from "@/use-cases/magic-link";
4 | import { createSessionUseCase } from "@/use-cases/users";
5 | import { PrismaAdapter } from "@auth/prisma-adapter";
6 | import crypto from "crypto";
7 |
8 | export async function GET(req: Request): Promise {
9 | try {
10 | const url = new URL(req.url);
11 | const token = url.searchParams.get("token");
12 | if (!token) {
13 | return new Response(null, {
14 | status: 302,
15 | headers: {
16 | Location: "/sign-in/magic/error",
17 | },
18 | });
19 | }
20 | const user = await loginWithMagicLinkUseCase(token);
21 | if (!user) {
22 | return new Response(null, {
23 | status: 302,
24 | headers: {
25 | Location: "/sign-in/magic/error",
26 | },
27 | });
28 | }
29 | // create a session
30 | await createSessionUseCase(user.id, user.salt);
31 | return new Response(null, {
32 | status: 302,
33 | headers: {
34 | Location: "/",
35 | },
36 | });
37 | } catch (error) {
38 | console.error("Error signing in with magic link", error);
39 | return new Response(null, {
40 | status: 302,
41 | headers: {
42 | Location: "/sign-in/magic/error",
43 | },
44 | });
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mihir2423/next-js-auth-starter/39a1fc667b788c6fe6acd780673042322f12259f/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/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: 222.2 84% 4.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 222.2 84% 4.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 222.2 84% 4.9%;
13 | --primary: 222.2 47.4% 11.2%;
14 | --primary-foreground: 210 40% 98%;
15 | --secondary: 210 40% 96.1%;
16 | --secondary-foreground: 222.2 47.4% 11.2%;
17 | --muted: 210 40% 96.1%;
18 | --muted-foreground: 215.4 16.3% 46.9%;
19 | --accent: 210 40% 96.1%;
20 | --accent-foreground: 222.2 47.4% 11.2%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 210 40% 98%;
23 | --border: 214.3 31.8% 91.4%;
24 | --input: 214.3 31.8% 91.4%;
25 | --ring: 222.2 84% 4.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 222.2 84% 4.9%;
36 | --foreground: 210 40% 98%;
37 | --card: 222.2 84% 4.9%;
38 | --card-foreground: 210 40% 98%;
39 | --popover: 222.2 84% 4.9%;
40 | --popover-foreground: 210 40% 98%;
41 | --primary: 210 40% 98%;
42 | --primary-foreground: 222.2 47.4% 11.2%;
43 | --secondary: 217.2 32.6% 17.5%;
44 | --secondary-foreground: 210 40% 98%;
45 | --muted: 217.2 32.6% 17.5%;
46 | --muted-foreground: 215 20.2% 65.1%;
47 | --accent: 217.2 32.6% 17.5%;
48 | --accent-foreground: 210 40% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 210 40% 98%;
51 | --border: 217.2 32.6% 17.5%;
52 | --input: 217.2 32.6% 17.5%;
53 | --ring: 212.7 26.8% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | body {
67 | @apply bg-background text-foreground;
68 | }
69 | }
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 | import { Toaster } from "@/components/ui/sonner";
5 |
6 | const inter = Inter({ subsets: ["latin"] });
7 |
8 | export const metadata: Metadata = {
9 | title: "Create Next App",
10 | description: "Generated by create next app",
11 | };
12 |
13 | export default function RootLayout({
14 | children,
15 | }: Readonly<{
16 | children: React.ReactNode;
17 | }>) {
18 | return (
19 |
20 |
21 | {children}
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/auth.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from "next-auth";
2 | import { PrismaAdapter } from "@auth/prisma-adapter";
3 | import prisma from "@/lib/db";
4 | import Google from "next-auth/providers/google";
5 | import CredentialsProvider from "next-auth/providers/credentials"
6 |
7 | export const { handlers, auth, signIn, signOut } = NextAuth({
8 | adapter: PrismaAdapter(prisma),
9 | providers: [
10 | Google,
11 | CredentialsProvider({
12 | name: "Credentials",
13 | credentials: {
14 | id: { label: "ID", type: "text" },
15 | salt: { label: "Salt", type: "text" },
16 | },
17 | async authorize(credentials) {
18 | if (!credentials?.id || !credentials?.salt) return null;
19 | const user = await prisma.user.findUnique({
20 | where: { id: credentials.id as string, salt: credentials.salt as string },
21 | });
22 | if (!user) return null;
23 | return user;
24 | },
25 | }),
26 | ],
27 | callbacks: {
28 | async session({ session, user }) {
29 | if (user) {
30 | session.user = user;
31 | }
32 | return session;
33 | },
34 | },
35 | session: {
36 | strategy: "jwt",
37 | },
38 | });
39 |
--------------------------------------------------------------------------------
/src/components/auth/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Fingerprint } from "lucide-react";
2 | import Link from "next/link";
3 | import React from "react";
4 |
5 | type Props = {
6 | children: React.ReactNode;
7 | type: "Sign-In" | "Sign-Up" | "Forgot Password" | "Reset Password";
8 | text: string;
9 | };
10 |
11 | export const AuthLayout = ({ children, type, text }: Props) => {
12 | return (
13 |
14 |
21 |
28 |
29 |
30 |
31 |
32 | {type}{" "}
33 | {type !== "Forgot Password" &&
34 | type !== "Reset Password" &&
35 | "to Next App"}
36 |
37 |
{text}
38 |
39 | {children}
40 |
41 |
42 |
43 |
44 | {type === "Sign-In"
45 | ? "Don’t have an account?"
46 | : "Already have an account?"}
47 |
48 | {type === "Sign-In" ? "Sign up" : "Sign in"}
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | success:
12 | "bg-success text-success-foreground [&>svg]:text-success-foreground",
13 | default: "bg-background dark:text-foreground",
14 | destructive:
15 | "text-black border-destructive/50 dark:bg-destructive dark:text-destructive-foreground dark:border-destructive [&>svg]:text-destructive-foreground",
16 | },
17 | },
18 | defaultVariants: {
19 | variant: "default",
20 | },
21 | }
22 | );
23 |
24 | const Alert = React.forwardRef<
25 | HTMLDivElement,
26 | React.HTMLAttributes & VariantProps
27 | >(({ className, variant, ...props }, ref) => (
28 |
34 | ));
35 | Alert.displayName = "Alert";
36 |
37 | const AlertTitle = React.forwardRef<
38 | HTMLParagraphElement,
39 | React.HTMLAttributes
40 | >(({ className, ...props }, ref) => (
41 |
46 | ));
47 | AlertTitle.displayName = "AlertTitle";
48 |
49 | const AlertDescription = React.forwardRef<
50 | HTMLParagraphElement,
51 | React.HTMLAttributes
52 | >(({ className, ...props }, ref) => (
53 |
58 | ));
59 | AlertDescription.displayName = "AlertDescription";
60 |
61 | export { Alert, AlertTitle, AlertDescription };
62 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { Slot } from "@radix-ui/react-slot"
6 | import {
7 | Controller,
8 | ControllerProps,
9 | FieldPath,
10 | FieldValues,
11 | FormProvider,
12 | useFormContext,
13 | } from "react-hook-form"
14 |
15 | import { cn } from "@/lib/utils"
16 | import { Label } from "@/components/ui/label"
17 |
18 | const Form = FormProvider
19 |
20 | type FormFieldContextValue<
21 | TFieldValues extends FieldValues = FieldValues,
22 | TName extends FieldPath = FieldPath
23 | > = {
24 | name: TName
25 | }
26 |
27 | const FormFieldContext = React.createContext(
28 | {} as FormFieldContextValue
29 | )
30 |
31 | const FormField = <
32 | TFieldValues extends FieldValues = FieldValues,
33 | TName extends FieldPath = FieldPath
34 | >({
35 | ...props
36 | }: ControllerProps) => {
37 | return (
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | const useFormField = () => {
45 | const fieldContext = React.useContext(FormFieldContext)
46 | const itemContext = React.useContext(FormItemContext)
47 | const { getFieldState, formState } = useFormContext()
48 |
49 | const fieldState = getFieldState(fieldContext.name, formState)
50 |
51 | if (!fieldContext) {
52 | throw new Error("useFormField should be used within ")
53 | }
54 |
55 | const { id } = itemContext
56 |
57 | return {
58 | id,
59 | name: fieldContext.name,
60 | formItemId: `${id}-form-item`,
61 | formDescriptionId: `${id}-form-item-description`,
62 | formMessageId: `${id}-form-item-message`,
63 | ...fieldState,
64 | }
65 | }
66 |
67 | type FormItemContextValue = {
68 | id: string
69 | }
70 |
71 | const FormItemContext = React.createContext(
72 | {} as FormItemContextValue
73 | )
74 |
75 | const FormItem = React.forwardRef<
76 | HTMLDivElement,
77 | React.HTMLAttributes
78 | >(({ className, ...props }, ref) => {
79 | const id = React.useId()
80 |
81 | return (
82 |
83 |
84 |
85 | )
86 | })
87 | FormItem.displayName = "FormItem"
88 |
89 | const FormLabel = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => {
93 | const { error, formItemId } = useFormField()
94 |
95 | return (
96 |
102 | )
103 | })
104 | FormLabel.displayName = "FormLabel"
105 |
106 | const FormControl = React.forwardRef<
107 | React.ElementRef,
108 | React.ComponentPropsWithoutRef
109 | >(({ ...props }, ref) => {
110 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
111 |
112 | return (
113 |
124 | )
125 | })
126 | FormControl.displayName = "FormControl"
127 |
128 | const FormDescription = React.forwardRef<
129 | HTMLParagraphElement,
130 | React.HTMLAttributes
131 | >(({ className, ...props }, ref) => {
132 | const { formDescriptionId } = useFormField()
133 |
134 | return (
135 |
141 | )
142 | })
143 | FormDescription.displayName = "FormDescription"
144 |
145 | const FormMessage = React.forwardRef<
146 | HTMLParagraphElement,
147 | React.HTMLAttributes
148 | >(({ className, children, ...props }, ref) => {
149 | const { error, formMessageId } = useFormField()
150 | const body = error ? String(error?.message) : children
151 |
152 | if (!body) {
153 | return null
154 | }
155 |
156 | return (
157 |
163 | {body}
164 |
165 | )
166 | })
167 | FormMessage.displayName = "FormMessage"
168 |
169 | export {
170 | useFormField,
171 | Form,
172 | FormItem,
173 | FormLabel,
174 | FormControl,
175 | FormDescription,
176 | FormMessage,
177 | FormField,
178 | }
179 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner"
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/src/data-access/magic-links.ts:
--------------------------------------------------------------------------------
1 | import { TOKEN_LENGTH, TOKEN_TTL } from "@/app-config";
2 | import { generateRandomToken } from "./utils";
3 | import prisma from "@/lib/db";
4 |
5 | export async function upsertMagicLink(email: string) {
6 | const token = await generateRandomToken(TOKEN_LENGTH);
7 | const tokenExpiresAt = new Date(Date.now() + TOKEN_TTL);
8 |
9 | // Save the token and its expiration date to the database
10 | await prisma.magicLink.upsert({
11 | where: { email },
12 | update: { token, tokenExpiresAt },
13 | create: { email, token, tokenExpiresAt }
14 | });
15 | return token;
16 | }
17 |
18 | export async function getMagicLinkByToken(token: string) {
19 | // Get the magic link from the database
20 | // (couldn't use prisma.magicLink.findUnique() because it token isn't unique)
21 | const existingToken = await prisma.magicLink.findFirst({
22 | where: { token }
23 | })
24 | return existingToken;
25 | }
26 |
27 | export async function deleteMagicLinkByToken(token: string) {
28 | // Delete the magic link from the database
29 | // (couldn't use prisma.magicLink.delete() because it token isn't unique)
30 | await prisma.magicLink.deleteMany({
31 | where: { token }
32 | })
33 | }
--------------------------------------------------------------------------------
/src/data-access/reset-tokens.ts:
--------------------------------------------------------------------------------
1 | import { TOKEN_LENGTH, TOKEN_TTL } from "@/app-config";
2 | import { generateRandomToken } from "./utils";
3 | import prisma from "@/lib/db";
4 |
5 | export async function createPasswordResetToken(userId: string) {
6 | const token = await generateRandomToken(TOKEN_LENGTH);
7 | const tokenExpiresAt = new Date(Date.now() + TOKEN_TTL);
8 |
9 | await prisma.resetToken.create({
10 | data: {
11 | userId,
12 | token,
13 | tokenExpiresAt,
14 | },
15 | });
16 |
17 | return token;
18 | }
19 |
20 | export async function getPasswordResetToken(token: string) {
21 | const resetToken = await prisma.resetToken.findFirst({
22 | where: { token },
23 | });
24 | return resetToken;
25 | }
26 |
27 | export async function deletePasswordResetToken(id: number, trx = prisma) {
28 | await trx.resetToken.delete({
29 | where: { id },
30 | });
31 | }
32 |
--------------------------------------------------------------------------------
/src/data-access/sessions.ts:
--------------------------------------------------------------------------------
1 | import prisma from "@/lib/db";
2 |
3 | export const deleteSessionUseCase = async (userId: string, trx = prisma) => {
4 | await trx.session.deleteMany({
5 | where: { userId },
6 | });
7 | };
8 |
--------------------------------------------------------------------------------
/src/data-access/users.ts:
--------------------------------------------------------------------------------
1 | import prisma from "@/lib/db";
2 | import { hashPassword } from "@/use-cases/utils";
3 | import crypto from "crypto";
4 |
5 | export async function createUser(
6 | email: string,
7 | password?: string,
8 | salt?: string
9 | ) {
10 | if (password && salt) {
11 | const user = await prisma.user.create({
12 | data: { email, password, salt },
13 | });
14 | return user;
15 | }
16 | const user = await prisma.user.create({
17 | data: { email },
18 | });
19 | return user;
20 | }
21 |
22 | export async function createMagicUser(email: string) {
23 | const salt = crypto.randomBytes(128).toString("base64");
24 | const user = await prisma.user.create({
25 | data: { email, emailVerified: new Date(), salt },
26 | });
27 | await prisma.account.create({
28 | data: {
29 | userId: user.id,
30 | provider: "email",
31 | providerAccountId: email,
32 | type: "email",
33 | },
34 | });
35 | return user;
36 | }
37 |
38 | export async function getUserByEmail(email: string) {
39 | const user = await prisma.user.findUnique({
40 | where: { email },
41 | });
42 | return user;
43 | }
44 |
45 | export async function setEmailVerified(userId: string) {
46 | const user = await prisma.user.update({
47 | where: { id: userId },
48 | data: { emailVerified: new Date() },
49 | });
50 | return user;
51 | }
52 |
53 | export async function verifyPassword(email: string, password: string) {
54 | const user = await getUserByEmail(email);
55 |
56 | if (!user) {
57 | return false;
58 | }
59 | const salt = user.salt;
60 | const savedPass = user.password;
61 | if (!salt || !savedPass) {
62 | return false;
63 | }
64 | const hash = await hashPassword(password, salt);
65 | return hash === savedPass;
66 | }
67 |
68 | export async function updatePassword(
69 | userId: string,
70 | password: string,
71 | trx = prisma
72 | ) {
73 | const salt = crypto.randomBytes(128).toString("base64");
74 | const hash = await hashPassword(password, salt);
75 | await trx.user.update({
76 | where: { id: userId },
77 | data: { password: hash, salt },
78 | });
79 | }
80 |
--------------------------------------------------------------------------------
/src/data-access/utils.ts:
--------------------------------------------------------------------------------
1 | import prisma from "@/lib/db";
2 | import crypto from "crypto";
3 |
4 | /** Generate a random token of the specified length, using crypto.randomBytes function
5 | * that first creates a buffer of random bytes and then converts it to a hex string.
6 | * @param length The length of the token to generate.
7 | */
8 | export async function generateRandomToken(length: number) {
9 | const buf = await new Promise((resolve, reject) => {
10 | crypto.randomBytes(Math.ceil(length / 2), (err, buf) => {
11 | if (err !== null) {
12 | reject(err);
13 | } else {
14 | resolve(buf);
15 | }
16 | });
17 | });
18 |
19 | return buf.toString("hex").slice(0, length);
20 | }
21 |
22 | export async function createTransaction(
23 | cb: (trx: T) => void
24 | ) {
25 | await prisma.$transaction(cb as any);
26 | }
27 |
--------------------------------------------------------------------------------
/src/emails/magic-link.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import {
4 | Body,
5 | Container,
6 | Head,
7 | Hr,
8 | Html,
9 | Img,
10 | Link,
11 | Preview,
12 | Section,
13 | Tailwind,
14 | Text,
15 | } from "@react-email/components";
16 |
17 | import { applicationName } from "@/app-config";
18 |
19 | export const BASE_URL = process.env.HOST_NAME;
20 |
21 | export function MagicLinkEmail({ token }: { token: string }) {
22 | const previewText = `You're been invted to a group!`;
23 | return (
24 |
25 |
26 | {previewText}
27 |
28 |
29 |
30 |
31 |
32 |
40 |
41 |
42 |
43 |
44 | Your magic link login is below, Click to login.
45 |
46 |
47 |
48 |
53 | Login from here!
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | © 2024 {applicationName}. All rights reserved.
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/emails/reset-password.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import {
4 | Body,
5 | Container,
6 | Head,
7 | Hr,
8 | Html,
9 | Img,
10 | Link,
11 | Preview,
12 | Section,
13 | Tailwind,
14 | Text,
15 | } from "@react-email/components";
16 |
17 | import { applicationName } from "@/app-config";
18 |
19 | export const BASE_URL = process.env.HOST_NAME;
20 |
21 | export function ResetPasswordEmail({ token }: { token: string }) {
22 | const previewText = `You're been invted to a group!`;
23 | return (
24 |
25 |
26 | {previewText}
27 |
28 |
29 |
30 |
31 |
32 |
40 |
41 |
42 |
43 |
44 | Click the following link to reset your password
45 |
46 |
47 |
48 |
53 | Reset Password
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | © 2024 {applicationName}. All rights reserved.
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/lib/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client'
2 |
3 | const prismaClientSingleton = () => {
4 | return new PrismaClient()
5 | }
6 |
7 | declare const globalThis: {
8 | prismaGlobal: ReturnType;
9 | } & typeof global;
10 |
11 | const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
12 |
13 | export default prisma
14 |
15 | if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
--------------------------------------------------------------------------------
/src/lib/safe-action.ts:
--------------------------------------------------------------------------------
1 | import { PublicError } from "@/use-cases/errors";
2 | import { assertAuthenticated } from "./session";
3 | import { createServerActionProcedure } from "zsa";
4 |
5 | function shapeErrors({ err }: any) {
6 | const isAllowedError = err instanceof PublicError;
7 | const isDev = process.env.NODE_ENV === "development";
8 | if (isDev && !isAllowedError) {
9 | console.error(err);
10 | return {
11 | code: err.code ?? "Error",
12 | message: `${!isAllowedError && isDev ? "DEV ONLY ENABLED - " : ""}${err.message
13 | }`,
14 | }
15 | } else {
16 | return {
17 | code: "Error",
18 | message: "Something went wrong!",
19 | }
20 | }
21 | }
22 |
23 | export const authenticatedAction = createServerActionProcedure()
24 | .experimental_shapeError(shapeErrors)
25 | .handler(async () => {
26 | const user = await assertAuthenticated();
27 | return { user };
28 | });
29 |
30 | export const unauthenticatedAction = createServerActionProcedure()
31 | .experimental_shapeError(shapeErrors)
32 | .handler(async () => {
33 | });
--------------------------------------------------------------------------------
/src/lib/send-email.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@react-email/components';
2 | import nodemailer from 'nodemailer';
3 |
4 | export async function sendEmail(
5 | email: string,
6 | subject: string,
7 | html: any
8 | ) {
9 | if (!process.env.NEXT_PUBLIC_NODEMAILER_EMAIL) {
10 | throw new Error("EMAIL_FROM is not defined")
11 | }
12 | const transporter = nodemailer.createTransport({
13 | service: 'gmail',
14 | secure: true,
15 | auth: {
16 | user: process.env.NEXT_PUBLIC_NODEMAILER_EMAIL,
17 | pass: process.env.NEXT_PUBLIC_NODEMAILER_PW,
18 | },
19 | });
20 |
21 | const emailHtml = render(html);
22 |
23 | try {
24 | const options = {
25 | from: process.env.NEXT_PUBLIC_NODEMAILER_EMAIL,
26 | to: email,
27 | subject: subject,
28 | html: emailHtml,
29 | };
30 | await transporter.sendMail(options as any);
31 | } catch (error) {
32 | console.error("Error sending email", error)
33 | }
34 | }
--------------------------------------------------------------------------------
/src/lib/session.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import { auth } from "@/auth";
3 | import { AuthenticationError } from "./utils";
4 |
5 | export const getCurrentUser = async () => {
6 | const session = await auth();
7 | if (!session || !session.user) {
8 | return undefined;
9 | }
10 | return session.user;
11 | };
12 |
13 | export const assertAuthenticated = async () => {
14 | const user = await getCurrentUser();
15 | if (!user) {
16 | throw new AuthenticationError();
17 | }
18 | return user;
19 | };
20 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
8 | export const AUTHENTICATION_ERROR_MESSAGE =
9 | "You must be logged in to view this content";
10 |
11 | export const AuthenticationError = class AuthenticationError extends Error {
12 | constructor() {
13 | super(AUTHENTICATION_ERROR_MESSAGE);
14 | this.name = "AuthenticationError";
15 | }
16 | };
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 |
2 | import { DEFAULT_LOGIN_REDIRECT, authRoutes } from "@/routes";
3 | import { NextResponse } from "next/server";
4 | import { auth } from "@/auth";
5 |
6 | export default auth((req) => {
7 | const { nextUrl } = req;
8 |
9 | const isAuthenticated = !!req.auth;
10 | console.log("isAuthenticated", isAuthenticated);
11 |
12 |
13 | const isAuthRoute = authRoutes.includes(nextUrl.pathname);
14 | if (nextUrl.pathname.startsWith('/api')) {
15 | return NextResponse.next(); // Allow the request to proceed
16 | }
17 |
18 | if (isAuthRoute && isAuthenticated)
19 | return NextResponse.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl));
20 |
21 | if (!isAuthenticated && !isAuthRoute)
22 | return NextResponse.redirect(new URL("/sign-in", nextUrl));
23 | });
24 |
25 | export const config = {
26 | matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'],
27 | }
28 |
--------------------------------------------------------------------------------
/src/routes.ts:
--------------------------------------------------------------------------------
1 | /*
2 | @type {string[]}
3 | */
4 |
5 | export const authRoutes = [
6 | "/sign-in",
7 | "/sign-in/email",
8 | "/sign-in/forgot-password",
9 | "/sign-in/magic",
10 | "/sign-in/magic/error",
11 | "/sign-up",
12 | "/reset-password",
13 | ];
14 |
15 | export const DEFAULT_LOGIN_REDIRECT = "/";
16 |
--------------------------------------------------------------------------------
/src/styles/icons.ts:
--------------------------------------------------------------------------------
1 | export const btnIconStyles = "w-4 h-4";
2 |
3 | export const btnStyles = "flex gap-2 items-center";
4 |
5 | export const socialIconStyles =
6 | "cursor-pointer dark:hover:fill-slate-400 w-8 h-8 dark:fill-white";
--------------------------------------------------------------------------------
/src/use-cases/errors.ts:
--------------------------------------------------------------------------------
1 | export class PublicError extends Error {
2 | constructor(message: string) {
3 | super(message);
4 | }
5 | }
6 |
7 | export class AuthenticationError extends PublicError {
8 | constructor() {
9 | super("You must be logged in to view this content");
10 | this.name = "AuthenticationError";
11 | }
12 | }
13 |
14 | export class EmailInUseError extends PublicError {
15 | constructor() {
16 | super("Email is already in use");
17 | this.name = "EmailInUseError";
18 | }
19 | }
20 |
21 | export class NotFoundError extends PublicError {
22 | constructor() {
23 | super("Resource not found");
24 | this.name = "NotFoundError";
25 | }
26 | }
27 |
28 | export class TokenExpiredError extends PublicError {
29 | constructor() {
30 | super("Token has expired");
31 | this.name = "TokenExpiredError";
32 | }
33 | }
34 |
35 | export class LoginError extends PublicError {
36 | constructor() {
37 | super("Invalid email or password");
38 | this.name = "LoginError";
39 | }
40 | }
--------------------------------------------------------------------------------
/src/use-cases/magic-link.tsx:
--------------------------------------------------------------------------------
1 | import { applicationName } from "@/app-config";
2 | import {
3 | deleteMagicLinkByToken,
4 | getMagicLinkByToken,
5 | upsertMagicLink,
6 | } from "@/data-access/magic-links";
7 | import {
8 | createMagicUser,
9 | getUserByEmail,
10 | setEmailVerified,
11 | } from "@/data-access/users";
12 | import { MagicLinkEmail } from "@/emails/magic-link";
13 | import { sendEmail } from "@/lib/send-email";
14 |
15 | export async function sendMagicLinkUseCase(email: string) {
16 | const token = await upsertMagicLink(email);
17 | try {
18 | await sendEmail(
19 | email,
20 | `Your magic login link for ${applicationName}`,
21 | MagicLinkEmail({ token })
22 | );
23 | } catch (error) {
24 | console.error("Error sending email from magic-link.tsx:", error);
25 |
26 | // Redirect to the fallback route
27 | return { redirect: "/sign-in/magic/email" };
28 | }
29 | }
30 |
31 | export async function loginWithMagicLinkUseCase(token: string) {
32 | const magicLinkInfo = await getMagicLinkByToken(token);
33 | if (!magicLinkInfo) {
34 | throw new Error("Token not found");
35 | }
36 |
37 | if (magicLinkInfo.tokenExpiresAt! < new Date()) {
38 | throw new Error("Token expired");
39 | }
40 | const existingUser = await getUserByEmail(magicLinkInfo.email);
41 | if (existingUser) {
42 | await setEmailVerified(existingUser.id);
43 | await deleteMagicLinkByToken(token);
44 | return existingUser;
45 | } else {
46 | const newUser = await createMagicUser(magicLinkInfo.email);
47 | await deleteMagicLinkByToken(token);
48 | return newUser;
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/use-cases/users.tsx:
--------------------------------------------------------------------------------
1 | import { applicationName } from "@/app-config";
2 | import { signIn } from "@/auth";
3 | import { upsertMagicLink } from "@/data-access/magic-links";
4 | import {
5 | createPasswordResetToken,
6 | deletePasswordResetToken,
7 | getPasswordResetToken,
8 | } from "@/data-access/reset-tokens";
9 | import { deleteSessionUseCase } from "@/data-access/sessions";
10 | import {
11 | createUser,
12 | getUserByEmail,
13 | updatePassword,
14 | verifyPassword,
15 | } from "@/data-access/users";
16 | import { createTransaction } from "@/data-access/utils";
17 | import { MagicLinkEmail } from "@/emails/magic-link";
18 | import { ResetPasswordEmail } from "@/emails/reset-password";
19 | import { sendEmail } from "@/lib/send-email";
20 | import crypto from "crypto";
21 | import { LoginError } from "./errors";
22 | import { hashPassword } from "./utils";
23 |
24 | export async function registerUserUseCase(email: string, password: string) {
25 | const existingUser = await getUserByEmail(email);
26 | if (existingUser) {
27 | throw new Error("Email is already in use");
28 | }
29 | const salt = crypto.randomBytes(128).toString("base64");
30 | const hash = await hashPassword(password, salt);
31 | const user = await createUser(email, hash, salt);
32 | if (!user) {
33 | throw new Error("Error creating user");
34 | }
35 | console.log("User created", user.id);
36 | const token = await upsertMagicLink(email);
37 | console.log("Verify email token created", token);
38 |
39 | await sendEmail(
40 | email,
41 | `Your magic link for ${applicationName}`,
42 | MagicLinkEmail({ token })
43 | );
44 | return { id: user.id, salt };
45 | }
46 |
47 | export const createSessionUseCase = async (
48 | userId: string,
49 | salt: string | null
50 | ) => {
51 | await signIn("credentials", { id: userId, salt, redirect: false });
52 | };
53 |
54 | export async function signInUseCase(email: string, password: string) {
55 | const user = await getUserByEmail(email);
56 |
57 | if (!user) {
58 | throw new LoginError();
59 | }
60 | const isPasswordCorrect = await verifyPassword(email, password);
61 | if (!isPasswordCorrect) {
62 | throw new LoginError();
63 | }
64 | return user;
65 | }
66 |
67 | export async function resetPasswordUseCase(email: string) {
68 | const user = await getUserByEmail(email);
69 |
70 | if (!user) {
71 | return null;
72 | }
73 |
74 | const token = await createPasswordResetToken(user.id);
75 |
76 | await sendEmail(
77 | email,
78 | `Your password reset link for ${applicationName}`,
79 |
80 | );
81 | }
82 |
83 | export async function changePasswordUseCase(token: string, password: string) {
84 | const resetToken = await getPasswordResetToken(token);
85 | if (!resetToken) {
86 | throw new Error("Invalid token");
87 | }
88 |
89 | const userId = resetToken.userId;
90 | const id = resetToken.id;
91 | await createTransaction(async (trx) => {
92 | await deletePasswordResetToken(id, trx);
93 | await updatePassword(userId, password, trx);
94 | await deleteSessionUseCase(userId, trx);
95 | });
96 | }
97 |
--------------------------------------------------------------------------------
/src/use-cases/utils.ts:
--------------------------------------------------------------------------------
1 | import crypto from "crypto";
2 |
3 | const ITERATIONS = 10000;
4 |
5 | async function hashPassword(plainTextPassword: string, salt: string) {
6 | return new Promise((resolve, reject) => {
7 | crypto.pbkdf2(
8 | plainTextPassword,
9 | salt,
10 | ITERATIONS,
11 | 64,
12 | "sha512",
13 | (err, derivedKey) => {
14 | if (err) reject(err);
15 | resolve(derivedKey.toString("hex"));
16 | }
17 | );
18 | });
19 | }
20 |
21 | export { hashPassword };
22 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------