T</>TahuCoding

Part 2 - Tutorial Next.js 13 Typescript Ecommerce Cart dengan Payment Gateway - Login & Register menggunakan Next Auth

Loading...

Pada tutorial ini kita akan membahas tentang bagaimana cara membuat sebuah custom login dan register based on Next Auth bahkan menambahkan client side validation, middleware dan error handling.

Step 1: Setup Next Auth

Next Auth merupakan sebuah library open source yang membantumu dalam setup autentikasi secara cepat dan mudah pada sisi frontend hingga backend. Secara default next auth menyediakan template login dan register tersendiri, tetapi berdasarkan kebutuhan kali ini, kita akan melakukan kustomisasi baik dari sisi login maupun register.

Install next auth menggunakan command berikut:

npm i next-auth

Jika sudah buatlah sebuah file pada folder api sebagai next auth handler sebagai berikut:

src\app\api\auth[...nextauth]\route.ts
import type { NextAuthOptions } from "next-auth";
 
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
 
export const OPTIONS: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: "credentials",
      credentials: {
        username: {
          label: "Username",
          type: "text",
          placeholder: "crusherblack",
        },
        password: { label: "Password", type: "password" },
      },
    }),
  ],
};
 
const handler = NextAuth(OPTIONS);
 
export { handler as GET, handler as POST };

Tambahkan SessionProvider agar kedepan kita bisa akses session dari sisi FE.

src\provider\index.tsx
"use client";
 
import { ReactNode } from "react";
 
import { SessionProvider } from "next-auth/react";
import { StyleProvider } from "@ant-design/cssinjs";
import { store } from "@/store";
import { Provider as ReduxProvider } from "react-redux";
 
type Props = {
  children: ReactNode,
};
 
const Provider = ({ children }: Props) => {
  return (
    <SessionProvider>
      <ReduxProvider store={store}>
        <StyleProvider hashPriority="high" ssrInline>
          {children}
        </StyleProvider>
      </ReduxProvider>
    </SessionProvider>
  );
};
 
export default Provider;

Tambahkan container pada app layout:

src\app\layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
 
import "./globals.css";
import Provider from "@/provider";
 
const inter = Inter({ subsets: ["latin"] });
 
export const metadata: Metadata = {
  title: "E-Commerce Tahu Coding",
  description: "Modern E-Commerce with latest stack",
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode,
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Provider>
          <main className="mx-auto max-w-7xl px-8 min-h-screen">
            {children}
          </main>
        </Provider>
      </body>
    </html>
  );
}

Step 2: Implementasi Register

Sebelum kita bisa melakukan login, tentunya kita perlu membuat sebuah page registrasi user terlebih dahulu agar dapat menambahkan user ke database, maka dari itu buatlah sebuah custom api untuk register pada folder api.

Tambahkan bcrypt sebagai library enkripsi/dekripsi password:

npm i bcrypt

Tambahkan juga bcrypt typing menggunakan command berikut:

npm i --save-dev @types/bcrypt

Tambahkan NEXTAUTH_URL & NEXTAUTH_SECRET pada .env, pastikan NEXTAUTH_SECRET value merupakan random string yang strong seperti 504-bit WPA Key.

.env
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
 
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
 
DATABASE_URL="YOUR_DB_URL"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="RANDOM_STRING_FOR_NEXT_AUTH_SECRET"

Tambahkan axios agar kita bisa melakukan request ke BE nantinya:

npm i axios

Setelah berhasil menambahkan library buatlah sebuah file register\route.ts dan modifikasi sebagai berikut:

src\app\api\auth\register\route.ts
import { NextResponse } from "next/server";
 
import prisma from "@/lib/prisma";
import { hash } from "bcrypt";
 
const { json: jsonResponse } = NextResponse;
 
