├── .eslintrc.json
├── app
├── api
│ └── auth
│ │ └── [...nextauth]
│ │ └── route.ts
├── favicon.ico
├── (auth)
│ ├── layout.tsx
│ ├── reset
│ │ └── page.tsx
│ ├── register
│ │ └── page.tsx
│ ├── resend
│ │ └── page.tsx
│ ├── error
│ │ └── page.tsx
│ ├── login
│ │ └── page.tsx
│ ├── verify
│ │ └── page.tsx
│ ├── new-password
│ │ └── page.tsx
│ └── two-factor
│ │ └── page.tsx
├── (main)
│ ├── settings
│ │ └── page.tsx
│ ├── layout.tsx
│ ├── page.tsx
│ └── profile
│ │ └── page.tsx
├── layout.tsx
├── not-found.tsx
└── _components
│ └── navbar.tsx
├── postcss.config.js
├── lib
├── db.ts
├── auth.ts
└── utils.ts
├── auth
├── config.ts
├── providers.ts
└── index.ts
├── public
├── assets
│ ├── error.svg
│ └── email-verified.svg
├── vercel.svg
└── next.svg
├── routes.ts
├── services
├── account.ts
├── user.ts
├── mail.ts
├── two-factor-confirmation.ts
├── two-factor-token.ts
├── verification-token.ts
└── reset-password-token.ts
├── next.config.js
├── components.json
├── .gitignore
├── types
├── next-auth.d.ts
└── index.ts
├── tsconfig.json
├── components
├── form
│ ├── verify-token-form.tsx
│ ├── resend-form.tsx
│ ├── reset-form.tsx
│ ├── new-password-form.tsx
│ ├── two-factor-form.tsx
│ ├── register-form.tsx
│ ├── login-form.tsx
│ └── profile-form.tsx
├── ui
│ ├── label.tsx
│ ├── separator.tsx
│ ├── input.tsx
│ ├── sonner.tsx
│ ├── switch.tsx
│ ├── avatar.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── form.tsx
│ └── dropdown-menu.tsx
└── auth
│ ├── error-card.tsx
│ ├── social.tsx
│ ├── form-input.tsx
│ ├── form-toggle.tsx
│ └── card-wrapper.tsx
├── .env.example
├── middleware.ts
├── README.md
├── actions
├── resend.ts
├── verify-token.ts
├── register.ts
├── reset-password.ts
├── new-password.ts
├── two-factor.ts
├── profile.ts
└── login.ts
├── styles
└── globals.css
├── package.json
├── prisma
└── schema.prisma
├── tailwind.config.ts
└── schemas
└── index.ts
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | export { GET, POST } from "@/auth";
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zulmy-azhary/next-auth-boilerplate/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | export default async function AuthLayout({ children }: { children: React.ReactNode }) {
2 | return {children} ;
3 | }
4 |
--------------------------------------------------------------------------------
/app/(main)/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 |
3 | export const metadata: Metadata = {
4 | title: "Settings",
5 | };
6 |
7 | export default function SettingsPage() {
8 | return (
9 |
SettingsPage
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/lib/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | declare global {
4 | var prisma: PrismaClient | undefined;
5 | }
6 |
7 | export const db = globalThis.prisma || new PrismaClient();
8 |
9 | if (process.env.NODE_ENV !== "production") globalThis.prisma = db;
--------------------------------------------------------------------------------
/auth/config.ts:
--------------------------------------------------------------------------------
1 | import { CredentialsProvider, GithubProvider, GoogleProvider } from "@/auth/providers";
2 | import type { NextAuthConfig } from "next-auth";
3 |
4 | export const authConfig = {
5 | providers: [CredentialsProvider, GithubProvider, GoogleProvider],
6 | } satisfies NextAuthConfig;
--------------------------------------------------------------------------------
/app/(auth)/reset/page.tsx:
--------------------------------------------------------------------------------
1 | import { ResetForm } from "@/components/form/reset-form";
2 | import { Metadata } from "next";
3 |
4 | export const metadata: Metadata = {
5 | title: "Forgot Password"
6 | }
7 |
8 | export default function ForgotPassword() {
9 | return ;
10 | }
11 |
--------------------------------------------------------------------------------
/public/assets/error.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | error
5 |
6 |
7 |
--------------------------------------------------------------------------------
/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@/auth"
2 |
3 | export const currentUser = async () => {
4 | const session = await auth();
5 |
6 | return session?.user;
7 | }
8 |
9 | export const currentRole = async () => {
10 | const session = await auth();
11 |
12 | return session?.user.role;
13 | }
--------------------------------------------------------------------------------
/app/(auth)/register/page.tsx:
--------------------------------------------------------------------------------
1 | import { RegisterForm } from "@/components/form/register-form";
2 | import type { Metadata } from "next";
3 |
4 | export const metadata: Metadata = {
5 | title: "Register",
6 | };
7 |
8 | export default function RegisterPage() {
9 | return ;
10 | }
11 |
--------------------------------------------------------------------------------
/app/(auth)/resend/page.tsx:
--------------------------------------------------------------------------------
1 | import { ResendForm } from "@/components/form/resend-form";
2 | import { Metadata } from "next";
3 |
4 | export const metadata: Metadata = {
5 | title: "Resend Confirmation",
6 | };
7 |
8 | export default function ResendPage() {
9 | return ;
10 | }
11 |
--------------------------------------------------------------------------------
/app/(main)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from "@/app/_components/navbar";
2 |
3 | export default async function MainLayout({ children }: { children: React.ReactNode }) {
4 | return (
5 | <>
6 |
7 | {children}
8 | >
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/app/(main)/page.tsx:
--------------------------------------------------------------------------------
1 | import { currentUser } from "@/lib/auth";
2 | import { Metadata } from "next";
3 |
4 | export const metadata: Metadata = {
5 | title: "Home",
6 | };
7 |
8 | export default async function Home() {
9 | const user = await currentUser();
10 | return Hello {user?.name}
;
11 | }
12 |
--------------------------------------------------------------------------------
/routes.ts:
--------------------------------------------------------------------------------
1 | export const publicRoutes: string[] = ["/verify"];
2 |
3 | export const authRoutes: string[] = [
4 | "/login",
5 | "/register",
6 | "/error",
7 | "/resend",
8 | "/reset",
9 | "/new-password",
10 | "/two-factor"
11 | ];
12 |
13 | export const apiAuthPrefix: string = "/api/auth";
14 |
15 | export const DEFAULT_LOGIN_REDIRECT: string = "/";
16 |
--------------------------------------------------------------------------------
/services/account.ts:
--------------------------------------------------------------------------------
1 | // Services for OAuth providers such as Google, Github, etc...
2 | import { db } from "@/lib/db";
3 |
4 | export const getAccountByUserId = async (userId: string) => {
5 | try {
6 | const account = await db.account.findFirst({
7 | where: { userId },
8 | });
9 |
10 | return account;
11 | } catch {
12 | return null;
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "avatars.githubusercontent.com",
8 | },
9 | {
10 | protocol: "https",
11 | hostname: "lh3.googleusercontent.com",
12 | },
13 | ],
14 | },
15 | };
16 |
17 | module.exports = nextConfig
18 |
--------------------------------------------------------------------------------
/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": "@/styles/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/app/(auth)/error/page.tsx:
--------------------------------------------------------------------------------
1 | import { ErrorCard } from "@/components/auth/error-card";
2 | import { Metadata } from "next";
3 | import { AuthError } from "next-auth";
4 |
5 | export const metadata: Metadata = {
6 | title: "Oops! Something went wrong",
7 | };
8 |
9 | export default function AuthErrorPage({
10 | searchParams,
11 | }: {
12 | searchParams: { message: AuthError["type"] };
13 | }) {
14 | return ;
15 | }
16 |
--------------------------------------------------------------------------------
/app/(auth)/login/page.tsx:
--------------------------------------------------------------------------------
1 | import { LoginForm } from "@/components/form/login-form";
2 | import { Metadata } from "next";
3 | import { redirect } from "next/navigation";
4 |
5 | export const metadata: Metadata = {
6 | title: "Login",
7 | };
8 |
9 | export default async function LoginPage({ searchParams }: { searchParams: { error: string } }) {
10 | if (searchParams.error) redirect(`/error?message=${searchParams.error}`);
11 | return ;
12 | }
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/app/(main)/profile/page.tsx:
--------------------------------------------------------------------------------
1 | import { ProfileForm } from "@/components/form/profile-form";
2 | import { currentUser } from "@/lib/auth";
3 | import { Metadata } from "next";
4 |
5 | export const metadata: Metadata = {
6 | title: "Profile",
7 | };
8 |
9 | export default async function ProfilePage() {
10 | const user = await currentUser();
11 | if (!user) return;
12 |
13 | return (
14 |
15 |
Profile Settings
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/(auth)/verify/page.tsx:
--------------------------------------------------------------------------------
1 | import { newVerification } from "@/actions/verify-token";
2 | import { NewVerificationForm } from "@/components/form/verify-token-form";
3 | import { Metadata } from "next";
4 | import { redirect } from "next/navigation";
5 |
6 | export const metadata: Metadata = {
7 | title: "Verify Email",
8 | };
9 |
10 | export default async function NewVerificationPage({
11 | searchParams,
12 | }: {
13 | searchParams: { token: string };
14 | }) {
15 | if (!searchParams.token) redirect("/login");
16 | const data = await newVerification(searchParams.token);
17 |
18 | return ;
19 | }
20 |
--------------------------------------------------------------------------------
/app/(auth)/new-password/page.tsx:
--------------------------------------------------------------------------------
1 | import { NewPasswordForm } from "@/components/form/new-password-form";
2 | import { getResetPasswordToken } from "@/services/reset-password-token";
3 | import { Metadata } from "next";
4 | import { redirect } from "next/navigation";
5 |
6 | export const metadata: Metadata = {
7 | title: "Reset Password",
8 | };
9 |
10 | export default async function NewPassword({ searchParams }: { searchParams: { token: string } }) {
11 | if (!searchParams.token) redirect("/");
12 | const resetPasswordToken = await getResetPasswordToken(searchParams.token);
13 | if (!resetPasswordToken) redirect("/");
14 |
15 | return ;
16 | }
17 |
--------------------------------------------------------------------------------
/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import { UserRole } from "@prisma/client";
2 | import { DefaultSession } from "next-auth";
3 |
4 | export type ExtendedUser = DefaultSession["user"] & {
5 | role: UserRole;
6 | isTwoFactorEnabled: boolean;
7 | isOAuth: boolean;
8 | };
9 |
10 | declare module "next-auth" {
11 | interface Session {
12 | user: ExtendedUser;
13 | }
14 | }
15 |
16 | declare module "@auth/core/jwt" {
17 | interface JWT extends ExtendedUser {}
18 | }
19 |
20 | // declare module "next-auth/providers/github" {
21 | // interface GithubProfile {
22 | // role: Role;
23 | // }
24 | // }
25 |
26 | // declare module "next-auth/providers/google" {
27 | // interface GoogleProfile {
28 | // role: Role;
29 | // }
30 | // }
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | },
24 | "typeRoots": ["types"]
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "@/styles/globals.css";
4 | import { Toaster } from "@/components/ui/sonner";
5 |
6 | const inter = Inter({ subsets: ["latin"] });
7 |
8 | export const metadata: Metadata = {
9 | title: {
10 | default: "Next Dashboard | Zulmy Azhary",
11 | template: "Next Dashboard | %s",
12 | },
13 | description: "Generated by create next app",
14 | };
15 |
16 | export default async function RootLayout({ children }: { children: React.ReactNode }) {
17 | return (
18 |
19 |
20 |
21 | {children}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/components/form/verify-token-form.tsx:
--------------------------------------------------------------------------------
1 | import { CardWrapper } from "@/components/auth/card-wrapper";
2 | import type { Response } from "@/types";
3 | import { redirect } from "next/navigation";
4 |
5 | type NewVerificationFormProps = {
6 | data: Response;
7 | };
8 |
9 | export const NewVerificationForm = ({ data }: NewVerificationFormProps) => {
10 | if (!data.success) {
11 | return redirect("/login");
12 | }
13 |
14 | return (
15 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Your app base url
2 | NEXT_PUBLIC_APP_URL="http://localhost:3000"
3 |
4 | # Your postgres' database url
5 | # example: postgresql://username:password@host:port/database
6 | POSTGRES_URL=
7 | POSTGRES_PRISMA_URL=
8 | POSTGRES_URL_NON_POOLING=
9 | POSTGRES_USER=
10 | POSTGRES_HOST=
11 | POSTGRES_PASSWORD=
12 | POSTGRES_DATABASE=
13 |
14 | # You can generate by run this command on your terminal: openssl rand -base64 32
15 | AUTH_SECRET=
16 |
17 | # You can get github id & secret by creating OAuth Apps from Settings > Developer Settings > OAuth Apps
18 | # More info: https://next-auth.js.org/providers/github
19 | GITHUB_ID=
20 | GITHUB_SECRET=
21 |
22 | # https://next-auth.js.org/providers/google
23 | GOOGLE_ID=
24 | GOOGLE_SECRET=
25 |
26 | # https://resend.com
27 | RESEND_API_KEY=
28 | RESEND_DOMAIN=
29 | EMAIL_FROM="Next Dashboard "
30 |
31 | # You can generate by run this command on your terminal: openssl rand -hex 64
32 | JWT_SECRET=
33 |
--------------------------------------------------------------------------------
/components/auth/error-card.tsx:
--------------------------------------------------------------------------------
1 | import { CardWrapper } from "@/components/auth/card-wrapper";
2 | import { AuthError } from "next-auth";
3 | import { redirect } from "next/navigation";
4 |
5 | type ErrorCardProps = {
6 | message?: AuthError["type"];
7 | };
8 |
9 | export const ErrorCard = ({ message }: ErrorCardProps) => {
10 | let headerDescription =
11 | "Oops! Something went wrong. Please contact administrator for more details or try again later.";
12 |
13 | if (!message) {
14 | redirect("/login");
15 | }
16 |
17 | if (message === "OAuthAccountNotLinked") {
18 | headerDescription =
19 | "Another account already registered with the same Email Address. Please login the different one.";
20 | }
21 |
22 | return (
23 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "next-themes";
4 | import { Toaster as Sonner } from "sonner";
5 |
6 | type ToasterProps = React.ComponentProps;
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme();
10 |
11 | return (
12 |
26 | );
27 | };
28 |
29 | export { Toaster };
30 |
--------------------------------------------------------------------------------
/services/user.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 | import { registerSchema } from "@/schemas";
3 | import { Prisma } from "@prisma/client";
4 | import { z } from "zod";
5 |
6 | export const getUserByEmail = async (email: string) => {
7 | try {
8 | const user = await db.user.findUnique({ where: { email } });
9 |
10 | return user;
11 | } catch {
12 | return null;
13 | }
14 | };
15 |
16 | export const getUserById = async (id: string) => {
17 | try {
18 | const user = await db.user.findUnique({ where: { id } });
19 |
20 | return user;
21 | } catch {
22 | return null;
23 | }
24 | };
25 |
26 | export const createUser = async (payload: z.infer) => {
27 | try {
28 | return await db.user.create({
29 | data: payload,
30 | });
31 | } catch {
32 | return null;
33 | }
34 | };
35 |
36 | type UpdateUserType = Prisma.Args["data"];
37 | export const updateUserById = async (id: string, payload: UpdateUserType) => {
38 | try {
39 | return await db.user.update({
40 | where: { id },
41 | data: payload,
42 | });
43 | } catch {
44 | return null;
45 | }
46 | };
47 |
--------------------------------------------------------------------------------
/components/auth/social.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { signIn } from "next-auth/react";
5 | import { useSearchParams } from "next/navigation";
6 | import { IoLogoGithub } from "react-icons/io5";
7 | import { FcGoogle } from "react-icons/fc";
8 | import { DEFAULT_LOGIN_REDIRECT } from "@/routes";
9 |
10 | export const Social = () => {
11 | const searchParams = useSearchParams();
12 | const callbackUrl = searchParams.get("callbackUrl") || DEFAULT_LOGIN_REDIRECT;
13 |
14 | const onClick = (provider: "google" | "github") => {
15 | signIn(provider, {
16 | callbackUrl,
17 | });
18 | };
19 |
20 | return (
21 |
22 | onClick("google")}
27 | >
28 |
29 |
30 | onClick("github")}
35 | >
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authConfig } from "@/auth/config";
2 | import NextAuth from "next-auth";
3 | import { DEFAULT_LOGIN_REDIRECT, apiAuthPrefix, authRoutes, publicRoutes } from "@/routes";
4 |
5 | export const { auth } = NextAuth(authConfig);
6 |
7 | export default auth((req) => {
8 | const { nextUrl } = req;
9 | const isLoggedIn = !!req.auth;
10 |
11 | const isApiAuthRoute = nextUrl.pathname.startsWith(apiAuthPrefix);
12 | const isPublicRoutes = publicRoutes.includes(nextUrl.pathname);
13 | const isAuthRoutes = authRoutes.includes(nextUrl.pathname);
14 |
15 | if (isApiAuthRoute) {
16 | return null;
17 | }
18 |
19 | if (isAuthRoutes) {
20 | if (isLoggedIn) {
21 | return Response.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl));
22 | }
23 | return null;
24 | }
25 |
26 | if (!isLoggedIn && !isPublicRoutes) {
27 | return Response.redirect(new URL("/login", nextUrl));
28 | }
29 |
30 | return null;
31 | });
32 |
33 | // Optionally, don't invoke Middleware on some paths
34 | // Read more: https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
35 | export const config = {
36 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
37 | };
38 |
--------------------------------------------------------------------------------
/app/(auth)/two-factor/page.tsx:
--------------------------------------------------------------------------------
1 | import { TwoFactorForm } from "@/components/form/two-factor-form";
2 | import { verifyJwtToken } from "@/lib/utils";
3 | import { loginSchema } from "@/schemas";
4 | import { getTwoFactorTokenByEmail } from "@/services/two-factor-token";
5 | import { Metadata } from "next";
6 | import { cookies } from "next/headers";
7 | import { redirect } from "next/navigation";
8 | import { z } from "zod";
9 |
10 | export const metadata: Metadata = {
11 | title: "Two-Factor Authentication",
12 | };
13 |
14 | export default async function TwoFactorPage() {
15 | const cookieStore = cookies();
16 |
17 | let credentials = cookieStore.get("credentials-session");
18 | if (!credentials) {
19 | redirect("/");
20 | }
21 |
22 | const verifyToken = verifyJwtToken>(credentials.value);
23 | if (!verifyToken.valid || !verifyToken.decoded) {
24 | redirect("/");
25 | }
26 |
27 | const existingToken = await getTwoFactorTokenByEmail(verifyToken.decoded.email);
28 | if (!existingToken) {
29 | redirect("/");
30 | }
31 |
32 | return (
33 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/components/auth/form-input.tsx:
--------------------------------------------------------------------------------
1 | import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
2 | import { Input } from "@/components/ui/input";
3 | import { cn } from "@/lib/utils";
4 | import { Control, FieldValues, Path } from "react-hook-form";
5 |
6 | type FormInputProps = React.ComponentPropsWithRef<"input"> & {
7 | control: Control;
8 | name: Path;
9 | label: string;
10 | isPending?: boolean;
11 | };
12 |
13 | export const FormInput = (props: FormInputProps) => {
14 | const { control, name, label, isPending, disabled, ...rest } = props;
15 | return (
16 | (
20 |
21 | {label}
22 |
23 |
29 |
30 |
31 |
32 | )}
33 | />
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/auth/providers.ts:
--------------------------------------------------------------------------------
1 | import { loginSchema } from "@/schemas";
2 | import { getUserByEmail } from "@/services/user";
3 | import Credentials from "next-auth/providers/credentials";
4 | import Github from "next-auth/providers/github";
5 | import Google from "next-auth/providers/google";
6 | import bcrypt from "bcryptjs";
7 |
8 | export const CredentialsProvider = Credentials({
9 | async authorize(credentials) {
10 | const validatedFields = loginSchema.safeParse(credentials);
11 |
12 | if (validatedFields.success) {
13 | const { email, password } = validatedFields.data;
14 |
15 | const user = await getUserByEmail(email);
16 | if (!user || !user.password) return null;
17 |
18 | const passwordsMatch = await bcrypt.compare(password, user.password);
19 |
20 | if (passwordsMatch) return user;
21 | }
22 |
23 | return null;
24 | },
25 | });
26 |
27 | export const GithubProvider = Github({
28 | clientId: process.env.GITHUB_ID as string,
29 | clientSecret: process.env.GITHUB_SECRET as string,
30 | });
31 |
32 | export const GoogleProvider = Google({
33 | clientId: process.env.GOOGLE_ID as string,
34 | clientSecret: process.env.GOOGLE_SECRET as string,
35 | authorization: {
36 | params: {
37 | prompt: "consent",
38 | access_type: "offline",
39 | response_type: "code",
40 | },
41 | },
42 | });
43 |
--------------------------------------------------------------------------------
/services/mail.ts:
--------------------------------------------------------------------------------
1 | import { Resend } from "resend";
2 |
3 | const resend = new Resend(process.env.RESEND_API_KEY);
4 |
5 | export const sendVerificationEmail = async (email: string, token: string) => {
6 | const verifyEmailLink = `${process.env.NEXT_PUBLIC_APP_URL}/verify?token=${token}`;
7 |
8 | await resend.emails.send({
9 | from: process.env.EMAIL_FROM as string,
10 | to: email,
11 | subject: "[Next Dashboard] Action required: Verify your email",
12 | html: `Click Here to verify your email.
`,
13 | });
14 | };
15 |
16 | export const sendResetPasswordEmail = async (email: string, token: string) => {
17 | const resetPasswordLink = `${process.env.NEXT_PUBLIC_APP_URL}/new-password?token=${token}`;
18 |
19 | await resend.emails.send({
20 | from: process.env.EMAIL_FROM as string,
21 | to: email,
22 | subject: "[Next Dashboard] Action required: Reset your password",
23 | html: `Click Here to reset your password.
`,
24 | });
25 | };
26 |
27 | export const sendTwoFactorEmail = async (email: string, token: string) => {
28 | await resend.emails.send({
29 | from: process.env.EMAIL_FROM as string,
30 | to: email,
31 | subject: "[Next Dashboard] Action required: Confirm Two-Factor Authentication",
32 | html: `${token} is your authentication Code.
`,
33 | });
34 | };
35 |
--------------------------------------------------------------------------------
/components/auth/form-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/components/ui/form";
2 | import { Control, FieldValues, Path } from "react-hook-form";
3 | import { Switch } from "@/components/ui/switch";
4 |
5 | type FormToggleProps = React.ComponentPropsWithRef<"button"> & {
6 | control: Control;
7 | name: Path;
8 | label: string;
9 | isPending?: boolean;
10 | description: string;
11 | };
12 |
13 | export const FormToggle = (props: FormToggleProps) => {
14 | const { control, name, label, description, isPending, ...rest } = props;
15 | return (
16 | (
20 |
21 | {label}
22 |
23 |
24 | {description}
25 |
26 |
27 |
33 |
34 |
35 |
36 | )}
37 | />
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/services/two-factor-confirmation.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 | import { setTokenExpiration } from "@/lib/utils";
3 |
4 | export const generateTwoFactorConfirmation = async (userId: string) => {
5 | const existingTwoFactorConfirmation = await getTwoFactorConfirmationByUserId(userId);
6 | if (existingTwoFactorConfirmation) {
7 | await deleteTwoFactorConfirmationById(existingTwoFactorConfirmation.id);
8 | }
9 |
10 | const expires = setTokenExpiration(60 * 15); // 15 minutes
11 |
12 | const twoFactorConfirmation = await db.twoFactorConfirmation.create({
13 | data: {
14 | userId,
15 | expires,
16 | },
17 | });
18 |
19 | return twoFactorConfirmation;
20 | };
21 |
22 | export const getTwoFactorConfirmationByUserId = async (userId: string) => {
23 | try {
24 | const twoFactorConfirmation = await db.twoFactorConfirmation.findUnique({
25 | where: { userId },
26 | });
27 |
28 | return twoFactorConfirmation;
29 | } catch {
30 | return null;
31 | }
32 | };
33 |
34 | export const deleteTwoFactorConfirmationById = async (id: string) => {
35 | try {
36 | return await db.twoFactorConfirmation.delete({
37 | where: { id },
38 | });
39 | } catch {
40 | return null;
41 | }
42 | };
43 |
44 | export const deleteTwoFactorConfirmationByUserId = async (userId: string) => {
45 | try {
46 | return await db.twoFactorConfirmation.delete({
47 | where: { userId },
48 | });
49 | } catch {
50 | return null;
51 | }
52 | }
--------------------------------------------------------------------------------
/services/two-factor-token.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 | import { setTokenExpiration } from "@/lib/utils";
3 | import crypto from "node:crypto";
4 |
5 | export const generateTwoFactorToken = async (email: string) => {
6 | const existingToken = await getTwoFactorTokenByEmail(email);
7 | if (existingToken) {
8 | await deleteTwoFactorTokenById(existingToken.id);
9 | }
10 |
11 | const token = String(crypto.randomInt(100000, 1000000));
12 | const expires = setTokenExpiration(60 * 2); // 2 minutes
13 |
14 | const twoFactorToken = await db.twoFactorToken.create({
15 | data: {
16 | email,
17 | token,
18 | expires,
19 | },
20 | });
21 |
22 | return twoFactorToken;
23 | };
24 |
25 | export const getTwoFactorToken = async (token: string) => {
26 | try {
27 | const twoFactorToken = await db.twoFactorToken.findUnique({
28 | where: { token },
29 | });
30 |
31 | return twoFactorToken;
32 | } catch {
33 | return null;
34 | }
35 | };
36 |
37 | export const getTwoFactorTokenByEmail = async (email: string) => {
38 | try {
39 | const twoFactorToken = await db.twoFactorToken.findFirst({
40 | where: { email },
41 | });
42 |
43 | return twoFactorToken;
44 | } catch {
45 | return null;
46 | }
47 | };
48 |
49 | export const deleteTwoFactorTokenById = async (id: string) => {
50 | try {
51 | return await db.twoFactorToken.delete({
52 | where: { id },
53 | });
54 | } catch {
55 | return null;
56 | }
57 | };
58 |
--------------------------------------------------------------------------------
/services/verification-token.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 | import { setTokenExpiration } from "@/lib/utils";
3 | import { v4 as uuid } from "uuid";
4 |
5 | export const generateVerificationToken = async (email: string) => {
6 | const existingToken = await getVerificationTokenByEmail(email);
7 | if (existingToken) {
8 | await deleteVerificationTokenById(existingToken.id);
9 | }
10 |
11 | const token = uuid();
12 | const expires = setTokenExpiration();
13 |
14 | const verificationToken = await db.verificationToken.create({
15 | data: {
16 | email,
17 | token,
18 | expires,
19 | },
20 | });
21 |
22 | return verificationToken;
23 | };
24 |
25 | export const getVerificationToken = async (token: string) => {
26 | try {
27 | const verificationToken = await db.verificationToken.findUnique({
28 | where: { token },
29 | });
30 |
31 | return verificationToken;
32 | } catch {
33 | return null;
34 | }
35 | };
36 |
37 | export const getVerificationTokenByEmail = async (email: string) => {
38 | try {
39 | const verificationToken = await db.verificationToken.findFirst({
40 | where: { email },
41 | });
42 |
43 | return verificationToken;
44 | } catch {
45 | return null;
46 | }
47 | };
48 |
49 | export const deleteVerificationTokenById = async (id: string) => {
50 | try {
51 | return await db.verificationToken.delete({
52 | where: { id },
53 | });
54 | } catch {
55 | return null;
56 | }
57 | };
58 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/services/reset-password-token.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 | import { setTokenExpiration } from "@/lib/utils";
3 | import { v4 as uuid } from "uuid";
4 |
5 | export const generateResetPasswordToken = async (email: string) => {
6 | const existingToken = await getResetPasswordTokenByEmail(email);
7 | if (existingToken) {
8 | await deleteResetPasswordTokenById(existingToken.id);
9 | }
10 |
11 | const token = uuid();
12 | const expires = setTokenExpiration();
13 |
14 | const resetPasswordToken = await db.resetPasswordToken.create({
15 | data: {
16 | email,
17 | token,
18 | expires,
19 | },
20 | });
21 |
22 | return resetPasswordToken;
23 | };
24 |
25 | export const getResetPasswordToken = async (token: string) => {
26 | try {
27 | const resetPasswordToken = await db.resetPasswordToken.findUnique({
28 | where: { token },
29 | });
30 |
31 | return resetPasswordToken;
32 | } catch {
33 | return null;
34 | }
35 | };
36 |
37 | export const getResetPasswordTokenByEmail = async (email: string) => {
38 | try {
39 | const resetPasswordToken = await db.resetPasswordToken.findFirst({
40 | where: { email },
41 | });
42 |
43 | return resetPasswordToken;
44 | } catch {
45 | return null;
46 | }
47 | };
48 |
49 | export const deleteResetPasswordTokenById = async (id: string) => {
50 | try {
51 | return await db.resetPasswordToken.delete({
52 | where: { id },
53 | });
54 | } catch {
55 | return null;
56 | }
57 | };
58 |
--------------------------------------------------------------------------------
/actions/resend.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { response } from "@/lib/utils";
4 | import { resendSchema } from "@/schemas";
5 | import { sendVerificationEmail } from "@/services/mail";
6 | import { generateVerificationToken, getVerificationTokenByEmail } from "@/services/verification-token";
7 | import { z } from "zod";
8 |
9 | export const resendToken = async (payload: z.infer) => {
10 | // Check if user input is not valid.
11 | const validatedFields = resendSchema.safeParse(payload);
12 | if (!validatedFields.success) {
13 | return response({
14 | success: false,
15 | error: {
16 | code: 422,
17 | message: "Invalid fields.",
18 | },
19 | });
20 | }
21 |
22 | const { email } = validatedFields.data;
23 |
24 | // Check if token doesn't exist, then return an error.
25 | const existingToken = await getVerificationTokenByEmail(email);
26 | if (!existingToken) {
27 | return response({
28 | success: false,
29 | error: {
30 | code: 422,
31 | message: "Failed to resend verification email.",
32 | },
33 | });
34 | }
35 |
36 | // Generate verification token and resend to the email.
37 | const verificationToken = await generateVerificationToken(existingToken.email);
38 | await sendVerificationEmail(verificationToken.email, verificationToken.token);
39 |
40 | // Return response success.
41 | return response({
42 | success: true,
43 | code: 201,
44 | message: "Confirmation email sent. Please check your email.",
45 | });
46 | }
--------------------------------------------------------------------------------
/public/assets/email-verified.svg:
--------------------------------------------------------------------------------
1 | email-verification
--------------------------------------------------------------------------------
/types/index.ts:
--------------------------------------------------------------------------------
1 | const responseStatus = {
2 | 200: "OK",
3 | 201: "Created",
4 | 202: "Accepted",
5 | 203: "Non-Authoritative Information",
6 | 204: "No Content",
7 | 400: "Bad Request",
8 | 401: "Unauthorized",
9 | 402: "Payment Required",
10 | 403: "Forbidden",
11 | 404: "Not Found",
12 | 405: "Method Not Allowed",
13 | 406: "Not Acceptable",
14 | 408: "Request Timeout",
15 | 410: "Gone",
16 | 422: "Unprocessable Entity",
17 | 429: "Too Many Requests",
18 | 500: "Internal Server Error",
19 | 502: "Bad Gateway",
20 | 503: "Service Unavailable",
21 | } as const;
22 |
23 | const reason = {
24 | REQUIRED: "The requested resource is required",
25 | NOT_AVAILABLE: "The requested resource is not available",
26 | EXPIRED: "The requested resource is expired",
27 | } as const;
28 |
29 | type ResponseStatus = typeof responseStatus;
30 | type ResponseCode = keyof ResponseStatus;
31 |
32 | type ErrorType = keyof typeof reason;
33 |
34 | type ResponseError = {
35 | success: false;
36 | error: {
37 | code: ResponseCode;
38 | type?: ErrorType;
39 | message: string;
40 | };
41 | };
42 |
43 | export type ResponseWithMessage =
44 | | {
45 | success: true;
46 | code: ResponseCode;
47 | message: string;
48 | }
49 | | ResponseError;
50 |
51 | export type ResponseSuccess =
52 | | {
53 | success: true;
54 | code: ResponseCode;
55 | message?: string;
56 | data: T;
57 | }
58 | | ResponseError;
59 |
60 | export type Response = T extends object ? ResponseSuccess : ResponseWithMessage;
61 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Metadata } from "next";
3 |
4 | export const metadata: Metadata = {
5 | title: "404 Not Found",
6 | };
7 |
8 | export default function NotFound() {
9 | return (
10 |
11 |
12 |
13 |
14 | Error
15 |
16 |
404
17 |
18 |
19 |
20 |
21 | Page Not Found
22 |
23 |
24 | The content you’re looking for doesn’t exist. Either it was removed, or you mistyped the
25 | link.
26 |
27 | Sorry about that! Please visit our homepage to get where you need to go.
28 |
29 |
33 | Go back to Homepage
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/actions/verify-token.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { isExpired, response } from "@/lib/utils";
4 | import { getUserByEmail, updateUserById } from "@/services/user";
5 | import { deleteVerificationTokenById, getVerificationToken } from "@/services/verification-token";
6 | import { redirect } from "next/navigation";
7 |
8 | export const newVerification = async (token: string) => {
9 | // Check if token doesn't exist, then return an error.
10 | const existingToken = await getVerificationToken(token);
11 | if (!existingToken) {
12 | return response({
13 | success: false,
14 | error: {
15 | code: 422,
16 | message: "Invalid token provided.",
17 | },
18 | });
19 | }
20 |
21 | // Check if token has expired, then redirect to the resend form.
22 | const hasExpired = isExpired(existingToken.expires);
23 | if (hasExpired) {
24 | redirect("/resend");
25 | }
26 |
27 | // Check if email address doesn't exist, then return an error
28 | const existingUser = await getUserByEmail(existingToken.email);
29 | if (!existingUser || !existingUser.email || !existingUser.password) {
30 | return response({
31 | success: false,
32 | error: {
33 | code: 401,
34 | message: "Email address does not exist.",
35 | },
36 | });
37 | }
38 |
39 | // Update user verified based on current datetime.
40 | await updateUserById(existingUser.id, {
41 | emailVerified: new Date(),
42 | email: existingToken.email, // This is needed when user want to change their email address
43 | });
44 | // Then delete verify token.
45 | await deleteVerificationTokenById(existingToken.id);
46 |
47 | return response({
48 | success: true,
49 | code: 200,
50 | message: "Your email address has been verified.",
51 | });
52 | };
53 |
--------------------------------------------------------------------------------
/actions/register.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { registerSchema } from "@/schemas";
4 | import { z } from "zod";
5 | import { createUser, getUserByEmail } from "@/services/user";
6 | import { generateVerificationToken } from "@/services/verification-token";
7 | import { sendVerificationEmail } from "@/services/mail";
8 | import { hashPassword, response } from "@/lib/utils";
9 |
10 | export const register = async (payload: z.infer) => {
11 | // Check if user input is not valid.
12 | const validatedFields = registerSchema.safeParse(payload);
13 | if (!validatedFields.success) {
14 | return response({
15 | success: false,
16 | error: {
17 | code: 422,
18 | message: "Invalid fields.",
19 | },
20 | });
21 | }
22 | const { name, email, password } = validatedFields.data;
23 |
24 | // Check if user already exist, then return an error.
25 | const existingUser = await getUserByEmail(email);
26 | if (existingUser) {
27 | return response({
28 | success: false,
29 | error: {
30 | code: 422,
31 | message: "Email address already exists. Please use another one.",
32 | },
33 | });
34 | }
35 |
36 | // Hash password that user entered.
37 | const hashedPassword = await hashPassword(password);
38 |
39 | // Create an user.
40 | await createUser({ name, email, password: hashedPassword });
41 |
42 | // Generate verification token, then send it to the email.
43 | const verificationToken = await generateVerificationToken(email);
44 | await sendVerificationEmail(verificationToken.email, verificationToken.token);
45 |
46 | // Return response success.
47 | return response({
48 | success: true,
49 | code: 201,
50 | message: "Confirmation email sent. Please check your email.",
51 | });
52 | };
53 |
--------------------------------------------------------------------------------
/actions/reset-password.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { z } from "zod";
4 | import { resetPasswordSchema } from "@/schemas";
5 | import { getUserByEmail } from "@/services/user";
6 | import { generateResetPasswordToken } from "@/services/reset-password-token";
7 | import { sendResetPasswordEmail } from "@/services/mail";
8 | import { response } from "@/lib/utils";
9 |
10 | export const resetPassword = async (payload: z.infer) => {
11 | // Check if user input is not valid.
12 | const validatedFields = resetPasswordSchema.safeParse(payload);
13 | if (!validatedFields.success) {
14 | return response({
15 | success: false,
16 | error: {
17 | code: 422,
18 | message: "Invalid fields.",
19 | },
20 | });
21 | }
22 |
23 | const { email } = validatedFields.data;
24 |
25 | // Check if user doesn't exist, then return an error.
26 | const existingUser = await getUserByEmail(email);
27 | if (!existingUser || !existingUser.email || !existingUser.password) {
28 | return response({
29 | success: false,
30 | error: {
31 | code: 401,
32 | message: "Email address does not exist.",
33 | },
34 | });
35 | }
36 |
37 | // Check if user email isn't verified yet, then return an error.
38 | if (!existingUser.emailVerified) {
39 | return response({
40 | success: false,
41 | error: {
42 | code: 401,
43 | message: "Your email address is not verified yet. Please check your email.",
44 | },
45 | });
46 | }
47 |
48 | // Generate reset password token, then send it to the email.
49 | const resetPasswordToken = await generateResetPasswordToken(email);
50 | await sendResetPasswordEmail(resetPasswordToken.email, resetPasswordToken.token);
51 |
52 | // Return response success.
53 | return response({
54 | success: true,
55 | code: 201,
56 | message: "Email has been sent. Please check to your email.",
57 | });
58 | };
59 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html, body, :root {
6 | height: 100%;
7 | }
8 |
9 | @layer base {
10 | :root {
11 | --background: 0 0% 100%;
12 | --foreground: 222.2 84% 4.9%;
13 |
14 | --card: 0 0% 100%;
15 | --card-foreground: 222.2 84% 4.9%;
16 |
17 | --popover: 0 0% 100%;
18 | --popover-foreground: 222.2 84% 4.9%;
19 |
20 | --primary: 222.2 47.4% 11.2%;
21 | --primary-foreground: 210 40% 98%;
22 |
23 | --secondary: 210 40% 96.1%;
24 | --secondary-foreground: 222.2 47.4% 11.2%;
25 |
26 | --muted: 210 40% 96.1%;
27 | --muted-foreground: 215.4 16.3% 46.9%;
28 |
29 | --accent: 210 40% 96.1%;
30 | --accent-foreground: 222.2 47.4% 11.2%;
31 |
32 | --destructive: 0 84.2% 60.2%;
33 | --destructive-foreground: 210 40% 98%;
34 |
35 | --border: 214.3 31.8% 91.4%;
36 | --input: 214.3 31.8% 91.4%;
37 | --ring: 222.2 84% 4.9%;
38 |
39 | --radius: 0.5rem;
40 | }
41 |
42 | .dark {
43 | --background: 222.2 84% 4.9%;
44 | --foreground: 210 40% 98%;
45 |
46 | --card: 222.2 84% 4.9%;
47 | --card-foreground: 210 40% 98%;
48 |
49 | --popover: 222.2 84% 4.9%;
50 | --popover-foreground: 210 40% 98%;
51 |
52 | --primary: 210 40% 98%;
53 | --primary-foreground: 222.2 47.4% 11.2%;
54 |
55 | --secondary: 217.2 32.6% 17.5%;
56 | --secondary-foreground: 210 40% 98%;
57 |
58 | --muted: 217.2 32.6% 17.5%;
59 | --muted-foreground: 215 20.2% 65.1%;
60 |
61 | --accent: 217.2 32.6% 17.5%;
62 | --accent-foreground: 210 40% 98%;
63 |
64 | --destructive: 0 62.8% 30.6%;
65 | --destructive-foreground: 210 40% 98%;
66 |
67 | --border: 217.2 32.6% 17.5%;
68 | --input: 217.2 32.6% 17.5%;
69 | --ring: 212.7 26.8% 83.9%;
70 | }
71 | }
72 |
73 | @layer base {
74 | * {
75 | @apply border-border;
76 | }
77 | body {
78 | @apply bg-background text-foreground;
79 | }
80 | }
--------------------------------------------------------------------------------
/components/form/resend-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CardWrapper } from "@/components/auth/card-wrapper";
4 | import { resendSchema } from "@/schemas";
5 | import { useForm } from "react-hook-form";
6 | import { z } from "zod";
7 | import { Form } from "@/components/ui/form";
8 | import { FormInput } from "@/components/auth/form-input";
9 | import { useTransition } from "react";
10 | import { resendToken } from "@/actions/resend";
11 | import { toast } from "sonner";
12 | import { Button } from "@/components/ui/button";
13 | import { zodResolver } from "@hookform/resolvers/zod";
14 |
15 | export const ResendForm = () => {
16 | const [isPending, startTransition] = useTransition();
17 | const form = useForm>({
18 | resolver: zodResolver(resendSchema),
19 | defaultValues: {
20 | email: ""
21 | }
22 | })
23 |
24 | const handleSubmit = form.handleSubmit(values => {
25 | startTransition(() => {
26 | resendToken(values).then((data) => {
27 | if (data.success) {
28 | return toast.success(data.message);
29 | }
30 | return toast.error(data.error.message);
31 | });
32 | })
33 | });
34 | return (
35 |
41 |
53 |
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/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 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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-dashboard",
3 | "version": "0.1.0",
4 | "private": true,
5 | "author": {
6 | "name": "Zulmy Azhary",
7 | "email": "zulmyazhary32@gmail.com",
8 | "url": "https://zoel.vercel.app"
9 | },
10 | "scripts": {
11 | "dev": "next dev",
12 | "build": "next build",
13 | "start": "next start",
14 | "lint": "next lint",
15 | "prisma:update": "npx prisma generate && npx prisma db push",
16 | "prisma:clean": "npx prisma generate && npx prisma migrate reset && npx prisma db push",
17 | "postinstall": "prisma generate"
18 | },
19 | "dependencies": {
20 | "@auth/prisma-adapter": "^1.0.14",
21 | "@hookform/resolvers": "^3.3.4",
22 | "@prisma/client": "^5.7.1",
23 | "@radix-ui/react-avatar": "^1.0.4",
24 | "@radix-ui/react-dropdown-menu": "^2.0.6",
25 | "@radix-ui/react-label": "^2.0.2",
26 | "@radix-ui/react-separator": "^1.0.3",
27 | "@radix-ui/react-slot": "^1.0.2",
28 | "@radix-ui/react-switch": "^1.0.3",
29 | "bcryptjs": "^2.4.3",
30 | "class-variance-authority": "^0.7.0",
31 | "clsx": "^2.0.0",
32 | "jsonwebtoken": "^9.0.2",
33 | "lucide-react": "^0.303.0",
34 | "next": "14.0.4",
35 | "next-auth": "^5.0.0-beta.4",
36 | "next-themes": "^0.2.1",
37 | "react": "^18",
38 | "react-dom": "^18",
39 | "react-hook-form": "^7.49.2",
40 | "react-icons": "^4.12.0",
41 | "resend": "^2.1.0",
42 | "sonner": "^1.3.1",
43 | "tailwind-merge": "^2.2.0",
44 | "tailwindcss-animate": "^1.0.7",
45 | "uuid": "^9.0.1",
46 | "zod": "^3.22.4"
47 | },
48 | "devDependencies": {
49 | "@types/bcryptjs": "^2.4.6",
50 | "@types/jsonwebtoken": "^9.0.5",
51 | "@types/node": "^20",
52 | "@types/react": "^18",
53 | "@types/react-dom": "^18",
54 | "@types/uuid": "^9.0.7",
55 | "autoprefixer": "^10.0.1",
56 | "eslint": "^8",
57 | "eslint-config-next": "14.0.4",
58 | "postcss": "^8",
59 | "prisma": "^5.7.1",
60 | "tailwindcss": "^3.3.0",
61 | "typescript": "^5"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/components/form/reset-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CardWrapper } from "@/components/auth/card-wrapper";
4 | import { Form } from "@/components/ui/form";
5 | import { FormInput } from "@/components/auth/form-input";
6 | import { useTransition } from "react";
7 | import { useForm } from "react-hook-form";
8 | import { resetPasswordSchema } from "@/schemas";
9 | import { zodResolver } from "@hookform/resolvers/zod";
10 | import { z } from "zod";
11 | import { Button } from "@/components/ui/button";
12 | import { resetPassword } from "@/actions/reset-password";
13 | import { toast } from "sonner";
14 | import { useRouter } from "next/navigation";
15 |
16 | export const ResetForm = () => {
17 | const router = useRouter();
18 | const [isPending, startTransition] = useTransition();
19 | const form = useForm>({
20 | resolver: zodResolver(resetPasswordSchema),
21 | defaultValues: {
22 | email: "",
23 | },
24 | });
25 |
26 | const handleSubmit = form.handleSubmit((values) => {
27 | startTransition(() => {
28 | resetPassword(values).then((data) => {
29 | if (data.success) {
30 | router.push("/login");
31 | return toast.success(data.message);
32 | }
33 | return toast.error(data.error.message);
34 | });
35 | });
36 | });
37 |
38 | return (
39 |
45 |
59 |
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | import { sign, verify, type SignOptions, type Secret } from "jsonwebtoken";
4 | import bcrypt from "bcryptjs";
5 | import { Response, ResponseWithMessage } from "@/types";
6 |
7 | export function cn(...inputs: ClassValue[]) {
8 | return twMerge(clsx(inputs));
9 | }
10 |
11 | export async function hashPassword(password: string) {
12 | return await bcrypt.hash(password, await bcrypt.genSalt());
13 | }
14 |
15 | /**
16 | * Function to check whether the given value is expired or not.
17 | * @param expires The date that want to check
18 | * @return true if the value is expired, false otherwise
19 | */
20 | export function isExpired(expires: Date): boolean {
21 | return new Date(expires) < new Date();
22 | }
23 |
24 | /**
25 | * Function to set token expiration.
26 | * @param exp Duration of token expiration, default is 3600 milliseconds or 1 hour
27 | * @return Generates datetime for the token expiration
28 | */
29 | export function setTokenExpiration(exp: number = 60 * 60) {
30 | return new Date(new Date().getTime() + 1000 * exp);
31 | }
32 |
33 | /**
34 | * Function to generate jwt.
35 | * @param payload The payload want to generate
36 | * @param options The sign options
37 | * @return The token generated
38 | */
39 |
40 | export function signJwt(payload: Record, options?: SignOptions) {
41 | return sign(payload, process.env.JWT_SECRET as Secret, {
42 | ...options,
43 | algorithm: "HS256",
44 | });
45 | }
46 |
47 | export const verifyJwtToken = (token: string) => {
48 | try {
49 | const decoded = verify(token, process.env.JWT_SECRET as Secret);
50 | return {
51 | valid: true,
52 | decoded: decoded as T,
53 | };
54 | } catch (error) {
55 | return {
56 | valid: false,
57 | decoded: null,
58 | };
59 | }
60 | };
61 |
62 | // Overload for response status in server action
63 | export function response(response: ResponseWithMessage): Response;
64 | export function response>(response: Response): Response;
65 | export function response(response: T): T {
66 | return response;
67 | }
68 |
--------------------------------------------------------------------------------
/components/auth/card-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardDescription,
5 | CardFooter,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card";
9 | import { Button } from "@/components/ui/button";
10 | import Link from "next/link";
11 | import { Social } from "@/components/auth/social";
12 | import { Separator } from "@/components/ui/separator";
13 | import Image from "next/image";
14 |
15 | type CardWrapperProps = React.HTMLAttributes & {
16 | headerTitle: string;
17 | headerDescription: string;
18 | backButtonLabel: string;
19 | backButtonHref: string;
20 | showSocial?: boolean;
21 | heroImage?: string;
22 | };
23 |
24 | export const CardWrapper = (props: CardWrapperProps) => {
25 | const {
26 | heroImage,
27 | headerTitle,
28 | headerDescription,
29 | backButtonLabel,
30 | backButtonHref,
31 | showSocial,
32 | children,
33 | ...rest
34 | } = props;
35 |
36 | return (
37 |
38 | {heroImage ? (
39 |
40 |
41 |
42 | ) : null}
43 |
44 | {headerTitle}
45 | {headerDescription}
46 |
47 | {children ? {children} : null}
48 | {showSocial ? (
49 | <>
50 |
51 |
52 | Or connect with
53 |
54 |
55 |
56 |
57 |
58 | >
59 | ) : null}
60 |
61 |
62 |
63 | {backButtonLabel}
64 |
65 |
66 |
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "postgresql"
10 | url = env("POSTGRES_PRISMA_URL")
11 | directUrl = env("POSTGRES_URL_NON_POOLING")
12 | }
13 |
14 | model Account {
15 | id String @id @default(cuid())
16 | userId String
17 | type String
18 | provider String
19 | providerAccountId String
20 | refresh_token String? @db.Text
21 | access_token String? @db.Text
22 | expires_at Int?
23 | token_type String?
24 | scope String?
25 | id_token String? @db.Text
26 | session_state String?
27 |
28 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
29 |
30 | @@unique([provider, providerAccountId])
31 | }
32 |
33 | enum UserRole {
34 | Admin
35 | User
36 | }
37 |
38 | model User {
39 | id String @id @default(cuid())
40 | name String?
41 | email String? @unique
42 | emailVerified DateTime?
43 | image String? // You can use gravatar.com to get image profile
44 | password String?
45 | role UserRole @default(User)
46 | accounts Account[]
47 | isTwoFactorEnabled Boolean @default(false)
48 | twoFactorConfirmation TwoFactorConfirmation?
49 | }
50 |
51 | model VerificationToken {
52 | id String @id @default(cuid())
53 | email String
54 | token String @unique
55 | expires DateTime
56 |
57 | @@unique([email, token])
58 | }
59 |
60 | model ResetPasswordToken {
61 | id String @id @default(cuid())
62 | email String
63 | token String @unique
64 | expires DateTime
65 |
66 | @@unique([email, token])
67 | }
68 |
69 | model TwoFactorToken {
70 | id String @id @default(cuid())
71 | email String
72 | token String @unique
73 | expires DateTime
74 |
75 | @@unique([email, token])
76 | }
77 |
78 | model TwoFactorConfirmation {
79 | id String @id @default(cuid())
80 | userId String @unique
81 | expires DateTime
82 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
83 | }
84 |
--------------------------------------------------------------------------------
/actions/new-password.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { newPasswordSchema } from "@/schemas";
4 | import {
5 | deleteResetPasswordTokenById,
6 | getResetPasswordToken,
7 | } from "@/services/reset-password-token";
8 | import { getUserByEmail, updateUserById } from "@/services/user";
9 | import { redirect } from "next/navigation";
10 | import { z } from "zod";
11 | import { hashPassword, isExpired, response } from "@/lib/utils";
12 |
13 | export const newPassword = async (payload: z.infer, token: string) => {
14 | // Check if user input is not valid, then return an error.
15 | const validatedFields = newPasswordSchema.safeParse(payload);
16 | if (!validatedFields.success) {
17 | return response({
18 | success: false,
19 | error: {
20 | code: 422,
21 | message: "Invalid fields.",
22 | },
23 | });
24 | }
25 |
26 | const { password } = validatedFields.data;
27 |
28 | // Check if token doesn't exist, then redirect to login page.
29 | const existingToken = await getResetPasswordToken(token);
30 | if (!existingToken) redirect("/");
31 |
32 | // Check if token has expired, then return an error.
33 | const hasExpired = isExpired(existingToken.expires);
34 | if (hasExpired) {
35 | return response({
36 | success: false,
37 | error: {
38 | code: 401,
39 | message: "Token has expired. Please resend to your email.",
40 | },
41 | });
42 | }
43 |
44 | // Check if email address doesn't exist, then return an error.
45 | const existingUser = await getUserByEmail(existingToken.email);
46 | if (!existingUser || !existingUser.email || !existingUser.password) {
47 | return response({
48 | success: false,
49 | error: {
50 | code: 401,
51 | message: "Email address does not exist.",
52 | },
53 | });
54 | }
55 |
56 | // Create new password by hashing the password first.
57 | const hashedPassword = await hashPassword(password);
58 |
59 | // Replace the old password with the new one.
60 | await updateUserById(existingUser.id, {
61 | password: hashedPassword,
62 | });
63 | // Delete reset password token.
64 | await deleteResetPasswordTokenById(existingToken.id);
65 |
66 | // Then return response success.
67 | return response({
68 | success: true,
69 | code: 200,
70 | message: "Your password has been reset successfully.",
71 | });
72 | };
73 |
--------------------------------------------------------------------------------
/components/form/new-password-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CardWrapper } from "@/components/auth/card-wrapper";
4 | import { Form } from "@/components/ui/form";
5 | import { FormInput } from "@/components/auth/form-input";
6 | import { useTransition } from "react";
7 | import { useForm } from "react-hook-form";
8 | import { newPasswordSchema } from "@/schemas";
9 | import { zodResolver } from "@hookform/resolvers/zod";
10 | import { z } from "zod";
11 | import { Button } from "@/components/ui/button";
12 | import { useRouter } from "next/navigation";
13 | import { newPassword } from "@/actions/new-password";
14 | import { toast } from "sonner";
15 |
16 | type NewPasswordFormProps = {
17 | token: string;
18 | };
19 |
20 | export const NewPasswordForm = ({ token }: NewPasswordFormProps) => {
21 | const router = useRouter();
22 | const [isPending, startTransition] = useTransition();
23 | const form = useForm>({
24 | resolver: zodResolver(newPasswordSchema),
25 | defaultValues: {
26 | password: "",
27 | confirmPassword: "",
28 | },
29 | });
30 |
31 | const handleSubmit = form.handleSubmit((values) => {
32 | startTransition(() => {
33 | newPassword(values, token).then((data) => {
34 | if (data.success) {
35 | router.push("/login");
36 | return toast.success(data.message);
37 | }
38 | return toast.error(data.error.message);
39 | });
40 | });
41 | });
42 |
43 | return (
44 |
50 |
72 |
73 |
74 | );
75 | };
76 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | fontSize: {
76 | "10xl": ["12rem", { lineHeight: "1" }],
77 | },
78 | },
79 | },
80 | plugins: [require("tailwindcss-animate")],
81 | } satisfies Config;
82 |
83 | export default config
--------------------------------------------------------------------------------
/schemas/index.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | const EMAIL_SCHEMA = z
4 | .string()
5 | .min(1, "Email Address is required.")
6 | .email("Invalid Email Address.");
7 |
8 | export const loginSchema = z.object({
9 | email: EMAIL_SCHEMA,
10 | password: z.string().min(1, "Password is required."),
11 | });
12 |
13 | export const registerSchema = z.object({
14 | email: EMAIL_SCHEMA,
15 | name: z
16 | .string()
17 | .min(1, {
18 | message: "Name is required.",
19 | })
20 | .min(4, "Name must be at least 4 characters.")
21 | .max(24, "Maximum length of Name is 24 characters."),
22 | password: z
23 | .string()
24 | .min(1, "Password is required.")
25 | .min(6, "Password must be at least 6 characters."),
26 | });
27 |
28 | export const resendSchema = z.object({
29 | email: EMAIL_SCHEMA,
30 | });
31 |
32 | export const resetPasswordSchema = z.object({
33 | email: EMAIL_SCHEMA,
34 | });
35 |
36 | export const newPasswordSchema = z
37 | .object({
38 | password: z
39 | .string()
40 | .min(1, "Password is required.")
41 | .min(6, "Password must be at least 6 characters."),
42 | confirmPassword: z.string().min(1, "Confirm Password is required."),
43 | })
44 | .refine((data) => data.password === data.confirmPassword, {
45 | message: "Password doesn't match.",
46 | path: ["confirmPassword"],
47 | });
48 |
49 | export const twoFactorSchema = z.object({
50 | code: z
51 | .string()
52 | .regex(/^[0-9]+$/, "Code must be a number.")
53 | .length(6, "Code must be 6 digits long."),
54 | });
55 |
56 | export const profileSchema = z
57 | .object({
58 | name: z.optional(
59 | z
60 | .string()
61 | .min(1, {
62 | message: "Name is required.",
63 | })
64 | .min(4, "Name must be at least 4 characters.")
65 | .max(24, "Maximum length of Name is 24 characters.")
66 | ),
67 | email: z.optional(z.string().email()),
68 | password: z.optional(z.string().min(6, "Password must be at least 6 characters.")),
69 | newPassword: z.optional(z.string().min(6, "New Password must be at least 6 characters.")),
70 | isTwoFactorEnabled: z.optional(z.boolean()),
71 | })
72 | .refine(
73 | (data) => {
74 | if (!data.password && data.newPassword) return false;
75 | return true;
76 | },
77 | {
78 | message: "Password is required.",
79 | path: ["password"],
80 | }
81 | )
82 | .refine(
83 | (data) => {
84 | if (data.password && !data.newPassword) return false;
85 | return true;
86 | },
87 | {
88 | message: "New Password is required.",
89 | path: ["newPassword"],
90 | }
91 | );
92 |
--------------------------------------------------------------------------------
/components/form/two-factor-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CardWrapper } from "@/components/auth/card-wrapper";
4 | import { loginSchema, twoFactorSchema } from "@/schemas";
5 | import { useForm } from "react-hook-form";
6 | import { z } from "zod";
7 | import { Form } from "@/components/ui/form";
8 | import { FormInput } from "@/components/auth/form-input";
9 | import { Button } from "@/components/ui/button";
10 | import { useTransition } from "react";
11 | import { zodResolver } from "@hookform/resolvers/zod";
12 | import { resendTwoFactor, twoFactor } from "@/actions/two-factor";
13 | import { toast } from "sonner";
14 |
15 | type TwoFactorFormProps = {
16 | payload: z.infer;
17 | };
18 |
19 | export const TwoFactorForm = ({ payload }: TwoFactorFormProps) => {
20 | const [isPending, startTransition] = useTransition();
21 | const form = useForm>({
22 | resolver: zodResolver(twoFactorSchema),
23 | defaultValues: {
24 | code: "",
25 | },
26 | });
27 |
28 | const handleSubmit = form.handleSubmit((values) => {
29 | startTransition(() => {
30 | twoFactor(values, payload).then((data) => {
31 | if (!data) return;
32 | if (!data.success) {
33 | return toast.error(data.error.message);
34 | }
35 | });
36 | });
37 | });
38 |
39 | const handleResend = () => {
40 | startTransition(() => {
41 | resendTwoFactor(payload.email).then((data) => {
42 | if (data.success) {
43 | return toast.success(data.message);
44 | }
45 | return toast.error(data.error.message);
46 | });
47 | });
48 | };
49 |
50 | return (
51 |
57 |
71 |
78 | Resend
79 |
80 |
81 |
82 | );
83 | };
84 |
--------------------------------------------------------------------------------
/components/form/register-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CardWrapper } from "@/components/auth/card-wrapper";
4 | import { Form } from "@/components/ui/form";
5 | import { registerSchema } from "@/schemas";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { useForm } from "react-hook-form";
8 | import { z } from "zod";
9 | import { FormInput } from "@/components/auth/form-input";
10 | import { Button } from "@/components/ui/button";
11 | import { useTransition } from "react";
12 | import { register } from "@/actions/register";
13 | import { toast } from "sonner";
14 | import { useRouter } from "next/navigation";
15 |
16 | export const RegisterForm = () => {
17 | const router = useRouter();
18 | const [isPending, startTransition] = useTransition();
19 | const form = useForm>({
20 | resolver: zodResolver(registerSchema),
21 | defaultValues: {
22 | name: "",
23 | email: "",
24 | password: "",
25 | },
26 | });
27 |
28 | const handleSubmit = form.handleSubmit((values) => {
29 | startTransition(() => {
30 | register(values).then((data) => {
31 | if (data.success) {
32 | router.push("/login");
33 | return toast.success(data.message);
34 | }
35 | return toast.error(data.error.message);
36 | });
37 | });
38 | });
39 |
40 | return (
41 |
47 |
79 |
80 |
81 | );
82 | };
83 |
--------------------------------------------------------------------------------
/auth/index.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from "next-auth";
2 | import { authConfig } from "@/auth/config";
3 | import { PrismaAdapter } from "@auth/prisma-adapter";
4 | import { db } from "@/lib/db";
5 | import { getUserById, updateUserById } from "@/services/user";
6 | import { getTwoFactorConfirmationByUserId } from "@/services/two-factor-confirmation";
7 | import { isExpired } from "@/lib/utils";
8 | import { getAccountByUserId } from "@/services/account";
9 |
10 | export const {
11 | handlers: { GET, POST },
12 | auth,
13 | signIn,
14 | signOut,
15 | update
16 | } = NextAuth({
17 | adapter: PrismaAdapter(db),
18 | session: {
19 | strategy: "jwt",
20 | maxAge: 60 * 60 * 24, // 1 Day
21 | },
22 | pages: {
23 | signIn: "/login",
24 | error: "/error",
25 | },
26 | events: {
27 | async linkAccount({ user }) {
28 | await updateUserById(user.id, { emailVerified: new Date() });
29 | },
30 | },
31 | callbacks: {
32 | async jwt({ token }) {
33 | if (!token.sub) return token;
34 |
35 | const existingUser = await getUserById(token.sub);
36 | if (!existingUser) return token;
37 |
38 | const existingAccount = await getAccountByUserId(existingUser.id);
39 |
40 | token.name = existingUser.name;
41 | token.email = existingUser.email;
42 | token.role = existingUser.role;
43 | token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled;
44 | token.isOAuth = !!existingAccount;
45 |
46 | return token;
47 | },
48 | async session({ token, session }) {
49 | if (token.sub && session.user) {
50 | session.user.id = token.sub;
51 | }
52 |
53 | if (token.role && session.user) {
54 | session.user.role = token.role;
55 | }
56 |
57 | if (session.user) {
58 | session.user.name = token.name;
59 | session.user.email = token.email;
60 | session.user.isTwoFactorEnabled = token.isTwoFactorEnabled;
61 | session.user.isOAuth = token.isOAuth;
62 | }
63 |
64 | return session;
65 | },
66 | async signIn({ user, account }) {
67 | if (account?.provider !== "credentials") return true;
68 |
69 | const existingUser = await getUserById(user.id);
70 | // Prevent sign in without email verification
71 | if (!existingUser?.emailVerified) return false;
72 |
73 | // If user's 2FA checked
74 | if (existingUser.isTwoFactorEnabled) {
75 | const existingTwoFactorConfirmation = await getTwoFactorConfirmationByUserId(
76 | existingUser.id
77 | );
78 | // If two factor confirmation doesn't exist, then prevent to login
79 | if (!existingTwoFactorConfirmation) return false;
80 | // If two factor confirmation is expired, then prevent to login
81 | const hasExpired = isExpired(existingTwoFactorConfirmation.expires);
82 | if (hasExpired) return false;
83 | }
84 |
85 | return true;
86 | },
87 | },
88 | ...authConfig,
89 | });
90 |
--------------------------------------------------------------------------------
/components/form/login-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CardWrapper } from "@/components/auth/card-wrapper";
4 | import { useForm } from "react-hook-form";
5 | import { zodResolver } from "@hookform/resolvers/zod";
6 | import { Form } from "@/components/ui/form";
7 | import { z } from "zod";
8 | import { loginSchema } from "@/schemas";
9 | import { Button } from "@/components/ui/button";
10 | import { useTransition } from "react";
11 | import { login } from "@/actions/login";
12 | import { FormInput } from "@/components/auth/form-input";
13 | import { toast } from "sonner";
14 | import Link from "next/link";
15 | import { useRouter } from "next/navigation";
16 |
17 | export const LoginForm = () => {
18 | const router = useRouter();
19 | const [isPending, startTransition] = useTransition();
20 | const form = useForm>({
21 | resolver: zodResolver(loginSchema),
22 | mode: "onChange",
23 | defaultValues: {
24 | email: "",
25 | password: "",
26 | },
27 | });
28 |
29 | const handleSubmit = form.handleSubmit((values) => {
30 | startTransition(() => {
31 | login(values)
32 | .then((data) => {
33 | if (!data) return;
34 | if (!data.success) {
35 | return toast.error(data.error.message);
36 | }
37 | return router.push("/two-factor");
38 | })
39 | .catch(() => toast.error("Something went wrong."));
40 | });
41 | });
42 |
43 | return (
44 |
51 |
85 |
86 |
87 | );
88 | };
89 |
--------------------------------------------------------------------------------
/app/_components/navbar.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuGroup,
6 | DropdownMenuItem,
7 | DropdownMenuLabel,
8 | DropdownMenuSeparator,
9 | DropdownMenuShortcut,
10 | DropdownMenuTrigger,
11 | } from "@/components/ui/dropdown-menu";
12 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
13 | import { UserRound } from "lucide-react";
14 | import { signOut } from "@/auth";
15 | import Link from "next/link";
16 | import { currentUser } from "@/lib/auth";
17 |
18 | async function AuthNav() {
19 | const user = await currentUser();
20 |
21 | if (!user) return;
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | {user.name}
34 |
35 |
36 |
37 | My Account
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
{user.name}
49 |
{user.email}
50 |
{user.role}
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | Profile
59 | ⇧⌘P
60 |
61 |
62 |
63 |
64 | Settings
65 | ⌘S
66 |
67 |
68 |
69 |
70 |
83 |
84 |
85 | );
86 | }
87 |
88 | export default function Navbar() {
89 | return (
90 |
91 |
92 | Next Dashboard
93 |
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/actions/two-factor.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { loginSchema, twoFactorSchema } from "@/schemas";
4 | import { z } from "zod";
5 | import { getUserByEmail } from "@/services/user";
6 | import {
7 | deleteTwoFactorTokenById,
8 | generateTwoFactorToken,
9 | getTwoFactorTokenByEmail,
10 | } from "@/services/two-factor-token";
11 | import { isExpired, response } from "@/lib/utils";
12 | import { generateTwoFactorConfirmation } from "@/services/two-factor-confirmation";
13 | import { signInCredentials } from "@/actions/login";
14 | import { cookies } from "next/headers";
15 | import { sendTwoFactorEmail } from "@/services/mail";
16 |
17 | export const twoFactor = async (
18 | payload: z.infer,
19 | credentials: z.infer
20 | ) => {
21 | // Check if user input is not valid.
22 | const validatedFields = twoFactorSchema.safeParse(payload);
23 | if (!validatedFields.success) {
24 | return response({
25 | success: false,
26 | error: {
27 | code: 422,
28 | message: "Invalid fields.",
29 | },
30 | });
31 | }
32 |
33 | const { code } = validatedFields.data;
34 |
35 | // Check if email address doesn't exist, then return an error.
36 | const existingUser = await getUserByEmail(credentials.email);
37 | if (!existingUser || !existingUser.email || !existingUser.password) {
38 | return response({
39 | success: false,
40 | error: {
41 | code: 401,
42 | message: "Email address does not exist.",
43 | },
44 | });
45 | }
46 |
47 | // Check if token invalid or doesn't exist, then return an error.
48 | const twoFactorToken = await getTwoFactorTokenByEmail(credentials.email);
49 | if (!twoFactorToken || twoFactorToken.token !== code) {
50 | return response({
51 | success: false,
52 | error: {
53 | code: 422,
54 | message: "Invalid code.",
55 | },
56 | });
57 | }
58 |
59 | // Check if token has expired. then return an error.
60 | const hasExpired = isExpired(twoFactorToken.expires);
61 | if (hasExpired) {
62 | return response({
63 | success: false,
64 | error: {
65 | code: 401,
66 | message: "Code has been expired. Please resend the 2FA code to your email.",
67 | },
68 | });
69 | }
70 |
71 | // Delete two factor token, and generate two factor confirmation
72 | await deleteTwoFactorTokenById(twoFactorToken.id);
73 | await generateTwoFactorConfirmation(existingUser.id);
74 |
75 | // Delete credentials-session's payload from login page.
76 | const cookieStore = cookies();
77 | cookieStore.delete("credentials-session");
78 |
79 | // Then try to sign in with next-auth credentials.
80 | return await signInCredentials(credentials.email, credentials.password);
81 | };
82 |
83 | // Resend Two Factor Authentication
84 | export const resendTwoFactor = async (email: string) => {
85 | // Check if email doesn't exist to generate token, then return an error.
86 | const twoFactorToken = await generateTwoFactorToken(email);
87 | if (!twoFactorToken) {
88 | return response({
89 | success: false,
90 | error: {
91 | code: 422,
92 | message: "Failed to resend two factor authentication.",
93 | },
94 | });
95 | }
96 |
97 | // Send two factor authentication code to the email.
98 | await sendTwoFactorEmail(twoFactorToken.email, twoFactorToken.token);
99 | return response({
100 | success: true,
101 | code: 201,
102 | message: "Two factor authentication code has been sent to your email.",
103 | });
104 | };
105 |
--------------------------------------------------------------------------------
/actions/profile.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { profileSchema } from "@/schemas";
4 | import { z } from "zod";
5 | import { currentUser } from "@/lib/auth";
6 | import { hashPassword, response } from "@/lib/utils";
7 | import { getUserByEmail, getUserById, updateUserById } from "@/services/user";
8 | import { update } from "@/auth";
9 | import { deleteTwoFactorConfirmationByUserId } from "@/services/two-factor-confirmation";
10 | import bcrypt from "bcryptjs";
11 | import { generateVerificationToken } from "@/services/verification-token";
12 | import { sendVerificationEmail } from "@/services/mail";
13 |
14 | export const profile = async (payload: z.infer) => {
15 | // Check if user input is not valid, then return an error.
16 | const validatedFields = profileSchema.safeParse(payload);
17 | if (!validatedFields.success) {
18 | return response({
19 | success: false,
20 | error: {
21 | code: 422,
22 | message: "Invalid fields.",
23 | },
24 | });
25 | }
26 |
27 | let { name, email, password, newPassword, isTwoFactorEnabled } = validatedFields.data;
28 |
29 | // Check if current user does not exist, then return an error.
30 | const user = await currentUser();
31 | if (!user) {
32 | return response({
33 | success: false,
34 | error: {
35 | code: 401,
36 | message: "Unauthorized.",
37 | },
38 | });
39 | }
40 |
41 | // Check if user does not exist in the database, then return an error.
42 | const existingUser = await getUserById(user.id);
43 | if (!existingUser) {
44 | return response({
45 | success: false,
46 | error: {
47 | code: 401,
48 | message: "Unauthorized.",
49 | },
50 | });
51 | }
52 |
53 | // Check if current user logged in with OAuth provider (Google or Github), then prevent to update few fields.
54 | if (user.isOAuth) {
55 | email = undefined;
56 | password = undefined;
57 | newPassword = undefined;
58 | isTwoFactorEnabled = undefined;
59 | }
60 |
61 | // Check if user trying to update the email address
62 | if (email && email !== user.email) {
63 | // Check if email already in use from another user and make sure that email doesn't same as current user.
64 | const existingEmail = await getUserByEmail(email);
65 | if (existingEmail && user.id !== existingEmail.id) {
66 | return response({
67 | success: false,
68 | error: {
69 | code: 422,
70 | message: "The email address you have entered is already in use. Please use another one.",
71 | },
72 | });
73 | }
74 |
75 | // Generate verification token, then send it to the email.
76 | const verificationToken = await generateVerificationToken(email);
77 | await sendVerificationEmail(verificationToken.email, verificationToken.token);
78 |
79 | // Return response success.
80 | return response({
81 | success: true,
82 | code: 201,
83 | message: "Confirmation email sent. Please check your email.",
84 | });
85 | }
86 |
87 | // Check if password not entered, then don't update the password.
88 | if (!password || !newPassword) {
89 | password = undefined;
90 | }
91 |
92 | // Check if password entered
93 | if (password && newPassword && existingUser.password) {
94 | // Check if passwords doesn't matches, then return an error.
95 | const isPasswordMatch = await bcrypt.compare(password, existingUser.password);
96 | if (!isPasswordMatch) {
97 | return response({
98 | success: false,
99 | error: {
100 | code: 401,
101 | message: "Incorrect password.",
102 | },
103 | });
104 | }
105 |
106 | const hashedPassword = await hashPassword(newPassword);
107 | password = hashedPassword;
108 | }
109 |
110 | // Check if user disabled 2fa, then delete two factor confirmation
111 | if (!isTwoFactorEnabled) {
112 | await deleteTwoFactorConfirmationByUserId(existingUser.id);
113 | }
114 |
115 | // Update current user
116 | const updatedUser = await updateUserById(existingUser.id, {
117 | name,
118 | email,
119 | password,
120 | isTwoFactorEnabled,
121 | });
122 |
123 | // Update session
124 | await update({ user: { ...updatedUser } });
125 |
126 | // Return response success.
127 | return response({
128 | success: true,
129 | code: 204,
130 | message: "Profile updated.",
131 | });
132 | };
133 |
--------------------------------------------------------------------------------
/components/form/profile-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { profileSchema } from "@/schemas";
4 | import { zodResolver } from "@hookform/resolvers/zod";
5 | import { useTransition } from "react";
6 | import { useForm } from "react-hook-form";
7 | import { z } from "zod";
8 | import { Form } from "@/components/ui/form";
9 | import { FormInput } from "@/components/auth/form-input";
10 | import { Button } from "@/components/ui/button";
11 | import { profile } from "@/actions/profile";
12 | import { toast } from "sonner";
13 | import { ExtendedUser } from "@/types/next-auth";
14 | import { FormToggle } from "@/components/auth/form-toggle";
15 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
16 | import { UserRound } from "lucide-react";
17 |
18 | type ProfileFormProps = {
19 | user: ExtendedUser;
20 | };
21 |
22 | export const ProfileForm = ({ user }: ProfileFormProps) => {
23 | const [isPending, startTransition] = useTransition();
24 | const form = useForm>({
25 | resolver: zodResolver(profileSchema),
26 | mode: "onChange",
27 | values: {
28 | name: user.name || undefined,
29 | email: user.email || undefined,
30 | password: undefined,
31 | newPassword: undefined,
32 | isTwoFactorEnabled: user.isTwoFactorEnabled || undefined,
33 | },
34 | });
35 |
36 | const handleSubmit = form.handleSubmit((values) => {
37 | startTransition(() => {
38 | profile(values).then((data) => {
39 | if (data.success) {
40 | form.reset();
41 | return toast.success(data.message);
42 | }
43 | return toast.error(data.error.message);
44 | });
45 | });
46 | });
47 |
48 | return (
49 | <>
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
115 | >
116 | );
117 | };
118 |
--------------------------------------------------------------------------------
/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/actions/login.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { signIn } from "@/auth";
4 | import { loginSchema } from "@/schemas";
5 | import { z } from "zod";
6 | import { DEFAULT_LOGIN_REDIRECT } from "@/routes";
7 | import { AuthError } from "next-auth";
8 | import { getUserByEmail } from "@/services/user";
9 | import bcrypt from "bcryptjs";
10 | import { generateTwoFactorToken } from "@/services/two-factor-token";
11 | import { sendTwoFactorEmail } from "@/services/mail";
12 | import { cookies } from "next/headers";
13 | import {
14 | getTwoFactorConfirmationByUserId,
15 | deleteTwoFactorConfirmationById,
16 | } from "@/services/two-factor-confirmation";
17 | import { isExpired, response, signJwt } from "@/lib/utils";
18 |
19 | export const login = async (payload: z.infer) => {
20 | // Check if user input is not valid, then return an error.
21 | const validatedFields = loginSchema.safeParse(payload);
22 | if (!validatedFields.success) {
23 | return response({
24 | success: false,
25 | error: {
26 | code: 422,
27 | message: "Invalid fields.",
28 | },
29 | });
30 | }
31 |
32 | const { email, password } = validatedFields.data;
33 |
34 | // Check if user, email and password doesn't exist, then return an error.
35 | const existingUser = await getUserByEmail(email);
36 | if (!existingUser || !existingUser.email || !existingUser.password) {
37 | return response({
38 | success: false,
39 | error: {
40 | code: 401,
41 | message: "Invalid credentials.",
42 | },
43 | });
44 | }
45 |
46 | // Check if passwords doesn't matches, then return an error.
47 | const isPasswordMatch = await bcrypt.compare(password, existingUser.password);
48 | if (!isPasswordMatch) {
49 | return response({
50 | success: false,
51 | error: {
52 | code: 401,
53 | message: "Invalid credentials.",
54 | },
55 | });
56 | }
57 |
58 | // Check if user email isn't verified yet, then return an error.
59 | if (!existingUser.emailVerified) {
60 | return response({
61 | success: false,
62 | error: {
63 | code: 401,
64 | message: "Your email address is not verified yet. Please check your email.",
65 | },
66 | });
67 | }
68 |
69 | // Check if user's 2FA are enabled
70 | if (existingUser.isTwoFactorEnabled && existingUser.email) {
71 | const existingTwoFactorConfirmation = await getTwoFactorConfirmationByUserId(existingUser.id);
72 | const hasExpired = isExpired(existingTwoFactorConfirmation?.expires!);
73 |
74 | // If two factor confirmation exist and expired, then delete it.
75 | if (existingTwoFactorConfirmation && hasExpired) {
76 | await deleteTwoFactorConfirmationById(existingTwoFactorConfirmation.id);
77 | }
78 |
79 | // If two factor confirmation doesn't exist or if two factor confirmation has expired, then handle 2fa
80 | if (!existingTwoFactorConfirmation || hasExpired) {
81 | const cookieStore = cookies();
82 | const token = signJwt(validatedFields.data);
83 | cookieStore.set("credentials-session", token);
84 |
85 | const twoFactorToken = await generateTwoFactorToken(existingUser.email);
86 | await sendTwoFactorEmail(twoFactorToken.email, twoFactorToken.token);
87 |
88 | return response({
89 | success: true,
90 | code: 200,
91 | message: "Please confirm your two-factor authentication code.",
92 | });
93 | }
94 | }
95 |
96 | // Then try to sign in with next-auth credentials.
97 | return await signInCredentials(email, password);
98 | };
99 |
100 | // Sign in credentials from next-auth
101 | export const signInCredentials = async (email: string, password: string) => {
102 | try {
103 | await signIn("credentials", {
104 | email,
105 | password,
106 | redirectTo: DEFAULT_LOGIN_REDIRECT,
107 | });
108 | } catch (error) {
109 | if (error instanceof AuthError) {
110 | switch (error.type) {
111 | case "CredentialsSignin":
112 | return response({
113 | success: false,
114 | error: {
115 | code: 401,
116 | message: "Invalid credentials.",
117 | },
118 | });
119 |
120 | case "OAuthAccountNotLinked":
121 | return response({
122 | success: false,
123 | error: {
124 | code: 403,
125 | message:
126 | "Another account already registered with the same Email Address. Please login the different one.",
127 | },
128 | });
129 |
130 | case "Verification":
131 | return response({
132 | success: false,
133 | error: {
134 | code: 422,
135 | message: "Verification failed. Please try again.",
136 | },
137 | });
138 |
139 | case "AuthorizedCallbackError":
140 | return response({
141 | success: false,
142 | error: {
143 | code: 422,
144 | message: "Authorization failed. Please try again.",
145 | },
146 | });
147 |
148 | default:
149 | return response({
150 | success: false,
151 | error: {
152 | code: 500,
153 | message: "Something went wrong.",
154 | },
155 | });
156 | }
157 | }
158 |
159 | throw error;
160 | }
161 | };
162 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------