├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── CHECKLIST.md ├── README.md ├── components.json ├── drizzle.config.ts ├── migrations ├── 0000_mean_vector.sql ├── 0001_greedy_red_shift.sql └── meta │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ └── _journal.json ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── src ├── actions │ ├── admin │ │ ├── change-user-role-action.ts │ │ └── toggle-email-verified-action.ts │ ├── create-verification-token-action.ts │ ├── forgot-password-action.ts │ ├── mail │ │ ├── send-forgot-password-email.ts │ │ └── send-signup-user-email.ts │ ├── oauth-signin-action.ts │ ├── oauth-verify-email-action.ts │ ├── reset-password-action.ts │ ├── signin-user-action.ts │ ├── signout-user-action.ts │ ├── signup-user-action.ts │ ├── update-user-info-action.ts │ └── verify-credentials-email-action.ts ├── app │ ├── api │ │ └── auth │ │ │ └── [...nextauth] │ │ │ └── route.ts │ ├── auth │ │ ├── layout.tsx │ │ ├── signin │ │ │ ├── _components │ │ │ │ ├── forgot-password-form.tsx │ │ │ │ └── signin-form.tsx │ │ │ ├── forgot-password │ │ │ │ ├── _components │ │ │ │ │ └── reset-password-form.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ └── reset-password │ │ │ │ └── success │ │ │ │ └── page.tsx │ │ └── signup │ │ │ ├── _components │ │ │ └── signup-form.tsx │ │ │ ├── page.tsx │ │ │ ├── success │ │ │ └── page.tsx │ │ │ └── verify-email │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── profile │ │ ├── _components │ │ └── update-user-info-form.tsx │ │ ├── admin-panel │ │ ├── _components │ │ │ ├── change-user-role-input.tsx │ │ │ └── toggle-email-verified-input.tsx │ │ └── page.tsx │ │ └── page.tsx ├── auth.config.ts ├── auth.ts ├── components │ ├── navbar-links.tsx │ ├── navbar.tsx │ ├── oauth-signin-buttons.tsx │ ├── providers.tsx │ ├── signout-button.tsx │ └── ui │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ └── label.tsx ├── drizzle │ ├── index.ts │ └── schema.ts ├── lib │ ├── constants.ts │ ├── custom-errors.ts │ ├── nodemailer.ts │ └── utils.ts ├── middleware.ts ├── resources │ ├── admin-user-email-address-queries.ts │ ├── user-queries.ts │ └── verification-token-queries.ts ├── types │ ├── auth-core.d.ts │ └── next-auth.d.ts └── validators │ ├── forgot-password-validator.ts │ ├── reset-password-validator.ts │ ├── signin-validator.ts │ ├── signup-validator.ts │ └── update-user-info-validator.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="FILL_ME" 2 | 3 | AUTH_SECRET="FILL_ME" 4 | 5 | AUTH_URL="FILL_ME" 6 | 7 | GOOGLE_CLIENT_ID="FILL_ME" 8 | GOOGLE_CLIENT_SECRET="FILL_ME" 9 | 10 | GITHUB_CLIENT_ID="FILL_ME" 11 | GITHUB_CLIENT_SECRET="FILL_ME" 12 | 13 | # ADMIN_EMAIL_ADDRESSES="FILL_ME" 14 | 15 | NODEMAILER_GOOGLE_SMTP_USER="FILL_ME" 16 | NODEMAILER_GOOGLE_ACCESS_TOKEN="FILL_ME" 17 | NODEMAILER_GOOGLE_REFRESH_TOKEN="FILL_ME" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /CHECKLIST.md: -------------------------------------------------------------------------------- 1 | == PART 1 == 2 | 3 | [X] Create Next.js App WITH CLI 4 | [X] Clean up files 5 | [X] Install shadcn/ui 6 | [X] Create Navbar 7 | [X] Install shadcn button, form, input 8 | [X] Create signup page 9 | [X] Install valibot 10 | [X] Create signup validator 11 | [X] Create signup form 12 | [X] Create signup server action 13 | [X] Handle errors 14 | [X] Hash password 15 | [X] Create signin page 16 | [X] Create signin validator 17 | [X] Create signin form 18 | [X] Create signin server action 19 | 20 | == PART 2 == 21 | 22 | [X] Install prettier plugin tailwindcss 23 | [X] Install Drizzle ORM / Drizzle Kit 24 | [X] Create Drizzle Config 25 | [X] Create Drizzle Schema 26 | [X] Install Auth.js 27 | [X] Update drizzle schema (password, role, email uniqueness) 28 | [X] Setup Postgres with NeonDB 29 | [X] Create helper scripts 30 | [X] Show how to view data (drizzle studio, neon console) 31 | [X] Create drizzle singleton 32 | [X] Signup our first user 33 | [X] Handle email conflicts 34 | [X] Create success message 35 | 36 | == PART 3 == 37 | 38 | [X] Redirect Links 39 | [X] Setup AuthJS config 40 | [X] Auth Secret, Auth Url 41 | [X] Partial Signin - Parse Credentials 42 | [X] User Queries 43 | [X] `server-only` package 44 | [X] Partial Signin - Log User 45 | [X] Complete Signin 46 | [X] Error Handling 47 | [X] Create /profile 48 | [X] Redirect to /profile 49 | [X] Session info server side (auth function) 50 | [X] Display information as table 51 | [X] Create signout button 52 | [X] Signout action 53 | [X] Discussion: Next.js Client vs Server Components 54 | [X] Navbar Links 55 | [X] Session server side in NavbarLinks 56 | [X] Session client side in NavbarLinks 57 | [X] Session Provider 58 | [X] Route Handlers 59 | [X] Full Signin Signout flow 60 | 61 | == PART 4 == 62 | 63 | [X] Setup Google OAuth 64 | [X] Install Simple Icons 65 | [X] Create OAuth Signin Buttons 66 | [X] Test Google SignIn 67 | [X] Setup Github OAuth 68 | [X] Test Github SignIn 69 | [X] Drizzle Adapter 70 | 71 | Drizzle Adapter Source Code 72 | https://github.com/nextauthjs/next-auth/blob/main/packages/adapter-drizzle/src/lib/pg.ts 73 | 74 | [X] AuthJS Callbacks 75 | [X] Extend Session Information 76 | [X] Extend Typescript Types 77 | [X] Update Drizzle Adapter 78 | [X] `@auth/core` 79 | [X] AuthJS Events 80 | 81 | == PART 5 == 82 | 83 | [X] Working with user from database instead of session 84 | [X] Updating the session (schema, server action) 85 | [X] JWT Callback (trigger) 86 | [X] Verify With SignIn Callback 87 | 88 | _CASE 1_ 89 | If I made an account with an OAuth Provider (Google or Github) 90 | AND I am **LOGGED OUT** 91 | I cannot login with the other OAuth Provider that has the same EMAIL 92 | 93 | e.g. Create account with Github for email `giraffereactor@gmail.com` 94 | THEN I cannot login with Google for email `giraffereactor@gmail.com` 95 | 96 | BECAUSE of `allowDangerousEmailAccountLinking = false` 97 | 98 | _CASE 2_ 99 | If I made an account with OAuth Provider (Google or Github) 100 | AND I am **LOGGED OUT** 101 | I cannot login with the Credentials Provider that has the same EMAIL 102 | 103 | e.g. Create account with Github for email `giraffereactor@gmail.com` 104 | THEN I cannot login with Credentials for email `giraffereactor@gmail.com` 105 | 106 | BECAUSE in `auth.ts` my `authorize` has the statement `if (!user.password) return null` 107 | AND accounts created with OAuth Providers have no password. 108 | 109 | [X] Custom Errors 110 | [X] allowDangerousEmailAccountLinking 111 | 112 | We will set `allowDangerousEmailAccountLinking = true` 113 | 114 | **NOW - IF WE ARE LOGGED OUT** 115 | if we make an acc with credentials -> we can link google and/or github to that same email 116 | if we make an acc with google -> we can link github to that same email 117 | if we make an acc with github -> we can link google to that same email 118 | 119 | [X] DISCUSSION about **LOGGED IN** and account linking 120 | [X] Protect Pages With Auth 121 | [X] Authorized Callback 122 | [X] Protect Pages With Middleware 123 | [X] The Problem With The Crypto Module 124 | [X] The `auth.config.ts` File 125 | 126 | == PART 6 == 127 | 128 | [X] User Roles 129 | [X] How to create an admin user 130 | [X] Option 1 - The process.env method 131 | [X] Option 2 - The process.env method (extended) 132 | [X] Option 3 - Manually edit in SQL Console 133 | [X] Option 4 - Create Admin Email Table 134 | (continue from here) 135 | [X] Option 5 - ADMIN Dashboard 136 | [X] Email Verification Checkbox 137 | [X] Email Verification Action 138 | [X] Role Change Select 139 | [X] Role Change Action 140 | [X] Discussion - Don't allow admins to edit each other 141 | 142 | [X] Email Time - Nodemailer 143 | [X] Google Cloud Console 144 | [X] OAuth Playground 145 | [X] Setup Transporter (Nodemailer Instance) 146 | [X] Verification Token Action 147 | [X] Discussion Delete Verification Tokens ** 148 | [X] Auth Config Deny Email Verification 149 | [X] Forgot Password UI 150 | [X] Forgot Password Action (deter OAauth) 151 | [X] Reset Password Form 152 | [X] Reverify in Server Action 153 | [X] Again with ** 154 | 155 | Extensions: 156 | 157 | TRY THESE 158 | 159 | 1. delete user (as an admin) 160 | 2. manage verification tokens (delete them as needed) 161 | 3. add update to user.images 162 | 4. add another OAuth provider 163 | 5. add a third role 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | import type { Config } from "drizzle-kit"; 3 | 4 | const DATABASE_URL = process.env.DATABASE_URL ?? ""; 5 | 6 | const drizzleConfig = { 7 | schema: "./src/drizzle/schema.ts", 8 | out: "./migrations", 9 | dialect: "postgresql", 10 | dbCredentials: { url: DATABASE_URL }, 11 | } satisfies Config; 12 | 13 | export default defineConfig(drizzleConfig); 14 | -------------------------------------------------------------------------------- /migrations/0000_mean_vector.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "public"."role" AS ENUM('user', 'admin'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | CREATE TABLE IF NOT EXISTS "account" ( 8 | "userId" text NOT NULL, 9 | "type" text NOT NULL, 10 | "provider" text NOT NULL, 11 | "providerAccountId" text NOT NULL, 12 | "refresh_token" text, 13 | "access_token" text, 14 | "expires_at" integer, 15 | "token_type" text, 16 | "scope" text, 17 | "id_token" text, 18 | "session_state" text, 19 | CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId") 20 | ); 21 | --> statement-breakpoint 22 | CREATE TABLE IF NOT EXISTS "authenticator" ( 23 | "credentialID" text NOT NULL, 24 | "userId" text NOT NULL, 25 | "providerAccountId" text NOT NULL, 26 | "credentialPublicKey" text NOT NULL, 27 | "counter" integer NOT NULL, 28 | "credentialDeviceType" text NOT NULL, 29 | "credentialBackedUp" boolean NOT NULL, 30 | "transports" text, 31 | CONSTRAINT "authenticator_userId_credentialID_pk" PRIMARY KEY("userId","credentialID"), 32 | CONSTRAINT "authenticator_credentialID_unique" UNIQUE("credentialID") 33 | ); 34 | --> statement-breakpoint 35 | CREATE TABLE IF NOT EXISTS "session" ( 36 | "sessionToken" text PRIMARY KEY NOT NULL, 37 | "userId" text NOT NULL, 38 | "expires" timestamp NOT NULL 39 | ); 40 | --> statement-breakpoint 41 | CREATE TABLE IF NOT EXISTS "user" ( 42 | "id" text PRIMARY KEY NOT NULL, 43 | "name" text, 44 | "email" text NOT NULL, 45 | "emailVerified" timestamp, 46 | "image" text, 47 | "password" text, 48 | "role" "role" DEFAULT 'user' NOT NULL 49 | ); 50 | --> statement-breakpoint 51 | CREATE TABLE IF NOT EXISTS "verificationToken" ( 52 | "identifier" text NOT NULL, 53 | "token" text NOT NULL, 54 | "expires" timestamp NOT NULL, 55 | CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token") 56 | ); 57 | --> statement-breakpoint 58 | DO $$ BEGIN 59 | ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 60 | EXCEPTION 61 | WHEN duplicate_object THEN null; 62 | END $$; 63 | --> statement-breakpoint 64 | DO $$ BEGIN 65 | ALTER TABLE "authenticator" ADD CONSTRAINT "authenticator_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 66 | EXCEPTION 67 | WHEN duplicate_object THEN null; 68 | END $$; 69 | --> statement-breakpoint 70 | DO $$ BEGIN 71 | ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 72 | EXCEPTION 73 | WHEN duplicate_object THEN null; 74 | END $$; 75 | --> statement-breakpoint 76 | CREATE UNIQUE INDEX IF NOT EXISTS "emailUniqueIndex" ON "user" USING btree (lower("email")); -------------------------------------------------------------------------------- /migrations/0001_greedy_red_shift.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "adminUserEmailAddresses" ( 2 | "id" text PRIMARY KEY NOT NULL, 3 | "email" text NOT NULL 4 | ); 5 | --> statement-breakpoint 6 | CREATE UNIQUE INDEX IF NOT EXISTS "adminEmailUniqueIndex" ON "adminUserEmailAddresses" USING btree (lower("email")); -------------------------------------------------------------------------------- /migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "9f782d31-52fd-4586-a80e-1b7da32bb65c", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.account": { 8 | "name": "account", 9 | "schema": "", 10 | "columns": { 11 | "userId": { 12 | "name": "userId", 13 | "type": "text", 14 | "primaryKey": false, 15 | "notNull": true 16 | }, 17 | "type": { 18 | "name": "type", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "provider": { 24 | "name": "provider", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "providerAccountId": { 30 | "name": "providerAccountId", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "refresh_token": { 36 | "name": "refresh_token", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "access_token": { 42 | "name": "access_token", 43 | "type": "text", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "expires_at": { 48 | "name": "expires_at", 49 | "type": "integer", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "token_type": { 54 | "name": "token_type", 55 | "type": "text", 56 | "primaryKey": false, 57 | "notNull": false 58 | }, 59 | "scope": { 60 | "name": "scope", 61 | "type": "text", 62 | "primaryKey": false, 63 | "notNull": false 64 | }, 65 | "id_token": { 66 | "name": "id_token", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": false 70 | }, 71 | "session_state": { 72 | "name": "session_state", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": false 76 | } 77 | }, 78 | "indexes": {}, 79 | "foreignKeys": { 80 | "account_userId_user_id_fk": { 81 | "name": "account_userId_user_id_fk", 82 | "tableFrom": "account", 83 | "tableTo": "user", 84 | "columnsFrom": [ 85 | "userId" 86 | ], 87 | "columnsTo": [ 88 | "id" 89 | ], 90 | "onDelete": "cascade", 91 | "onUpdate": "no action" 92 | } 93 | }, 94 | "compositePrimaryKeys": { 95 | "account_provider_providerAccountId_pk": { 96 | "name": "account_provider_providerAccountId_pk", 97 | "columns": [ 98 | "provider", 99 | "providerAccountId" 100 | ] 101 | } 102 | }, 103 | "uniqueConstraints": {} 104 | }, 105 | "public.authenticator": { 106 | "name": "authenticator", 107 | "schema": "", 108 | "columns": { 109 | "credentialID": { 110 | "name": "credentialID", 111 | "type": "text", 112 | "primaryKey": false, 113 | "notNull": true 114 | }, 115 | "userId": { 116 | "name": "userId", 117 | "type": "text", 118 | "primaryKey": false, 119 | "notNull": true 120 | }, 121 | "providerAccountId": { 122 | "name": "providerAccountId", 123 | "type": "text", 124 | "primaryKey": false, 125 | "notNull": true 126 | }, 127 | "credentialPublicKey": { 128 | "name": "credentialPublicKey", 129 | "type": "text", 130 | "primaryKey": false, 131 | "notNull": true 132 | }, 133 | "counter": { 134 | "name": "counter", 135 | "type": "integer", 136 | "primaryKey": false, 137 | "notNull": true 138 | }, 139 | "credentialDeviceType": { 140 | "name": "credentialDeviceType", 141 | "type": "text", 142 | "primaryKey": false, 143 | "notNull": true 144 | }, 145 | "credentialBackedUp": { 146 | "name": "credentialBackedUp", 147 | "type": "boolean", 148 | "primaryKey": false, 149 | "notNull": true 150 | }, 151 | "transports": { 152 | "name": "transports", 153 | "type": "text", 154 | "primaryKey": false, 155 | "notNull": false 156 | } 157 | }, 158 | "indexes": {}, 159 | "foreignKeys": { 160 | "authenticator_userId_user_id_fk": { 161 | "name": "authenticator_userId_user_id_fk", 162 | "tableFrom": "authenticator", 163 | "tableTo": "user", 164 | "columnsFrom": [ 165 | "userId" 166 | ], 167 | "columnsTo": [ 168 | "id" 169 | ], 170 | "onDelete": "cascade", 171 | "onUpdate": "no action" 172 | } 173 | }, 174 | "compositePrimaryKeys": { 175 | "authenticator_userId_credentialID_pk": { 176 | "name": "authenticator_userId_credentialID_pk", 177 | "columns": [ 178 | "userId", 179 | "credentialID" 180 | ] 181 | } 182 | }, 183 | "uniqueConstraints": { 184 | "authenticator_credentialID_unique": { 185 | "name": "authenticator_credentialID_unique", 186 | "nullsNotDistinct": false, 187 | "columns": [ 188 | "credentialID" 189 | ] 190 | } 191 | } 192 | }, 193 | "public.session": { 194 | "name": "session", 195 | "schema": "", 196 | "columns": { 197 | "sessionToken": { 198 | "name": "sessionToken", 199 | "type": "text", 200 | "primaryKey": true, 201 | "notNull": true 202 | }, 203 | "userId": { 204 | "name": "userId", 205 | "type": "text", 206 | "primaryKey": false, 207 | "notNull": true 208 | }, 209 | "expires": { 210 | "name": "expires", 211 | "type": "timestamp", 212 | "primaryKey": false, 213 | "notNull": true 214 | } 215 | }, 216 | "indexes": {}, 217 | "foreignKeys": { 218 | "session_userId_user_id_fk": { 219 | "name": "session_userId_user_id_fk", 220 | "tableFrom": "session", 221 | "tableTo": "user", 222 | "columnsFrom": [ 223 | "userId" 224 | ], 225 | "columnsTo": [ 226 | "id" 227 | ], 228 | "onDelete": "cascade", 229 | "onUpdate": "no action" 230 | } 231 | }, 232 | "compositePrimaryKeys": {}, 233 | "uniqueConstraints": {} 234 | }, 235 | "public.user": { 236 | "name": "user", 237 | "schema": "", 238 | "columns": { 239 | "id": { 240 | "name": "id", 241 | "type": "text", 242 | "primaryKey": true, 243 | "notNull": true 244 | }, 245 | "name": { 246 | "name": "name", 247 | "type": "text", 248 | "primaryKey": false, 249 | "notNull": false 250 | }, 251 | "email": { 252 | "name": "email", 253 | "type": "text", 254 | "primaryKey": false, 255 | "notNull": true 256 | }, 257 | "emailVerified": { 258 | "name": "emailVerified", 259 | "type": "timestamp", 260 | "primaryKey": false, 261 | "notNull": false 262 | }, 263 | "image": { 264 | "name": "image", 265 | "type": "text", 266 | "primaryKey": false, 267 | "notNull": false 268 | }, 269 | "password": { 270 | "name": "password", 271 | "type": "text", 272 | "primaryKey": false, 273 | "notNull": false 274 | }, 275 | "role": { 276 | "name": "role", 277 | "type": "role", 278 | "typeSchema": "public", 279 | "primaryKey": false, 280 | "notNull": true, 281 | "default": "'user'" 282 | } 283 | }, 284 | "indexes": { 285 | "emailUniqueIndex": { 286 | "name": "emailUniqueIndex", 287 | "columns": [ 288 | { 289 | "expression": "lower(\"email\")", 290 | "asc": true, 291 | "isExpression": true, 292 | "nulls": "last" 293 | } 294 | ], 295 | "isUnique": true, 296 | "concurrently": false, 297 | "method": "btree", 298 | "with": {} 299 | } 300 | }, 301 | "foreignKeys": {}, 302 | "compositePrimaryKeys": {}, 303 | "uniqueConstraints": {} 304 | }, 305 | "public.verificationToken": { 306 | "name": "verificationToken", 307 | "schema": "", 308 | "columns": { 309 | "identifier": { 310 | "name": "identifier", 311 | "type": "text", 312 | "primaryKey": false, 313 | "notNull": true 314 | }, 315 | "token": { 316 | "name": "token", 317 | "type": "text", 318 | "primaryKey": false, 319 | "notNull": true 320 | }, 321 | "expires": { 322 | "name": "expires", 323 | "type": "timestamp", 324 | "primaryKey": false, 325 | "notNull": true 326 | } 327 | }, 328 | "indexes": {}, 329 | "foreignKeys": {}, 330 | "compositePrimaryKeys": { 331 | "verificationToken_identifier_token_pk": { 332 | "name": "verificationToken_identifier_token_pk", 333 | "columns": [ 334 | "identifier", 335 | "token" 336 | ] 337 | } 338 | }, 339 | "uniqueConstraints": {} 340 | } 341 | }, 342 | "enums": { 343 | "public.role": { 344 | "name": "role", 345 | "schema": "public", 346 | "values": [ 347 | "user", 348 | "admin" 349 | ] 350 | } 351 | }, 352 | "schemas": {}, 353 | "sequences": {}, 354 | "_meta": { 355 | "columns": {}, 356 | "schemas": {}, 357 | "tables": {} 358 | } 359 | } -------------------------------------------------------------------------------- /migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "0493672a-f8a4-4727-b642-2e67a707ffcd", 3 | "prevId": "9f782d31-52fd-4586-a80e-1b7da32bb65c", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.account": { 8 | "name": "account", 9 | "schema": "", 10 | "columns": { 11 | "userId": { 12 | "name": "userId", 13 | "type": "text", 14 | "primaryKey": false, 15 | "notNull": true 16 | }, 17 | "type": { 18 | "name": "type", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true 22 | }, 23 | "provider": { 24 | "name": "provider", 25 | "type": "text", 26 | "primaryKey": false, 27 | "notNull": true 28 | }, 29 | "providerAccountId": { 30 | "name": "providerAccountId", 31 | "type": "text", 32 | "primaryKey": false, 33 | "notNull": true 34 | }, 35 | "refresh_token": { 36 | "name": "refresh_token", 37 | "type": "text", 38 | "primaryKey": false, 39 | "notNull": false 40 | }, 41 | "access_token": { 42 | "name": "access_token", 43 | "type": "text", 44 | "primaryKey": false, 45 | "notNull": false 46 | }, 47 | "expires_at": { 48 | "name": "expires_at", 49 | "type": "integer", 50 | "primaryKey": false, 51 | "notNull": false 52 | }, 53 | "token_type": { 54 | "name": "token_type", 55 | "type": "text", 56 | "primaryKey": false, 57 | "notNull": false 58 | }, 59 | "scope": { 60 | "name": "scope", 61 | "type": "text", 62 | "primaryKey": false, 63 | "notNull": false 64 | }, 65 | "id_token": { 66 | "name": "id_token", 67 | "type": "text", 68 | "primaryKey": false, 69 | "notNull": false 70 | }, 71 | "session_state": { 72 | "name": "session_state", 73 | "type": "text", 74 | "primaryKey": false, 75 | "notNull": false 76 | } 77 | }, 78 | "indexes": {}, 79 | "foreignKeys": { 80 | "account_userId_user_id_fk": { 81 | "name": "account_userId_user_id_fk", 82 | "tableFrom": "account", 83 | "tableTo": "user", 84 | "columnsFrom": [ 85 | "userId" 86 | ], 87 | "columnsTo": [ 88 | "id" 89 | ], 90 | "onDelete": "cascade", 91 | "onUpdate": "no action" 92 | } 93 | }, 94 | "compositePrimaryKeys": { 95 | "account_provider_providerAccountId_pk": { 96 | "name": "account_provider_providerAccountId_pk", 97 | "columns": [ 98 | "provider", 99 | "providerAccountId" 100 | ] 101 | } 102 | }, 103 | "uniqueConstraints": {} 104 | }, 105 | "public.adminUserEmailAddresses": { 106 | "name": "adminUserEmailAddresses", 107 | "schema": "", 108 | "columns": { 109 | "id": { 110 | "name": "id", 111 | "type": "text", 112 | "primaryKey": true, 113 | "notNull": true 114 | }, 115 | "email": { 116 | "name": "email", 117 | "type": "text", 118 | "primaryKey": false, 119 | "notNull": true 120 | } 121 | }, 122 | "indexes": { 123 | "adminEmailUniqueIndex": { 124 | "name": "adminEmailUniqueIndex", 125 | "columns": [ 126 | { 127 | "expression": "lower(\"email\")", 128 | "asc": true, 129 | "isExpression": true, 130 | "nulls": "last" 131 | } 132 | ], 133 | "isUnique": true, 134 | "concurrently": false, 135 | "method": "btree", 136 | "with": {} 137 | } 138 | }, 139 | "foreignKeys": {}, 140 | "compositePrimaryKeys": {}, 141 | "uniqueConstraints": {} 142 | }, 143 | "public.authenticator": { 144 | "name": "authenticator", 145 | "schema": "", 146 | "columns": { 147 | "credentialID": { 148 | "name": "credentialID", 149 | "type": "text", 150 | "primaryKey": false, 151 | "notNull": true 152 | }, 153 | "userId": { 154 | "name": "userId", 155 | "type": "text", 156 | "primaryKey": false, 157 | "notNull": true 158 | }, 159 | "providerAccountId": { 160 | "name": "providerAccountId", 161 | "type": "text", 162 | "primaryKey": false, 163 | "notNull": true 164 | }, 165 | "credentialPublicKey": { 166 | "name": "credentialPublicKey", 167 | "type": "text", 168 | "primaryKey": false, 169 | "notNull": true 170 | }, 171 | "counter": { 172 | "name": "counter", 173 | "type": "integer", 174 | "primaryKey": false, 175 | "notNull": true 176 | }, 177 | "credentialDeviceType": { 178 | "name": "credentialDeviceType", 179 | "type": "text", 180 | "primaryKey": false, 181 | "notNull": true 182 | }, 183 | "credentialBackedUp": { 184 | "name": "credentialBackedUp", 185 | "type": "boolean", 186 | "primaryKey": false, 187 | "notNull": true 188 | }, 189 | "transports": { 190 | "name": "transports", 191 | "type": "text", 192 | "primaryKey": false, 193 | "notNull": false 194 | } 195 | }, 196 | "indexes": {}, 197 | "foreignKeys": { 198 | "authenticator_userId_user_id_fk": { 199 | "name": "authenticator_userId_user_id_fk", 200 | "tableFrom": "authenticator", 201 | "tableTo": "user", 202 | "columnsFrom": [ 203 | "userId" 204 | ], 205 | "columnsTo": [ 206 | "id" 207 | ], 208 | "onDelete": "cascade", 209 | "onUpdate": "no action" 210 | } 211 | }, 212 | "compositePrimaryKeys": { 213 | "authenticator_userId_credentialID_pk": { 214 | "name": "authenticator_userId_credentialID_pk", 215 | "columns": [ 216 | "userId", 217 | "credentialID" 218 | ] 219 | } 220 | }, 221 | "uniqueConstraints": { 222 | "authenticator_credentialID_unique": { 223 | "name": "authenticator_credentialID_unique", 224 | "nullsNotDistinct": false, 225 | "columns": [ 226 | "credentialID" 227 | ] 228 | } 229 | } 230 | }, 231 | "public.session": { 232 | "name": "session", 233 | "schema": "", 234 | "columns": { 235 | "sessionToken": { 236 | "name": "sessionToken", 237 | "type": "text", 238 | "primaryKey": true, 239 | "notNull": true 240 | }, 241 | "userId": { 242 | "name": "userId", 243 | "type": "text", 244 | "primaryKey": false, 245 | "notNull": true 246 | }, 247 | "expires": { 248 | "name": "expires", 249 | "type": "timestamp", 250 | "primaryKey": false, 251 | "notNull": true 252 | } 253 | }, 254 | "indexes": {}, 255 | "foreignKeys": { 256 | "session_userId_user_id_fk": { 257 | "name": "session_userId_user_id_fk", 258 | "tableFrom": "session", 259 | "tableTo": "user", 260 | "columnsFrom": [ 261 | "userId" 262 | ], 263 | "columnsTo": [ 264 | "id" 265 | ], 266 | "onDelete": "cascade", 267 | "onUpdate": "no action" 268 | } 269 | }, 270 | "compositePrimaryKeys": {}, 271 | "uniqueConstraints": {} 272 | }, 273 | "public.user": { 274 | "name": "user", 275 | "schema": "", 276 | "columns": { 277 | "id": { 278 | "name": "id", 279 | "type": "text", 280 | "primaryKey": true, 281 | "notNull": true 282 | }, 283 | "name": { 284 | "name": "name", 285 | "type": "text", 286 | "primaryKey": false, 287 | "notNull": false 288 | }, 289 | "email": { 290 | "name": "email", 291 | "type": "text", 292 | "primaryKey": false, 293 | "notNull": true 294 | }, 295 | "emailVerified": { 296 | "name": "emailVerified", 297 | "type": "timestamp", 298 | "primaryKey": false, 299 | "notNull": false 300 | }, 301 | "image": { 302 | "name": "image", 303 | "type": "text", 304 | "primaryKey": false, 305 | "notNull": false 306 | }, 307 | "password": { 308 | "name": "password", 309 | "type": "text", 310 | "primaryKey": false, 311 | "notNull": false 312 | }, 313 | "role": { 314 | "name": "role", 315 | "type": "role", 316 | "typeSchema": "public", 317 | "primaryKey": false, 318 | "notNull": true, 319 | "default": "'user'" 320 | } 321 | }, 322 | "indexes": { 323 | "emailUniqueIndex": { 324 | "name": "emailUniqueIndex", 325 | "columns": [ 326 | { 327 | "expression": "lower(\"email\")", 328 | "asc": true, 329 | "isExpression": true, 330 | "nulls": "last" 331 | } 332 | ], 333 | "isUnique": true, 334 | "concurrently": false, 335 | "method": "btree", 336 | "with": {} 337 | } 338 | }, 339 | "foreignKeys": {}, 340 | "compositePrimaryKeys": {}, 341 | "uniqueConstraints": {} 342 | }, 343 | "public.verificationToken": { 344 | "name": "verificationToken", 345 | "schema": "", 346 | "columns": { 347 | "identifier": { 348 | "name": "identifier", 349 | "type": "text", 350 | "primaryKey": false, 351 | "notNull": true 352 | }, 353 | "token": { 354 | "name": "token", 355 | "type": "text", 356 | "primaryKey": false, 357 | "notNull": true 358 | }, 359 | "expires": { 360 | "name": "expires", 361 | "type": "timestamp", 362 | "primaryKey": false, 363 | "notNull": true 364 | } 365 | }, 366 | "indexes": {}, 367 | "foreignKeys": {}, 368 | "compositePrimaryKeys": { 369 | "verificationToken_identifier_token_pk": { 370 | "name": "verificationToken_identifier_token_pk", 371 | "columns": [ 372 | "identifier", 373 | "token" 374 | ] 375 | } 376 | }, 377 | "uniqueConstraints": {} 378 | } 379 | }, 380 | "enums": { 381 | "public.role": { 382 | "name": "role", 383 | "schema": "public", 384 | "values": [ 385 | "user", 386 | "admin" 387 | ] 388 | } 389 | }, 390 | "schemas": {}, 391 | "sequences": {}, 392 | "_meta": { 393 | "columns": {}, 394 | "schemas": {}, 395 | "tables": {} 396 | } 397 | } -------------------------------------------------------------------------------- /migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1721346603435, 9 | "tag": "0000_mean_vector", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1721493512768, 16 | "tag": "0001_greedy_red_shift", 17 | "breakpoints": true 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "authy", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "db:generate": "drizzle-kit generate", 11 | "db:migrate": "drizzle-kit migrate", 12 | "db:studio": "drizzle-kit studio" 13 | }, 14 | "dependencies": { 15 | "@auth/core": "^0.34.1", 16 | "@auth/drizzle-adapter": "^1.4.1", 17 | "@hookform/resolvers": "^3.9.0", 18 | "@icons-pack/react-simple-icons": "^9.6.0", 19 | "@neondatabase/serverless": "^0.9.4", 20 | "@radix-ui/react-dialog": "^1.1.1", 21 | "@radix-ui/react-label": "^2.1.0", 22 | "@radix-ui/react-slot": "^1.1.0", 23 | "argon2": "^0.40.3", 24 | "class-variance-authority": "^0.7.0", 25 | "clsx": "^2.1.1", 26 | "drizzle-orm": "^0.32.0", 27 | "lucide-react": "^0.408.0", 28 | "next": "14.2.5", 29 | "next-auth": "5.0.0-beta.19", 30 | "nodemailer": "^6.9.14", 31 | "react": "^18", 32 | "react-dom": "^18", 33 | "react-hook-form": "^7.52.1", 34 | "server-only": "^0.0.1", 35 | "tailwind-merge": "^2.4.0", 36 | "tailwindcss-animate": "^1.0.7", 37 | "valibot": "^0.36.0" 38 | }, 39 | "devDependencies": { 40 | "@types/node": "^20", 41 | "@types/nodemailer": "^6.4.15", 42 | "@types/react": "^18", 43 | "@types/react-dom": "^18", 44 | "drizzle-kit": "^0.23.0", 45 | "eslint": "^8", 46 | "eslint-config-next": "14.2.5", 47 | "postcss": "^8", 48 | "prettier": "^3.3.3", 49 | "prettier-plugin-tailwindcss": "^0.6.5", 50 | "tailwindcss": "^3.4.1", 51 | "typescript": "^5" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/actions/admin/change-user-role-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { auth } from "@/auth"; 4 | import db from "@/drizzle"; 5 | import { users } from "@/drizzle/schema"; 6 | import { USER_ROLES } from "@/lib/constants"; 7 | import { findUserByEmail } from "@/resources/user-queries"; 8 | import { eq } from "drizzle-orm"; 9 | import { revalidatePath } from "next/cache"; 10 | 11 | // ADMIN PANEL ACTION 12 | export async function changeUserRoleAction( 13 | email: (typeof users.$inferSelect)["email"], 14 | newRole: (typeof users.$inferSelect)["role"], 15 | ) { 16 | const session = await auth(); 17 | 18 | if (session?.user?.role !== USER_ROLES.ADMIN) { 19 | throw new Error("Unauthorized"); 20 | } 21 | 22 | const existingUser = await findUserByEmail(email); 23 | 24 | if (!existingUser?.id) return; 25 | if (existingUser.role === USER_ROLES.ADMIN) return; 26 | if (existingUser.role === newRole) return; 27 | 28 | await db 29 | .update(users) 30 | .set({ role: newRole }) 31 | .where(eq(users.id, existingUser.id)); 32 | 33 | revalidatePath("/profile/admin-panel"); 34 | } 35 | -------------------------------------------------------------------------------- /src/actions/admin/toggle-email-verified-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { auth } from "@/auth"; 4 | import db from "@/drizzle"; 5 | import { users } from "@/drizzle/schema"; 6 | import { USER_ROLES } from "@/lib/constants"; 7 | import { findUserByEmail } from "@/resources/user-queries"; 8 | import { eq } from "drizzle-orm"; 9 | import { revalidatePath } from "next/cache"; 10 | 11 | // ADMIN PANEL ACTION 12 | export async function toggleEmailVerifiedAction( 13 | email: (typeof users.$inferSelect)["email"], 14 | isCurrentlyVerified: boolean, 15 | ) { 16 | const session = await auth(); 17 | 18 | if (session?.user?.role !== USER_ROLES.ADMIN) { 19 | throw new Error("Unauthorized"); 20 | } 21 | 22 | const existingUser = await findUserByEmail(email); 23 | 24 | if (!existingUser) return; 25 | if (existingUser.role === USER_ROLES.ADMIN) return; 26 | 27 | const emailVerified = isCurrentlyVerified ? null : new Date(); 28 | 29 | await db 30 | .update(users) 31 | .set({ emailVerified }) 32 | .where(eq(users.id, existingUser.id)); 33 | 34 | revalidatePath("/profile.admin-panel"); 35 | } 36 | -------------------------------------------------------------------------------- /src/actions/create-verification-token-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import db from "@/drizzle"; 4 | import { verificationTokens } from "@/drizzle/schema"; 5 | import { VERIFICATION_TOKEN_EXP_MIN } from "@/lib/constants"; 6 | 7 | export async function createVerificationTokenAction( 8 | identifier: (typeof verificationTokens.$inferSelect)["identifier"], 9 | ) { 10 | const expires = new Date(Date.now() + VERIFICATION_TOKEN_EXP_MIN * 60 * 1000); 11 | 12 | const token = Math.random().toString(36).substring(2); 13 | 14 | const newVerificationToken = await db 15 | .insert(verificationTokens) 16 | .values({ expires, identifier, token }) 17 | .returning({ token: verificationTokens.token }) 18 | .then((res) => res[0]); 19 | 20 | return newVerificationToken; 21 | } 22 | -------------------------------------------------------------------------------- /src/actions/forgot-password-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { findUserByEmail } from "@/resources/user-queries"; 4 | import { ForgotPasswordSchema } from "@/validators/forgot-password-validator"; 5 | import * as v from "valibot"; 6 | import { createVerificationTokenAction } from "@/actions/create-verification-token-action"; 7 | import { sendForgotPasswordEmail } from "@/actions/mail/send-forgot-password-email"; 8 | 9 | type Res = 10 | | { success: true } 11 | | { success: false; error: v.FlatErrors; statusCode: 400 } 12 | | { success: false; error: string; statusCode: 401 | 500 }; 13 | 14 | export async function forgotPasswordAction(values: unknown): Promise { 15 | const parsedValues = v.safeParse(ForgotPasswordSchema, values); 16 | 17 | if (!parsedValues.success) { 18 | const flatErrors = v.flatten(parsedValues.issues); 19 | return { success: false, error: flatErrors, statusCode: 400 }; 20 | } 21 | 22 | const email = parsedValues.output.email; 23 | 24 | try { 25 | const existingUser = await findUserByEmail(email); 26 | 27 | // this is a false positive, to deter malicious users 28 | if (!existingUser?.id) return { success: true }; 29 | 30 | if (!existingUser.password) { 31 | return { 32 | success: false, 33 | error: "This user was created with OAuth, please sign in with OAuth", 34 | statusCode: 401, 35 | }; 36 | } 37 | 38 | const verificationToken = await createVerificationTokenAction( 39 | existingUser.email, 40 | ); 41 | 42 | await sendForgotPasswordEmail({ 43 | email: existingUser.email, 44 | token: verificationToken.token, 45 | }); 46 | 47 | return { success: true }; 48 | } catch (err) { 49 | console.error(err); 50 | return { success: false, error: "Internal Server Error", statusCode: 500 }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/actions/mail/send-forgot-password-email.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { VERIFICATION_TOKEN_EXP_MIN } from "@/lib/constants"; 4 | import transport from "@/lib/nodemailer"; 5 | 6 | export async function sendForgotPasswordEmail({ 7 | email, 8 | token, 9 | }: { 10 | email: string; 11 | token: string; 12 | }) { 13 | console.log(`Sending email to ${email} with token ${token}`); 14 | 15 | await transport.sendMail({ 16 | from: `"Authy Team" <${process.env.NODEMAILER_GOOGLE_SMTP_USER}>`, 17 | to: email, 18 | subject: "Reset your password", 19 | html: ` 20 |
21 |