export const POST = async (request: Request) => {
  try {
    const { name, email, password } = await request.json();
 
    //check if email already exist in database or not
    const isUserExisted = await prisma.user.findUnique({
      where: {
        email,
      },
    });
 
    //if so, then throw error email already exist
    if (isUserExisted) {
      return jsonResponse(
        {
          message: "Email Already Exist",
        },
        {
          status: 409, //conflict status code
        }
      );
    }
 
    //if email don't exist continue creating user
    const user = await prisma.user.create({
      data: {
        name,
        email,
        password: await hash(password, 10), //encrypt password
      },
    });
 
    // exlude password from response, (note: you can customize the response object before send to client)
    const { password: _, ...restUser } = user;
 
    return jsonResponse(
      {
        message: "Successfully registered user",
        data: restUser,
      },
      {
        status: 201, //success created status code
      }
    );
  } catch (_error) {
    return jsonResponse(
      {
        message: "Server Error",
      },
      {
        status: 500, //server error status code
      }
    );
  }
};

Code diatas bertanggung jawab dalam pembuatan user baru, setiap kali API ini dipanggil maka dia akan melakukan pengecekan apakah user sudah pernah terdaftar atau tidak based on email, jika user sudah terdaftar maka throw error, jika user belum terdaftar lanjutkan proses registrasi dan hash password serta exlude password setelah berhasil pada response.

Selanjutnya buatlah sebuah folder (auth) sebagai route placeholder yang membantu kita dalam identifikasi sebuah route tanpa perlu merubah struktur url.

Tambahkan page register pada folder (auth):

src\app(auth)\register\page.tsx
import Form from "./form";
 
export default async function Register() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full md:w-8/12 lg:w-4/12 md:px-8 py-10">
        <Form />
      </div>
    </div>
  );
}

Bisa kita lihat, bahwa kita memanggil component Form dari file lain? alasannya karena next js 13 secara default merender page secara SSR, agar kita bisa melakukan interaksi dari sisi CSR maka dari itu kita buat sebuah component Form di folder yang sama.

Buatlah sebuah component baru pada folder yang sama yaitu Form:

src\app(auth)\register\form.tsx
"use client";
 
import { useState } from "react";
 
import { Alert, Button, Form, Input } from "antd";
import NextLink from "next/link";
import axios from "axios";
 
import { axiosErrorHandler } from "@/utils/errorHandling";
 
type FieldType = {
  name: string;
  email: string;
  password: string;
  passwordConfirmation: string;
};
 
const RegisterForm = () => {
  const [loading, setLoading] = useState(false);
  const [isRegisterSuccess, setIsRegisterSuccess] = useState(false);
  const [error, setError] = useState("");
 
  const [form] = Form.useForm();
 
  const onFinish = async (values: FieldType) => {
    try {
      setError("");
      setIsRegisterSuccess(false);
      setLoading(true);
 
      // do register to BE
      await axios.post("/api/auth/register", values);
 
      // if success show success alert & reset form
      setIsRegisterSuccess(true);
      form.resetFields();
    } catch (error) {
      //handle error based on axios instance or not
      setError(axiosErrorHandler(error));
    } finally {
      setLoading(false);
    }
  };
 
  return (
    <Form
      name="basic"
      initialValues={{ remember: true }}
      onFinish={onFinish}
      autoComplete="off"
      layout="vertical"
      form={form}
    >
      {error && (
        <Form.Item>
          <Alert message={error} type="error" showIcon />
        </Form.Item>
      )}
      {isRegisterSuccess && (
        <Form.Item>
          <Alert
            message="Register Success Please Login!"
            type="success"
            showIcon
          />
        </Form.Item>
      )}
      <p className="text-2xl font-bold">Welcome</p>
      <p className="text-md mb-10 text-gray-500">
        First, let's create your account
      </p>
      <Form.Item<FieldType>
        label="Fullname"
        name="name"
        rules={[
          {
            required: true,
            message: "Please input your Fullname!",
          },
        ]}
      >
        <Input placeholder="Input your Fullname" size="large" />
      </Form.Item>
 
      <Form.Item<FieldType>
        label="Email"
        name="email"
        rules={[
          {
            required: true,
            message: "Please input your Email!",
            type: "email",
          },
        ]}
      >
        <Input placeholder="Input your email" size="large" />
      </Form.Item>
 
      <Form.Item<FieldType>
        label="Password"
        name="password"
        rules={[
          {
            required: true,
            min: 8,
            max: 50,
            message: "Please input your Password!",
          },
        ]}
      >
        <Input.Password placeholder="Input your password" size="large" />
      </Form.Item>
 
      <Form.Item<FieldType>
        label="Password Confirmation"
        name="passwordConfirmation"
        rules={[
          {
            required: true,
            min: 8,
            max: 50,
            message: "Please confirm your Password!",
          },
          ({ getFieldValue }) => ({
            // add logic to make sure passwordConfirmation and password match each time user register
            validator(_, value) {
              if (!value || getFieldValue("password") === value) {
                return Promise.resolve();
              }
              return Promise.reject(
                new Error("The new password that you entered do not match!")
              );
            },
          }),
        ]}
      >
        <Input.Password placeholder="Input your password" size="large" />
      </Form.Item>
 
      <Form.Item>
        <Button
          type="primary"
          htmlType="submit"
          loading={loading}
          className="w-full mt-4"
          size="large"
        >
          Register
        </Button>
      </Form.Item>
 
      <p className="text-md">
        Already have account?{" "}
        <NextLink href="/login">
          <span className="text-blue-600 hover:cursor-pointer">Login Now!</span>
        </NextLink>
      </p>
    </Form>
  );
};
 
export default RegisterForm;

Component ini bertanggung jawab dalam melakukan input data yang dibutuhkan serta validasi semua data apakah berupa email? apakah password sudah sesuai? sebelum API register yang sudah dibuat dipanggil, jika berhasil register tampilkan pesan suskes jika tidak tampilkan error.

Bisa kita lihat pada line 39 kita memanggil sebuah function axiosErrorHandler, maka dari itu mari buat function tersebut pada folder utils:

src\utils\errorHandling.ts
import axios from "axios";
 
type AxiosErrorResponse = {
  //make sure to sync it with your BE if you want to use this in the future
  message: string;
};
 
export const axiosErrorHandler = (error: unknown): string => {
  //check if error is part of axios error
  if (axios.isAxiosError(error) && error.response) {
    return (error.response?.data as AxiosErrorResponse).message;
  }
 
  return "::CODE/RUNTIME ERROR =>" + error;
}

Function ini bertujuan untuk mengecek apakah error yang kita peroleh dari try and catch merupakan error axios request atau error biasa, jika sudah maka kamu dapat melakukan registrasi dan berikut tampilan page register.

Register Page

Step 3: Implementasi Login

Setelah berhasil setup next auth langkah berikutnya kita akan melakukan kustomisasi pada next auth api, panggil prisma client kemudian lakukan pengecekan terhadap email, jika sudah terdaftar lanjutkan daftar jika tidak throw error, berikut code-nya:

src\app\api\auth[...nextauth]\route.ts
import prisma from "@/lib/prisma";
import type { NextAuthOptions } from "next-auth";
import { compare } from "bcrypt";
 
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
 
export const OPTIONS: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: "credentials", //use this for custom login
      credentials: {
        username: {
          label: "Username",
          type: "text",
          placeholder: "crusherblack",
        },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        const loginErrorMessage = "Invalid email or password";
 
        //check if email already exist or not
        const isUserExisted = await prisma.user.findUnique({
          where: {
            email: credentials?.username,
          },
        });
 
        //if not throw error
        if (!isUserExisted) {
          throw Error(loginErrorMessage);
        }
 
        //check if password that client input is them sama with hash password from db
        if (
          isUserExisted &&
          credentials?.password &&
          (await compare(credentials.password, isUserExisted.password))
        ) {
          //if email and password valid continue
          return isUserExisted;
        }
 
        //throw error if password not valid
        throw Error(loginErrorMessage);
      },
    }),
  ],
  pages: {
    signIn: "/login", //tell next auth, this is url for custom login page
  },
  session: {
    strategy: "jwt", //make sure to set it to jwt
  },
  jwt: {
    secret: process.env.NEXTAUTH_SECRET, //fill with your own secret from .env
  },
};
 