Authy

22 | 23 |

Hi there,

24 | 25 |

Please use the link below to access the reset password form on Authy. This link will expire in ${VERIFICATION_TOKEN_EXP_MIN} minutes. If you don't think you should be receiving this email, you can safely ignore it.

26 | 27 |

28 | Reset Password Form 29 |

30 | 31 |
32 | 33 |

You received this email because you send a forgot password request for Authy.

34 | 35 |

© 2024 Authy. All rights reserved.

36 |
37 | `, 38 | }); 39 | 40 | console.log(`Email send to ${email} with token ${token}`); 41 | } 42 | -------------------------------------------------------------------------------- /src/actions/mail/send-signup-user-email.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { VERIFICATION_TOKEN_EXP_MIN } from "@/lib/constants"; 4 | import transport from "@/lib/nodemailer"; 5 | 6 | export async function sendSignupUserEmail({ 7 | email, 8 | token, 9 | }: { 10 | email: string; 11 | token: string; 12 | }) { 13 | console.log(`Sending email to ${email} with token ${token}`); 14 | 15 | await transport.sendMail({ 16 | from: `"Authy Team" <${process.env.NODEMAILER_GOOGLE_SMTP_USER}>`, 17 | to: email, 18 | subject: "Verify your email address", 19 | html: ` 20 |
21 |