const handler = NextAuth(OPTIONS);
 
export { handler as GET, handler as POST };

Singkat cerita, code diatas bertanggung jawab dalam melakukan pengecekan terhadap user yang sudah terdaftar atau tidak based on email, jika sudah terdaftar maka code akan melakukan komparasi terhadap hash password yang ada di db dengan plain password yang kita kirim dari FE menggunakan bcrypt, jika sesuai maka kita dapat login jika tidak throw error.

Terdapat beberapa config yang kita lakukan disini, diataranya adalah:

  • pages - signIn - "/login" => bertujuan untuk memberi tahu next auth url dari custom login page
  • session - strategy - "jwt" -> strategi auth yang berbasis token yang secure.
  • jwt - secret - process.env.NEXTAUTH_SECRET => merupakan secret jwt key yang digunakan next auth ketika membuat token (pastikan kamu menggunakan secret key yang baik seperti 504-bit WPA Key)

Agar dapat menggunakan handle diatas, kita akan membuat sebuah page login baru pada folder app.

Jika sudah buatlah file route.ts didalam app/(auth)/login dan tambahkan code berikut:

src\app(auth)\login\page.tsx
import Form from "./form";
 
export default async function Login() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full md:w-8/12 lg:w-4/12 md:px-8 py-10">
        <Form />
      </div>
    </div>
  );
}

Buatlah sebuah file form.tsx kemudian tambahkan code berikut:

src\app(auth)\login\form.tsx
"use client";
 
import { useState } from "react";
 
import { signIn } from "next-auth/react";
import { useSearchParams, useRouter } from "next/navigation";
import { Alert, Button, Form, Input } from "antd";
import NextLink from "next/link";
 
type FieldType = {
  email: string;
  password: string;
};
 
const LoginForm = () => {
  const router = useRouter();
 
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
 
  const searchParams = useSearchParams();
  const callbackUrl = searchParams.get("callbackUrl") || "/dashboard";
 
  const onFinish = async (values: FieldType) => {
    try {
      setLoading(true);
 
      //do login to next auth based on email and password
      const res = await signIn("credentials", {
        redirect: false,
        username: values.email,
        password: values.password,
        callbackUrl,
      });
 
      //if there is no error, redirect to dashboard or previous page
      if (!res?.error) {
        router.push(callbackUrl);
      } else {
        throw Error(res?.error);
      }
    } catch (error: any) {
      if (error) setError(error?.message);
    } finally {
      setLoading(false);
    }
  };
 
  return (
    <Form
      name="basic"
      initialValues={{ remember: true }}
      onFinish={onFinish}
      autoComplete="off"
      layout="vertical"
    >
      {error && (
        <Form.Item>
          <Alert message={error} type="error" showIcon />
        </Form.Item>
      )}
      <p className="text-2xl font-bold">Hello, Welcome Back</p>
      <p className="text-md mb-10 text-gray-500">
        Happy to see you again, login here
      </p>
      <Form.Item<FieldType>
        label="Email"
        name="email"
        rules={[
          {
            required: true,
            message: "Please input your Email!",
            type: "email",
          },
        ]}
      >
        <Input placeholder="Input your email" size="large" />
      </Form.Item>
 
      <Form.Item<FieldType>
        label="Password"
        name="password"
        rules={[
          {
            required: true,
            min: 8,
            max: 50,
            message: "Please input your Password!",
          },
        ]}
      >
        <Input.Password placeholder="Input your password" size="large" />
      </Form.Item>
 
      <Form.Item>
        <Button
          type="primary"
          htmlType="submit"
          loading={loading}
          className="w-full mt-4"
          size="large"
        >
          Login
        </Button>
      </Form.Item>
 
      <p className="text-md">
        Don't have account?{" "}
        <NextLink href="/register">
          <span className="text-blue-600 hover:cursor-pointer">
            Register Now!
          </span>
        </NextLink>
      </p>
    </Form>
  );
};
 
export default LoginForm;

Pada line 22 kita bisa melihat ada sebuah variable yang bertanggung jawab dalam menghandle route redirect, variable ini berisi route sebelum kita menuju login dan route default setelah kita login. Apa benefitnya?, jika kamu mengalami expire session pada menu yang sudah diproteksi dan melanjutkan login ulang, maka aplikasi akan redirect ke page sebelumnya ketimbang redirect ke default route, hal ini meningkatkan user experience dan mempermudamu dalam penggunaan aplikasi.

Jika sudah maka tampilan dari login form kita sebagai berikut:

Login Page

Kamu dapat melakukan login tetapi karena route dahsboard belum kita buat maka dari itu tambahkan dashboard pada app:

src\app\dashboard\page.tsx
import { getServerSession } from "next-auth";
import React from "react";
import { OPTIONS } from "../api/auth/[...nextauth]/route";
 
const Dashboard = async () => {
  const session = await getServerSession(OPTIONS);
 
  return <div>Welcome: {session?.user?.email}</div>;
};
 
export default Dashboard;

Jika sudah kamu sudah mencoba login menggunakan user yang teregistrasi.

Step 4: Tambahkan middleware

Meskipun kita sudah memiliki fungsi login tetapi dashboard dapat diakses tanpa perlu login, maka dari itu tambahkan middleware yang bertanggung jawab dalam proteksi route-route pada aplikasi:

src\middleware.ts
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
 
//this is custom middleware you may improve it as you like
export default async function middleware(req: NextRequest) {
  // Get the pathname of the request (e.g. /, /protected)
  const path = req.nextUrl.pathname;
 
  // If it's the root path, just render it
  if (path === "/") {
    return NextResponse.next();
  }
 
  //decript jwt based on NEXTAUTH_SECRET
  const session = await getToken({
    req,
    secret: process.env.NEXTAUTH_SECRET,
  });
 
  const isProtected = path.includes("/dashboard");
 
  //if jwt token valid then continue, otherwise redirect to login
  if (!session && isProtected) {
    return NextResponse.redirect(new URL("/login", req.url));
  } else if (session && (path === "/login" || path === "/register")) {
    return NextResponse.redirect(new URL("/dashboard", req.url));
  }
  return NextResponse.next();
}

Sekarang coba clear semua cookies kemudian paksa masuk pada page dashboard.

Tidak bisa bukan? Selamat middlewaremu berjalan dengan baik, kedepan kita akan mengembangkan middleware ini untuk handling protected route based on user role.

Step 5: Tambahkan fungsi Logout

Pada page dashboard tambahkan component LogoutButton

src\app\dashboard\page.tsx
import { getServerSession } from "next-auth";
import React from "react";
import { OPTIONS } from "../api/auth/[...nextauth]/route";
import LogoutButton from "./logoutButton";
 
const Dashboard = async () => {
  const session = await getServerSession(OPTIONS);
 
  return (
    <div>
      Welcome: {session?.user?.email} <LogoutButton />
    </div>
  );
};
 
export default Dashboard;

Lalu buat component tersebut:

src\app\dashboard\logoutButton.tsx
"use client";
 
import { Button } from "antd";
import { signOut } from "next-auth/react";
import React from "react";
 
const LogoutButton = () => {
  const handleLogout = () => {
    signOut();
  };
 
  return (
    <Button type="primary" onClick={handleLogout}>
      Logout
    </Button>
  );
};
 
export default LogoutButton;

Selamat kamu sudah menyelesaikan tutorial kali ini, kedepan kita akan membuat layout dashboard dan landing.

Code untuk tutorial kali ini link repo, jangan lupa star di repo ya!

Stay tune dan jangan lupa share & support Tahu Coding, Terima Kasih!