Authy

22 | 23 |

Hi there,

24 | 25 |

Please use the link below to verify your email address and continue on Authy. This link will expire in ${VERIFICATION_TOKEN_EXP_MIN} minutes. If you don't think you should be receiving this email, you can safely ignore it.

26 | 27 |

28 | Verify Email 29 |

30 | 31 |
32 | 33 |

You received this email because you signed up for Authy.

34 | 35 |

© 2024 Authy. All rights reserved.

36 |
37 | `, 38 | }); 39 | 40 | console.log(`Email send to ${email} with token ${token}`); 41 | } 42 | -------------------------------------------------------------------------------- /src/actions/oauth-signin-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { signIn } from "@/auth"; 4 | import { isRedirectError } from "next/dist/client/components/redirect"; 5 | 6 | export async function oauthSigninAction(provider: "google" | "github") { 7 | try { 8 | await signIn(provider, { redirectTo: "/profile" }); 9 | } catch (err) { 10 | if (isRedirectError(err)) { 11 | throw err; 12 | } 13 | 14 | console.error(err); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/actions/oauth-verify-email-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import db from "@/drizzle"; 4 | import { users } from "@/drizzle/schema"; 5 | import { and, eq, isNull } from "drizzle-orm"; 6 | 7 | export async function oauthVerifyEmailAction(email: string) { 8 | const existingUser = await db 9 | .select({ id: users.id }) 10 | .from(users) 11 | .where( 12 | and( 13 | eq(users.email, email), 14 | isNull(users.password), 15 | isNull(users.emailVerified), 16 | ), 17 | ) 18 | .then((res) => res[0] ?? null); 19 | 20 | if (existingUser?.id) { 21 | await db 22 | .update(users) 23 | .set({ emailVerified: new Date() }) 24 | .where(eq(users.id, existingUser.id)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/actions/reset-password-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { users, verificationTokens } from "@/drizzle/schema"; 4 | import { findUserByEmail } from "@/resources/user-queries"; 5 | import { findVerificationTokenByToken } from "@/resources/verification-token-queries"; 6 | import { ResetPasswordSchema } from "@/validators/reset-password-validator"; 7 | import * as v from "valibot"; 8 | import argon2 from "argon2"; 9 | import db from "@/drizzle"; 10 | import { eq } from "drizzle-orm"; 11 | 12 | type Res = 13 | | { success: true } 14 | | { success: false; error: v.FlatErrors; statusCode: 400 } 15 | | { success: false; error: string; statusCode: 401 | 500 }; 16 | 17 | export async function resetPasswordAction( 18 | email: (typeof users.$inferSelect)["email"], 19 | token: (typeof verificationTokens.$inferSelect)["token"], 20 | values: unknown, 21 | ): Promise { 22 | const parsedValues = v.safeParse(ResetPasswordSchema, values); 23 | 24 | if (!parsedValues.success) { 25 | const flatErrors = v.flatten(parsedValues.issues); 26 | return { success: false, error: flatErrors, statusCode: 400 }; 27 | } 28 | 29 | const password = parsedValues.output.password; 30 | 31 | const existingToken = await findVerificationTokenByToken(token); 32 | 33 | if (!existingToken?.expires) { 34 | return { 35 | success: false, 36 | error: "Token is invalid", 37 | statusCode: 401, 38 | }; 39 | } 40 | 41 | if (new Date(existingToken.expires) < new Date()) { 42 | return { 43 | success: false, 44 | error: "Token is expired", 45 | statusCode: 401, 46 | }; 47 | } 48 | 49 | const existingUser = await findUserByEmail(email); 50 | 51 | if ( 52 | !existingUser?.password || 53 | existingUser.email !== existingToken.identifier 54 | ) { 55 | return { 56 | success: false, 57 | error: "Oops, something went wrong", 58 | statusCode: 401, 59 | }; 60 | } 61 | 62 | try { 63 | const hashedPassword = await argon2.hash(password); 64 | 65 | await db 66 | .update(users) 67 | .set({ password: hashedPassword }) 68 | .where(eq(users.email, email)); 69 | 70 | return { success: true }; 71 | } catch (err) { 72 | console.error(err); 73 | return { success: false, error: "Internal Server Error", statusCode: 500 }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/actions/signin-user-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { signIn } from "@/auth"; 4 | import { AuthError } from "next-auth"; 5 | 6 | type Res = 7 | | { success: true } 8 | | { success: false; error: string; statusCode: 401 | 500 }; 9 | 10 | export async function signinUserAction(values: unknown): Promise { 11 | try { 12 | if ( 13 | typeof values !== "object" || 14 | values === null || 15 | Array.isArray(values) 16 | ) { 17 | throw new Error("Invalid JSON Object"); 18 | } 19 | 20 | await signIn("credentials", { ...values, redirect: false }); 21 | 22 | return { success: true }; 23 | } catch (err) { 24 | if (err instanceof AuthError) { 25 | switch (err.type) { 26 | case "CredentialsSignin": 27 | case "CallbackRouteError": 28 | return { 29 | success: false, 30 | error: "Invalid credentials", 31 | statusCode: 401, 32 | }; 33 | case "AccessDenied": 34 | return { 35 | success: false, 36 | error: 37 | "Please verify your email, sign up again to resend verification email", 38 | statusCode: 401, 39 | }; 40 | // custom error 41 | case "OAuthAccountAlreadyLinked" as AuthError["type"]: 42 | return { 43 | success: false, 44 | error: "Login with your Google or Github account", 45 | statusCode: 401, 46 | }; 47 | default: 48 | return { 49 | success: false, 50 | error: "Oops. Something went wrong", 51 | statusCode: 500, 52 | }; 53 | } 54 | } 55 | 56 | console.error(err); 57 | return { success: false, error: "Internal Server Error", statusCode: 500 }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/actions/signout-user-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { signOut } from "@/auth"; 4 | 5 | export async function signoutUserAction() { 6 | try { 7 | await signOut({ redirect: false }); 8 | } catch (err) { 9 | console.error(err); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/actions/signup-user-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import argon2 from "argon2"; 4 | import * as v from "valibot"; 5 | import { SignupSchema } from "@/validators/signup-validator"; 6 | import db from "@/drizzle"; 7 | import { lower, users } from "@/drizzle/schema"; 8 | import { eq } from "drizzle-orm"; 9 | import { USER_ROLES } from "@/lib/constants"; 10 | import { findAdminUserEmailAddresses } from "@/resources/admin-user-email-address-queries"; 11 | import { createVerificationTokenAction } from "@/actions/create-verification-token-action"; 12 | import { sendSignupUserEmail } from "@/actions/mail/send-signup-user-email"; 13 | 14 | type Res = 15 | | { success: true } 16 | | { success: false; error: v.FlatErrors; statusCode: 400 } 17 | | { success: false; error: string; statusCode: 409 | 500 }; 18 | 19 | export async function signupUserAction(values: unknown): Promise { 20 | const parsedValues = v.safeParse(SignupSchema, values); 21 | 22 | if (!parsedValues.success) { 23 | const flatErrors = v.flatten(parsedValues.issues); 24 | console.log(flatErrors); 25 | return { success: false, error: flatErrors, statusCode: 400 }; 26 | } 27 | 28 | const { name, email, password } = parsedValues.output; 29 | 30 | try { 31 | const existingUser = await db 32 | .select({ 33 | id: users.id, 34 | email: users.email, 35 | emailVerified: users.emailVerified, 36 | }) 37 | .from(users) 38 | .where(eq(lower(users.email), email.toLowerCase())) 39 | .then((res) => res[0] ?? null); 40 | 41 | if (existingUser?.id) { 42 | if (!existingUser.emailVerified) { 43 | const verificationToken = await createVerificationTokenAction( 44 | existingUser.email, 45 | ); 46 | 47 | await sendSignupUserEmail({ 48 | email: existingUser.email, 49 | token: verificationToken.token, 50 | }); 51 | 52 | return { 53 | success: false, 54 | error: "User exists but not verified. Verification link resent", 55 | statusCode: 409, 56 | }; 57 | } else { 58 | return { 59 | success: false, 60 | error: "Email already exists", 61 | statusCode: 409, 62 | }; 63 | } 64 | } 65 | } catch (err) { 66 | console.error(err); 67 | return { success: false, error: "Internal Server Error", statusCode: 500 }; 68 | } 69 | 70 | try { 71 | const hashedPassword = await argon2.hash(password); 72 | const adminEmails = await findAdminUserEmailAddresses(); 73 | const isAdmin = adminEmails.includes(email.toLowerCase()); 74 | 75 | const newUser = await db 76 | .insert(users) 77 | .values({ 78 | name, 79 | email, 80 | password: hashedPassword, 81 | role: isAdmin ? USER_ROLES.ADMIN : USER_ROLES.USER, 82 | }) 83 | .returning({ 84 | id: users.id, 85 | email: users.email, 86 | emailVerified: users.emailVerified, 87 | }) 88 | .then((res) => res[0]); 89 | 90 | const verificationToken = await createVerificationTokenAction( 91 | newUser.email, 92 | ); 93 | 94 | await sendSignupUserEmail({ 95 | email: newUser.email, 96 | token: verificationToken.token, 97 | }); 98 | 99 | return { success: true }; 100 | } catch (err) { 101 | console.error(err); 102 | return { success: false, error: "Internal Server Error", statusCode: 500 }; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/actions/update-user-info-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as v from "valibot"; 4 | import { UpdateUserInfoSchema } from "@/validators/update-user-info-validator"; 5 | import { auth } from "@/auth"; 6 | import { users } from "@/drizzle/schema"; 7 | import db from "@/drizzle"; 8 | import { eq } from "drizzle-orm"; 9 | 10 | type Res = 11 | | { 12 | success: true; 13 | data: { 14 | id: (typeof users.$inferSelect)["id"]; 15 | name: (typeof users.$inferSelect)["name"]; 16 | }; 17 | } 18 | | { success: false; error: v.FlatErrors; statusCode: 400 } 19 | | { success: false; error: string; statusCode: 401 | 500 }; 20 | 21 | export async function updateUserInfoAction(values: unknown): Promise { 22 | const parsedValues = v.safeParse(UpdateUserInfoSchema, values); 23 | 24 | if (!parsedValues.success) { 25 | const flatErrors = v.flatten(parsedValues.issues); 26 | return { success: false, error: flatErrors, statusCode: 400 }; 27 | } 28 | 29 | const { id, name } = parsedValues.output; 30 | 31 | const session = await auth(); 32 | 33 | if (!session?.user?.id || session.user.id !== id) { 34 | return { success: false, error: "Unauthorized", statusCode: 401 }; 35 | } 36 | 37 | if (session.user.name === name) { 38 | return { success: true, data: { id, name } }; 39 | } 40 | 41 | try { 42 | const updatedUser = await db 43 | .update(users) 44 | .set({ name }) 45 | .where(eq(users.id, id)) 46 | .returning({ id: users.id, name: users.name }) 47 | .then((res) => res[0]); 48 | 49 | return { success: true, data: updatedUser }; 50 | } catch (err) { 51 | console.error(err); 52 | return { success: false, error: "Internal Server Error", statusCode: 500 }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/actions/verify-credentials-email-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import db from "@/drizzle"; 4 | import { users, verificationTokens } from "@/drizzle/schema"; 5 | import { findUserByEmail } from "@/resources/user-queries"; 6 | import { findVerificationTokenByToken } from "@/resources/verification-token-queries"; 7 | import { eq } from "drizzle-orm"; 8 | 9 | export async function verifyCredentialsEmailAction( 10 | token: (typeof verificationTokens.$inferSelect)["token"], 11 | ) { 12 | const verificationToken = await findVerificationTokenByToken(token); 13 | 14 | if (!verificationToken?.expires) return { success: false }; 15 | 16 | if (new Date(verificationToken.expires) < new Date()) { 17 | return { success: false }; 18 | } 19 | 20 | const existingUser = await findUserByEmail(verificationToken.identifier); 21 | 22 | if ( 23 | existingUser?.id && 24 | !existingUser.emailVerified && 25 | existingUser.email === verificationToken.identifier 26 | ) { 27 | await db 28 | .update(users) 29 | .set({ emailVerified: new Date() }) 30 | .where(eq(users.id, existingUser.id)); 31 | 32 | await db 33 | .update(verificationTokens) 34 | .set({ expires: new Date() }) 35 | .where(eq(verificationTokens.identifier, existingUser.email)); 36 | 37 | return { success: true }; 38 | } else { 39 | return { success: false }; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth"; 2 | 3 | export const { GET, POST } = handlers; 4 | -------------------------------------------------------------------------------- /src/app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | // import { auth } from "@/auth"; 2 | // import { redirect } from "next/navigation"; 3 | 4 | export default async function AuthLayout({ 5 | children, 6 | }: { 7 | children: React.ReactNode; 8 | }) { 9 | // const session = await auth(); 10 | // if (session) redirect("/profile"); 11 | 12 | return <>{children}; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/auth/signin/_components/forgot-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { forgotPasswordAction } from "@/actions/forgot-password-action"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogDescription, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from "@/components/ui/dialog"; 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormLabel, 19 | FormMessage, 20 | } from "@/components/ui/form"; 21 | import { Input } from "@/components/ui/input"; 22 | import { ForgotPasswordSchema } from "@/validators/forgot-password-validator"; 23 | import type { ForgotPasswordInput } from "@/validators/forgot-password-validator"; 24 | import { valibotResolver } from "@hookform/resolvers/valibot"; 25 | import { useState } from "react"; 26 | import { useForm } from "react-hook-form"; 27 | 28 | export const ForgotPasswordForm = () => { 29 | const [success, setSuccess] = useState(""); 30 | 31 | const form = useForm({ 32 | resolver: valibotResolver(ForgotPasswordSchema), 33 | defaultValues: { email: "" }, 34 | }); 35 | 36 | const { handleSubmit, control, formState, setError } = form; 37 | 38 | const submit = async (values: ForgotPasswordInput) => { 39 | setSuccess(""); 40 | 41 | const res = await forgotPasswordAction(values); 42 | 43 | if (res.success) { 44 | setSuccess("Password reset email sent."); 45 | } else { 46 | switch (res.statusCode) { 47 | case 400: 48 | const nestedErrors = res.error.nested; 49 | 50 | if (nestedErrors && "email" in nestedErrors) { 51 | setError("email", { message: nestedErrors.email?.[0] }); 52 | } else { 53 | setError("email", { message: "Internal Server Error" }); 54 | } 55 | break; 56 | case 401: 57 | setError("email", { message: res.error }); 58 | break; 59 | case 500: 60 | default: 61 | const error = res.error || "Internal Server Error"; 62 | setError("email", { message: error }); 63 | } 64 | } 65 | }; 66 | 67 | return ( 68 | 69 | Forgot your password? Click{" "} 70 | 71 | 74 | 75 | 76 | 77 | Enter Your Email 78 | 79 | 80 | We will send you an email with a link to reset your password. 81 | 82 | 83 |
84 | 85 |
86 | 87 | ( 91 | 92 | Email 93 | 94 | 95 | 96 | 97 | 98 | )} 99 | /> 100 | 101 | {success && ( 102 |

{success}

103 | )} 104 | 105 | 112 | 113 | 114 | 115 | 116 |
117 | ); 118 | }; 119 | -------------------------------------------------------------------------------- /src/app/auth/signin/_components/signin-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useForm } from "react-hook-form"; 4 | import { valibotResolver } from "@hookform/resolvers/valibot"; 5 | import { 6 | Form, 7 | FormControl, 8 | FormField, 9 | FormItem, 10 | FormLabel, 11 | FormMessage, 12 | } from "@/components/ui/form"; 13 | import { Input } from "@/components/ui/input"; 14 | import { Button } from "@/components/ui/button"; 15 | import { type SigninInput, SigninSchema } from "@/validators/signin-validator"; 16 | import { signinUserAction } from "@/actions/signin-user-action"; 17 | 18 | export const SigninForm = () => { 19 | const form = useForm({ 20 | resolver: valibotResolver(SigninSchema), 21 | defaultValues: { email: "", password: "" }, 22 | }); 23 | 24 | const { handleSubmit, control, formState, setError } = form; 25 | 26 | const submit = async (values: SigninInput) => { 27 | const res = await signinUserAction(values); 28 | 29 | if (res.success) { 30 | window.location.href = "/profile"; 31 | } else { 32 | switch (res.statusCode) { 33 | case 401: 34 | setError("password", { message: res.error }); 35 | break; 36 | case 500: 37 | default: 38 | const error = res.error || "Internal Server Error"; 39 | setError("password", { message: error }); 40 | } 41 | } 42 | }; 43 | 44 | return ( 45 |
46 | 51 | ( 55 | 56 | Email 57 | 58 | 63 | 64 | 65 | 66 | )} 67 | /> 68 | 69 | ( 73 | 74 | Password 75 | 76 | 77 | 78 | 79 | 80 | )} 81 | /> 82 | 83 | 90 | 91 | 92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /src/app/auth/signin/forgot-password/_components/reset-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { resetPasswordAction } from "@/actions/reset-password-action"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Form, 7 | FormControl, 8 | FormField, 9 | FormItem, 10 | FormLabel, 11 | FormMessage, 12 | } from "@/components/ui/form"; 13 | import { Input } from "@/components/ui/input"; 14 | import { 15 | type ResetPasswordInput, 16 | ResetPasswordSchema, 17 | } from "@/validators/reset-password-validator"; 18 | import { valibotResolver } from "@hookform/resolvers/valibot"; 19 | import { useRouter } from "next/navigation"; 20 | import { useForm } from "react-hook-form"; 21 | 22 | type ResetPasswordFormProps = { email: string; token: string }; 23 | 24 | export const ResetPasswordForm = ({ email, token }: ResetPasswordFormProps) => { 25 | const router = useRouter(); 26 | 27 | const form = useForm({ 28 | resolver: valibotResolver(ResetPasswordSchema), 29 | defaultValues: { 30 | password: "", 31 | confirmPassword: "", 32 | }, 33 | }); 34 | 35 | const { handleSubmit, control, formState, setError } = form; 36 | 37 | const submit = async (values: ResetPasswordInput) => { 38 | const res = await resetPasswordAction(email, token, values); 39 | 40 | if (res.success) { 41 | router.push("/auth/signin/reset-password/success"); 42 | } else { 43 | switch (res.statusCode) { 44 | case 400: 45 | const nestedErrors = res.error.nested; 46 | 47 | for (const key in nestedErrors) { 48 | setError(key as keyof ResetPasswordInput, { 49 | message: nestedErrors[key]?.[0], 50 | }); 51 | } 52 | break; 53 | case 401: 54 | setError("confirmPassword", { message: res.error }); 55 | break; 56 | case 500: 57 | default: 58 | const error = res.error || "Internal Server Error"; 59 | setError("confirmPassword", { message: error }); 60 | } 61 | } 62 | }; 63 | 64 | return ( 65 |
66 | 67 | ( 71 | 72 | Password 73 | 74 | 75 | 76 | 77 | 78 | )} 79 | /> 80 | 81 | ( 85 | 86 | Confirm Password 87 | 88 | 89 | 90 | 91 | 92 | )} 93 | /> 94 | 95 | 102 | 103 | 104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /src/app/auth/signin/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { findVerificationTokenByToken } from "@/resources/verification-token-queries"; 3 | import Link from "next/link"; 4 | import { ResetPasswordForm } from "./_components/reset-password-form"; 5 | 6 | type PageProps = { searchParams: { token: string } }; 7 | 8 | export default async function Page({ searchParams }: PageProps) { 9 | const verificationToken = await findVerificationTokenByToken( 10 | searchParams.token, 11 | ); 12 | 13 | if (!verificationToken?.expires) return ; 14 | 15 | const isExpired = new Date(verificationToken.expires) < new Date(); 16 | 17 | if (isExpired) return ; 18 | 19 | return ( 20 |
21 |
22 |
23 | Forgot Password? 24 |
25 | 26 |
27 |
28 |

29 | Enter your new password below 30 |

31 | 32 |
33 | 37 |
38 |
39 | 40 | 41 | No longer need to reset your password? Click{" "} 42 | {" "} 45 | to sign in. 46 | 47 |
48 |
49 | ); 50 | } 51 | 52 | const TokenIsInvalidState = () => { 53 | return ( 54 |
55 |
56 |
57 | Forgot Password? 58 |
59 | 60 |
61 |
62 |

Token is invalid.

63 | 64 | 65 | Click{" "} 66 | {" "} 69 | to sign in page so you can request a new forgot password email. 70 | 71 |
72 |
73 |
74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/app/auth/signin/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { SigninForm } from "./_components/signin-form"; 3 | import Link from "next/link"; 4 | import { 5 | OAuthSigninButtons, 6 | OAuthSigninButtonsSkeleton, 7 | } from "@/components/oauth-signin-buttons"; 8 | import { Suspense } from "react"; 9 | import { ForgotPasswordForm } from "./_components/forgot-password-form"; 10 | 11 | export default function SigninPage() { 12 | return ( 13 |
14 |
15 |

Sign In

16 | 17 | {/* Signin Form */} 18 |
19 | 20 | 21 | {/* OAuth Links */} 22 |
23 | }> 24 | 25 | 26 | 27 | {/* Go to Signup Link */} 28 |
29 |

30 | Don't have an account? Click{" "} 31 | {" "} 34 | to sign up. 35 |

36 | 37 | {/* Forgot Password Dialog */} 38 | 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/app/auth/signin/reset-password/success/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | 4 | export default function Page() { 5 | return ( 6 |
7 |
8 |
Reset Password
9 | 10 |
11 |

Password has been successfully reset!

12 | 13 |
14 | 15 | Click{" "} 16 | {" "} 19 | to sign in. 20 | 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/auth/signup/_components/signup-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type SignupInput, SignupSchema } from "@/validators/signup-validator"; 4 | import { useForm } from "react-hook-form"; 5 | import { valibotResolver } from "@hookform/resolvers/valibot"; 6 | import { 7 | Form, 8 | FormControl, 9 | FormDescription, 10 | FormField, 11 | FormItem, 12 | FormLabel, 13 | FormMessage, 14 | } from "@/components/ui/form"; 15 | import { Input } from "@/components/ui/input"; 16 | import { Button } from "@/components/ui/button"; 17 | import { signupUserAction } from "@/actions/signup-user-action"; 18 | import { useState } from "react"; 19 | import Link from "next/link"; 20 | import { useRouter } from "next/navigation"; 21 | 22 | export const SignupForm = () => { 23 | const [success, setSuccess] = useState(false); 24 | const router = useRouter(); 25 | 26 | const form = useForm({ 27 | resolver: valibotResolver(SignupSchema), 28 | defaultValues: { name: "", email: "", password: "", confirmPassword: "" }, 29 | }); 30 | 31 | const { handleSubmit, control, formState, setError } = form; 32 | 33 | const submit = async (values: SignupInput) => { 34 | const res = await signupUserAction(values); 35 | 36 | if (res.success) { 37 | router.push("/auth/signup/success"); 38 | } else { 39 | switch (res.statusCode) { 40 | case 400: 41 | const nestedErrors = res.error.nested; 42 | 43 | for (const key in nestedErrors) { 44 | setError(key as keyof SignupInput, { 45 | message: nestedErrors[key]?.[0], 46 | }); 47 | } 48 | break; 49 | case 500: 50 | default: 51 | const error = res.error || "Internal Server Error"; 52 | setError("confirmPassword", { message: error }); 53 | } 54 | } 55 | }; 56 | 57 | if (success) { 58 | return ( 59 |
60 |

User successfully created!

61 | 62 | 63 | Click{" "} 64 | {" "} 67 | to sign in. 68 | 69 |
70 | ); 71 | } 72 | 73 | return ( 74 |
75 | 80 | ( 84 | 85 | Name 86 | 87 | 88 | 89 | Optional 90 | 91 | 92 | )} 93 | /> 94 | 95 | ( 99 | 100 | Email 101 | 102 | 107 | 108 | 109 | 110 | )} 111 | /> 112 | 113 | ( 117 | 118 | Password 119 | 120 | 121 | 122 | 123 | 124 | )} 125 | /> 126 | 127 | ( 131 | 132 | Confirm Password 133 | 134 | 135 | 136 | 137 | 138 | )} 139 | /> 140 | 141 | 148 | 149 | 150 | ); 151 | }; 152 | -------------------------------------------------------------------------------- /src/app/auth/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { SignupForm } from "./_components/signup-form"; 3 | import Link from "next/link"; 4 | import { 5 | OAuthSigninButtons, 6 | OAuthSigninButtonsSkeleton, 7 | } from "@/components/oauth-signin-buttons"; 8 | import { Suspense } from "react"; 9 | 10 | export default function SignupPage() { 11 | return ( 12 |
13 |
14 |

Sign Up

15 | 16 | {/* Signup Form */} 17 |
18 | 19 | 20 | {/* OAuth Links */} 21 |
22 | }> 23 | 24 | 25 | 26 | {/* Go to Signin Link */} 27 |
28 |

29 | Already have an account? Click{" "} 30 | {" "} 33 | to sign in. 34 |

35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/app/auth/signup/success/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | 4 | export default function Page() { 5 | return ( 6 |
7 |
8 |
Sign Up
9 | 10 |
11 |

Verification email has been sent!

12 |

Please check your email to verify your account.

13 | 14 |
15 | 16 | Click{" "} 17 | {" "} 20 | to go back to the home page. 21 | 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/auth/signup/verify-email/loading.tsx: -------------------------------------------------------------------------------- 1 | export default function Loading() { 2 | return ( 3 |
4 |
5 |
Verify Email
6 | 7 |
8 |
9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/app/auth/signup/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import { verifyCredentialsEmailAction } from "@/actions/verify-credentials-email-action"; 2 | import { Button } from "@/components/ui/button"; 3 | import { findVerificationTokenByToken } from "@/resources/verification-token-queries"; 4 | import Link from "next/link"; 5 | 6 | type PageProps = { searchParams: { token: string } }; 7 | 8 | export default async function Page({ searchParams }: PageProps) { 9 | const verificationToken = await findVerificationTokenByToken( 10 | searchParams.token, 11 | ); 12 | 13 | if (!verificationToken?.expires) return ; 14 | 15 | const isExpired = new Date(verificationToken.expires) < new Date(); 16 | 17 | if (isExpired) return ; 18 | 19 | const res = await verifyCredentialsEmailAction(searchParams.token); 20 | 21 | if (!res.success) return ; 22 | 23 | return ( 24 |
25 |
26 |
Verify Email
27 | 28 |
29 |
30 |

Email verified.

31 | 32 | 33 | Click{" "} 34 | {" "} 37 | to sign in. 38 | 39 |
40 |
41 |
42 | ); 43 | } 44 | 45 | const TokenIsInvalidState = () => { 46 | return ( 47 |
48 |
49 |
Verify Email
50 | 51 |
52 |
53 |

Token is invalid.

54 | 55 | 56 | Click{" "} 57 | {" "} 60 | to sign up again. 61 | 62 |
63 |
64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giraffereactor/yt-authjs-v5/38b26e5947d4251d06617ded42eddaa3cb3e6ac9/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 { Navbar } from "@/components/navbar"; 5 | import { Providers } from "@/components/providers"; 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Create Next App", 11 | description: "Generated by create next app", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: Readonly<{ 17 | children: React.ReactNode; 18 | }>) { 19 | return ( 20 | 21 | 22 | 23 | 24 | {children} 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | export default function HomePage() { 2 | return ( 3 |
4 |
5 |

Welcome

6 | 7 |
8 |

Features

9 | 10 |
    11 |
  • 12 | User in Client Components 13 |
  • 14 |
  • 15 | User in Server Components 16 |
  • 17 |
  • 18 | Credentials Provider 19 |
  • 20 |
  • 21 | Protect Pages 22 |
  • 23 |
  • 24 | Signout 25 |
  • 26 |
  • 27 | Google OAuth Provider 28 |
  • 29 |
  • 30 | Github OAuth Provider 31 |
  • 32 |
  • 33 | Auth.js Drizzle Adapter 34 |
  • 35 |
  • 36 | Extend Session Information 37 |
  • 38 |
  • 39 | Auth.js Extend Types 40 |
  • 41 |
  • 42 | Auth.js Session Events 43 |
  • 44 |
  • 45 | Update Session (Client) 46 |
  • 47 |
  • 48 | Auth.js Session Callbacks 49 |
  • 50 |
  • 51 | Custom errors 52 |
  • 53 |
  • 54 | Account Linking 55 |
  • 56 |
  • 57 | Middleware 58 |
  • 59 |
  • 60 | User Roles 61 |
  • 62 |
  • 63 | Admin Dashboard 64 |
  • 65 |
  • 66 | Email Verification 67 |
  • 68 |
  • 69 | Password Reset 70 |
  • 71 |
72 | 73 |
74 |

Created With

75 | 76 |
    77 |
  • Next.js
  • 78 |
  • Tailwind
  • 79 |
  • shadcn/ui
  • 80 |
  • Auth.js
  • 81 |
  • Drizzle ORM
  • 82 |
  • NeonDB
  • 83 |
  • PostgreSQL
  • 84 |
  • Valibot
  • 85 |
  • TypeScript
  • 86 |
87 |
88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /src/app/profile/_components/update-user-info-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { updateUserInfoAction } from "@/actions/update-user-info-action"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogDescription, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from "@/components/ui/dialog"; 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormLabel, 19 | FormMessage, 20 | } from "@/components/ui/form"; 21 | import { Input } from "@/components/ui/input"; 22 | import { 23 | type UpdateUserInfoInput, 24 | UpdateUserInfoSchema, 25 | } from "@/validators/update-user-info-validator"; 26 | import { valibotResolver } from "@hookform/resolvers/valibot"; 27 | import { PencilIcon } from "lucide-react"; 28 | import { type User } from "next-auth"; 29 | import { useSession } from "next-auth/react"; 30 | import { useRouter } from "next/navigation"; 31 | import { useState } from "react"; 32 | import { useForm } from "react-hook-form"; 33 | 34 | type UpdateUserInfoFormProps = { user: User }; 35 | 36 | export const UpdateUserInfoForm = ({ user }: UpdateUserInfoFormProps) => { 37 | const [success, setSuccess] = useState(""); 38 | const { data: session, update } = useSession(); 39 | const router = useRouter(); 40 | 41 | const { id, name: defaultName } = user; 42 | 43 | const form = useForm({ 44 | resolver: valibotResolver(UpdateUserInfoSchema), 45 | defaultValues: { id, name: defaultName || "" }, 46 | }); 47 | 48 | const { handleSubmit, control, formState, setError } = form; 49 | 50 | const submit = async (values: UpdateUserInfoInput) => { 51 | const res = await updateUserInfoAction(values); 52 | 53 | if (res.success) { 54 | const updatedUser = res.data; 55 | 56 | if (session?.user) { 57 | await update({ 58 | ...session, 59 | user: { 60 | ...session.user, 61 | name: updatedUser.name, 62 | }, 63 | }); 64 | } 65 | 66 | router.refresh(); 67 | setSuccess("User information updated successfully."); 68 | } else { 69 | switch (res.statusCode) { 70 | case 400: 71 | const nestedErrors = res.error.nested; 72 | 73 | for (const key in nestedErrors) { 74 | setError(key as keyof UpdateUserInfoInput, { 75 | message: nestedErrors[key]?.[0], 76 | }); 77 | } 78 | break; 79 | case 401: 80 | case 500: 81 | default: 82 | const error = res.error || "Internal Server Error"; 83 | setError("name", { message: error }); 84 | } 85 | } 86 | }; 87 | 88 | return ( 89 | 90 | 91 | 97 | 98 | 99 | 100 | 101 | Edit User Information 102 | 103 | 104 | Update your user information below. 105 | 106 | 107 |
108 | 109 |
110 | 111 | ( 115 | 116 | Name 117 | 118 | 119 | 120 | 121 | 122 | )} 123 | /> 124 | {success && ( 125 |

{success}

126 | )} 127 | 128 | } /> 129 | 130 | 137 | 138 | 139 | 140 | 141 |
142 | ); 143 | }; 144 | -------------------------------------------------------------------------------- /src/app/profile/admin-panel/_components/change-user-role-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { changeUserRoleAction } from "@/actions/admin/change-user-role-action"; 4 | import { users } from "@/drizzle/schema"; 5 | import React, { useTransition } from "react"; 6 | 7 | type ChangeUserRoleInputProps = { 8 | email: (typeof users.$inferSelect)["email"]; 9 | currentRole: (typeof users.$inferSelect)["role"]; 10 | isAdmin: boolean; 11 | }; 12 | 13 | export const ChangeUserRoleInput = ({ 14 | email, 15 | currentRole, 16 | isAdmin, 17 | }: ChangeUserRoleInputProps) => { 18 | const [isPending, startTransition] = useTransition(); 19 | 20 | const changeHandler = ( 21 | email: string, 22 | evt: React.ChangeEvent, 23 | ) => { 24 | const newRole = evt.target.value as (typeof users.$inferSelect)["role"]; 25 | 26 | if (newRole === currentRole) return; 27 | 28 | startTransition(async () => { 29 | await changeUserRoleAction(email, newRole); 30 | }); 31 | }; 32 | 33 | return ( 34 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/app/profile/admin-panel/_components/toggle-email-verified-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toggleEmailVerifiedAction } from "@/actions/admin/toggle-email-verified-action"; 4 | import { users } from "@/drizzle/schema"; 5 | import { useTransition } from "react"; 6 | 7 | type ToggleEmailVerifiedInputProps = { 8 | email: (typeof users.$inferSelect)["email"]; 9 | emailVerified: (typeof users.$inferSelect)["emailVerified"]; 10 | isAdmin: boolean; 11 | }; 12 | 13 | export const ToggleEmailVerifiedInput = ({ 14 | email, 15 | emailVerified, 16 | isAdmin, 17 | }: ToggleEmailVerifiedInputProps) => { 18 | const [isPending, startTransition] = useTransition(); 19 | 20 | const clickHandler = (email: string, isCurrentlyVerified: boolean) => { 21 | startTransition(async () => { 22 | await toggleEmailVerifiedAction(email, isCurrentlyVerified); 23 | }); 24 | }; 25 | 26 | return ( 27 |
28 | 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/app/profile/admin-panel/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | import { Button } from "@/components/ui/button"; 3 | import { USER_ROLES } from "@/lib/constants"; 4 | import { cn } from "@/lib/utils"; 5 | import { findAllUsers } from "@/resources/user-queries"; 6 | import { ArrowLeftSquareIcon } from "lucide-react"; 7 | import Link from "next/link"; 8 | import { redirect } from "next/navigation"; 9 | import { ToggleEmailVerifiedInput } from "./_components/toggle-email-verified-input"; 10 | import { ChangeUserRoleInput } from "./_components/change-user-role-input"; 11 | 12 | export default async function Page() { 13 | const session = await auth(); 14 | 15 | if (session?.user?.role !== USER_ROLES.ADMIN) redirect("/profile"); 16 | 17 | const users = await findAllUsers(); 18 | 19 | return ( 20 |
21 |
22 |
23 |

Admin Panel

24 | 25 |
26 | 27 |
28 |

All Users

29 | 30 |
31 | 32 | 33 | 34 | 35 | 38 | 41 | 44 | 47 | 48 | 49 | 50 | 51 | {users.map((user) => ( 52 | 58 | 59 | 66 | 67 | 74 | 81 | 82 | ))} 83 | 84 |
id 36 | name 37 | 39 | email 40 | 42 | email verified 43 | 45 | role 46 |
{user.id} 64 | {user.name ?? "NULL"} 65 | {user.email} 68 | 73 | 75 | 80 |
85 |
86 |
87 | ); 88 | } 89 | 90 | const ProfileButton = () => { 91 | return ( 92 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /src/app/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { auth } from "@/auth"; 3 | import { SignoutButton } from "@/components/signout-button"; 4 | import { Button } from "@/components/ui/button"; 5 | import { type User } from "next-auth"; 6 | import { UpdateUserInfoForm } from "./_components/update-user-info-form"; 7 | import { USER_ROLES } from "@/lib/constants"; 8 | import { LockIcon } from "lucide-react"; 9 | import { cn } from "@/lib/utils"; 10 | 11 | export default async function ProfilePage() { 12 | const session = await auth(); 13 | const isAdmin = session?.user?.role === USER_ROLES.ADMIN; 14 | 15 | return ( 16 |
17 |
18 |
19 |

Profile

20 | {isAdmin && } 21 |
22 | 23 |
24 | 25 | {!!session?.user ? : } 26 |
27 |
28 | ); 29 | } 30 | 31 | const SignedIn = ({ user }: { user: User }) => { 32 | return ( 33 | <> 34 |
35 |

User Information

36 | 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 59 | 60 | 61 | 62 | 63 |
idnameemailrole
{user.id} 57 | {user.name ?? "NULL"} 58 | {user.email}{user.role}
64 | 65 |
66 | 67 | 68 | ); 69 | }; 70 | 71 | const SignedOut = () => { 72 | return ( 73 | <> 74 |

User Not Signed In

75 | 76 |
77 | 78 | 81 | 82 | ); 83 | }; 84 | 85 | const AdminPanelButton = () => { 86 | return ( 87 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/auth.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextAuthConfig } from "next-auth"; 2 | import { DrizzleAdapter } from "@auth/drizzle-adapter"; 3 | import Google from "next-auth/providers/google"; 4 | import Github from "next-auth/providers/github"; 5 | import db from "@/drizzle"; 6 | import * as schema from "@/drizzle/schema"; 7 | import { oauthVerifyEmailAction } from "@/actions/oauth-verify-email-action"; 8 | import { USER_ROLES } from "@/lib/constants"; 9 | import type { AdapterUser } from "@auth/core/adapters"; 10 | import { getTableColumns } from "drizzle-orm"; 11 | import { findAdminUserEmailAddresses } from "./resources/admin-user-email-address-queries"; 12 | 13 | export const authConfig = { 14 | adapter: { 15 | ...DrizzleAdapter(db, { 16 | accountsTable: schema.accounts, 17 | usersTable: schema.users, 18 | authenticatorsTable: schema.authenticators, 19 | sessionsTable: schema.sessions, 20 | verificationTokensTable: schema.verificationTokens, 21 | }), 22 | async createUser(data: AdapterUser) { 23 | const { id, ...insertData } = data; 24 | const hasDefaultId = getTableColumns(schema.users)["id"]["hasDefault"]; 25 | 26 | const adminEmails = await findAdminUserEmailAddresses(); 27 | const isAdmin = adminEmails.includes(insertData.email.toLowerCase()); 28 | 29 | if (isAdmin) { 30 | insertData.role = isAdmin ? USER_ROLES.ADMIN : USER_ROLES.USER; 31 | } 32 | 33 | return db 34 | .insert(schema.users) 35 | .values(hasDefaultId ? insertData : { ...insertData, id }) 36 | .returning() 37 | .then((res) => res[0]); 38 | }, 39 | }, 40 | session: { strategy: "jwt" }, 41 | secret: process.env.AUTH_SECRET, 42 | pages: { signIn: "/auth/signin" }, 43 | callbacks: { 44 | authorized({ auth, request }) { 45 | const { nextUrl } = request; 46 | 47 | const isLoggedIn = !!auth?.user; 48 | const isOnProfile = nextUrl.pathname.startsWith("/profile"); 49 | const isOnAuth = nextUrl.pathname.startsWith("/auth"); 50 | 51 | if (isOnProfile) { 52 | if (isLoggedIn) return true; 53 | return Response.redirect(new URL("/auth/signin", nextUrl)); 54 | } 55 | 56 | if (isOnAuth) { 57 | if (!isLoggedIn) return true; 58 | return Response.redirect(new URL("/profile", nextUrl)); 59 | } 60 | 61 | return true; 62 | }, 63 | async jwt({ token, user, trigger, session }) { 64 | if (trigger === "update") { 65 | return { ...token, ...session.user }; 66 | } 67 | 68 | if (user?.id) token.id = user.id; 69 | if (user?.role) token.role = user.role; 70 | 71 | return token; 72 | }, 73 | session({ session, token }) { 74 | session.user.id = token.id; 75 | session.user.role = token.role; 76 | 77 | return session; 78 | }, 79 | signIn({ user, account, profile }) { 80 | if (account?.provider === "google") { 81 | return !!profile?.email_verified; 82 | } 83 | 84 | if (account?.provider === "github") { 85 | return true; 86 | } 87 | 88 | if (account?.provider === "credentials") { 89 | if (user.emailVerified) return true; 90 | } 91 | 92 | return false; 93 | }, 94 | }, 95 | events: { 96 | async linkAccount({ user, account }) { 97 | if (["google", "github"].includes(account.provider)) { 98 | if (user.email) await oauthVerifyEmailAction(user.email); 99 | } 100 | }, 101 | }, 102 | providers: [ 103 | Google({ 104 | clientId: process.env.GOOGLE_CLIENT_ID, 105 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 106 | allowDangerousEmailAccountLinking: true, 107 | }), 108 | Github({ 109 | clientId: process.env.GITHUB_CLIENT_ID, 110 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 111 | allowDangerousEmailAccountLinking: true, 112 | }), 113 | ], 114 | } satisfies NextAuthConfig; 115 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import Credentials from "next-auth/providers/credentials"; 3 | import * as v from "valibot"; 4 | import argon2 from "argon2"; 5 | import { SigninSchema } from "@/validators/signin-validator"; 6 | import { findUserByEmail } from "@/resources/user-queries"; 7 | import { OAuthAccountAlreadyLinkedError } from "@/lib/custom-errors"; 8 | import { authConfig } from "@/auth.config"; 9 | 10 | const { providers: authConfigProviders, ...authConfigRest } = authConfig; 11 | 12 | const nextAuth = NextAuth({ 13 | ...authConfigRest, 14 | providers: [ 15 | ...authConfigProviders, 16 | Credentials({ 17 | async authorize(credentials) { 18 | const parsedCredentials = v.safeParse(SigninSchema, credentials); 19 | 20 | if (parsedCredentials.success) { 21 | const { email, password } = parsedCredentials.output; 22 | 23 | const user = await findUserByEmail(email); 24 | if (!user) return null; 25 | 26 | if (!user.password) throw new OAuthAccountAlreadyLinkedError(); 27 | 28 | const passwordsMatch = await argon2.verify(user.password, password); 29 | 30 | if (passwordsMatch) { 31 | const { password: _, ...userWithoutPassword } = user; 32 | return userWithoutPassword; 33 | } 34 | } 35 | 36 | return null; 37 | }, 38 | }), 39 | ], 40 | }); 41 | 42 | export const { signIn, signOut, auth, handlers } = nextAuth; 43 | -------------------------------------------------------------------------------- /src/components/navbar-links.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { Button } from "@/components/ui/button"; 5 | import { SignoutButton } from "@/components/signout-button"; 6 | import { useSession } from "next-auth/react"; 7 | import { Loader2Icon } from "lucide-react"; 8 | 9 | export const NavbarLinks = () => { 10 | const session = useSession(); 11 | 12 | switch (session.status) { 13 | case "loading": 14 | return ; 15 | case "unauthenticated": 16 | return ; 17 | case "authenticated": 18 | return ; 19 | default: 20 | return null; 21 | } 22 | }; 23 | 24 | const Loading = () => { 25 | return ( 26 |
  • 27 | 30 |
  • 31 | ); 32 | }; 33 | 34 | const SignedIn = () => { 35 | return ( 36 | <> 37 |
  • 38 | 41 |
  • 42 | 43 |
  • 44 | 45 |
  • 46 | 47 | ); 48 | }; 49 | 50 | const SignedOut = () => { 51 | return ( 52 | <> 53 |
  • 54 | 57 |
  • 58 | 59 |
  • 60 | 63 |
  • 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { NavbarLinks } from "./navbar-links"; 3 | 4 | export const Navbar = () => { 5 | return ( 6 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/oauth-signin-buttons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | SiGithub, 5 | SiGithubHex, 6 | SiGoogle, 7 | SiGoogleHex, 8 | } from "@icons-pack/react-simple-icons"; 9 | import { Button } from "@/components/ui/button"; 10 | import { oauthSigninAction } from "@/actions/oauth-signin-action"; 11 | import { useEffect, useState } from "react"; 12 | import { useSearchParams } from "next/navigation"; 13 | 14 | type OAuthSigninButtonsProps = { signup?: boolean }; 15 | 16 | export const OAuthSigninButtons = ({ signup }: OAuthSigninButtonsProps) => { 17 | const [errMessage, setErrMessage] = useState(""); 18 | const searchParams = useSearchParams(); 19 | const error = searchParams.get("error"); 20 | 21 | useEffect(() => { 22 | if (!error) return; 23 | 24 | if (error === "OAuthAccountNotLinked") { 25 | setErrMessage("This account is already in use. Please sign in."); 26 | } else { 27 | setErrMessage("An error occured. Please try again."); 28 | } 29 | }, [error]); 30 | 31 | const clickHandler = async (provider: "google" | "github") => { 32 | try { 33 | await oauthSigninAction(provider); 34 | } catch (err) { 35 | console.log(err); 36 | } 37 | }; 38 | 39 | const text = signup ? "Sign up" : "Sign in"; 40 | return ( 41 |
    42 | 51 | 52 | 60 | 61 | {errMessage && ( 62 |

    63 | {errMessage} 64 |

    65 | )} 66 |
    67 | ); 68 | }; 69 | 70 | type OAuthSigninButtonsSkeletonProps = OAuthSigninButtonsProps; 71 | 72 | export const OAuthSigninButtonsSkeleton = ({ 73 | signup, 74 | }: OAuthSigninButtonsSkeletonProps) => { 75 | const text = signup ? "Sign up" : "Sign in"; 76 | 77 | return ( 78 |
    79 | 83 | 84 | 88 |
    89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SessionProvider } from "next-auth/react"; 4 | 5 | export const Providers = ({ children }: { children: React.ReactNode }) => { 6 | return {children}; 7 | }; 8 | -------------------------------------------------------------------------------- /src/components/signout-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signoutUserAction } from "@/actions/signout-user-action"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export const SignoutButton = () => { 7 | const clickHandler = async () => { 8 | await signoutUserAction(); 9 | window.location.href = "/"; 10 | }; 11 | 12 | return ( 13 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /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/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
    67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
    81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /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 |