즐겁게 개발을...

개발보다 게임이 더 많이 올라오는 것 같은...

개발/Node.js

Nest.js+Next.js를 이용한 기반 프로젝트 만들기 #2

다물칸 2023. 4. 5. 14:57
728x90

업데이트일자: 2023.04.05

내용이 길어져서 접은 글로 처리합니다. 

 

[공통 메시지]

더보기

참고했던 글들이 100% 완벽하게 되지 않아서 3~4일 헤메다 얼추 기반 프로젝트에 준하는 결과물을 만들 수 있을 것 같아 작성해봅니다. 아마 몇 주, 몇 달 후에 이 글을 따라 하시는 분들도 저와 같이 100% 성공하지는 못할 수도 있습니다.

자바스크립트 진영은 매일/매시간 패키지들이 업데이트 되는 터라 서로 버전이 맞지 않아 발생될 수도 있는 일이니 그 부분 염두에 두고 이 글을 봐주셨으면 감사하겠습니다. 

개발 스택

Language Typescript
Backend Framework Nest.js
Frontend Framework Next.js (for React.js)
Database Sqlite
Database ORM Prisma
UI Framework Chakra-ui
Tools ESLint

글에 앞서 이 글을 쓴 기준을 종속된 패키지 버전을 참고해서 글을 이해해주시기 바랍니다. 

종속버전

"dependencies": {
    "@chakra-ui/icons": "^2.0.18",
    "@chakra-ui/react": "^2.5.5",
    "@emotion/react": "^11.10.6",
    "@emotion/styled": "^11.10.6",
    "@heroicons/react": "^1.0.6",
    "@nestjs/common": "^9.3.12",
    "@nestjs/core": "^9.3.12",
    "@nestjs/platform-express": "^9.3.12",
    "@nestjs/swagger": "^6.2.1",
    "@prisma/client": "^4.12.0",
    "@types/node": "18.15.11",
    "@types/react": "18.0.31",
    "@types/react-dom": "18.0.11",
    "bcrypt": "^5.1.0",
    "bcryptjs": "^2.4.3",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.14.0",
    "classnames": "^2.3.2",
    "eslint": "8.37.0",
    "eslint-config-next": "13.2.4",
    "framer-motion": "^10.10.0",
    "jsonwebtoken": "^9.0.0",
    "next": "13.2.4",
    "next-auth": "^4.20.1",
    "postcss-import": "^15.1.0",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-icons": "^4.2.0",
    "swagger-ui-express": "^4.6.2",
    "typescript": "5.0.2"
  },
  "devDependencies": {
    "@nestjs/cli": "^9.3.0",
    "@types/bcrypt": "^5.0.0",
    "@types/bcryptjs": "^2.4.2",
    "autoprefixer": "^10.4.14",
    "dotenv-cli": "^7.1.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-plugin-prettier": "^4.2.1",
    "postcss": "^8.4.21",
    "prettier": "2.8.7",
    "prisma": "^4.12.0",
    "tailwindcss": "^3.3.1"
  }

Node.js 버전: v18.15.0

 

 

Nest.js+Next.js를 이용한 기반 프로젝트 만들기 #1

업데이트일자: 2023.04.05 참고했던 글들이 100% 완벽하게 되지 않아서 3~4일 헤메다 얼추 기반 프로젝트에 준하는 결과물을 만들 수 있을 것 같아 작성해봅니다. 아마 몇 주, 몇 달 후에 이 글을 따라

endev.tistory.com

이전 글에 이어 다음 주제로 이야기를 계속 이어가겠습니다. 

 

  1. Next-Auth 패키지의 Credential을 이용한 인증
  2. Nest.js의 Restful API를 이용하는 방법
  3. tailwindcss, Chakra-UI를 기존 Next.js프로젝트에 적용
  4. tailwindcss를 이용해 네비게이션 바 탑재
  5. Session을 이용한 Sign In/Out 적용

Next-Auth를 적용 시키고 Next.js에서 직접 API를 만들어 호출 할 수 있지만, 현재 프로젝트가 Nest.js를 백엔드로 두고 학습 중이므로 Nest.js에서 만든 API를 호출하여 인증하는 방식을 넣어봅니다. 

프론트엔드의 맛을 살짝 보기 위해 tailwindcss를 이용해 네비게이션 바를 (다른 블로그 글을 통해) 만들어 세션 상태를 UI에 표현하는 방법도 내용에 담았습니다.

이 글을 쓰기 위해 여기저기 올려주신 다른 블로그들을 보고 따라 해보지만, 역시나 시간이 지나면 적용이 안되는 코드들이 많더라구요. chakra-UI를 설치하지만 실제 UI를 적용하는 것은 다음 글에서 진행됩니다. Chakra-UI 템플릿 중 하나를 골라서 적용하는 형태로 글을 작성할 예정입니다.

 

어쨋든 이제 시작합니다. 

Next-Auth를 이용한 인증 + Nest.js의 Restful API를 이용하는 방법

더보기
# Next.js에 패키지를 설치하기 위해 소스 루트경로로 이동한다.
cd ..
npm i next-auth bcrypt bcryptjs
mkdir pages/api/auth

pages/_app.tsx 파일수정

※ Navbar와 Layout은 아래 "tailwindcss를 이용해 네비게이션 바 탑재"에서 추가됩니다. 

※ /css/tailwind.css파일은 "tailwindcss, Chakra-UI를 기존 Next.js프로젝트에 적용"에서 추가됩니다.

import * as React from 'react';
import type { AppProps } from 'next/app';
import { SessionProvider } from 'next-auth/react';
import Navbar from '@/pages/components/Navbar';
import { ChakraProvider } from '@chakra-ui/react';
import Layout from '@/pages/components/Layout';

import '@/css/tailwind.css';

export default function App({
  Component,
  pageProps: { session, ...pageProps },
}: AppProps) {
  return (
    <SessionProvider session={session}>
      <ChakraProvider resetCSS>
        <Navbar />
        <Layout>
          <Component {...pageProps} />
        </Layout>
      </ChakraProvider>
    </SessionProvider>
  );
}

pages/api/auth폴더에 [...nextauth].js 파일 생성

Next-Auth의 CredentialsProvider를 사용하는 예제입니다. 인증을 위해 "email"과 "Password"를 입력받기 위해 항목을 추가합니다. "Type"을 "email"로 할 경우 입력 시 이메일 형태가 아닐 경우 next-auth의 자체 에러가 표시됩니다. 

 

Next.js의 API를 호출해서 처리하기 위해 fetch구문을 이용해 Nest.js에서 생성될 API를 호출해 처리하는 구문입니다. 

 JWT는 아직 처리되지 않았습니다.

import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

export default NextAuth({
  providers: [
    CredentialsProvider({
      // The name to display on the sign in form (e.g. "Sign in with...")
      name: 'Credentials',
      // The credentials is used to generate a suitable form on the sign in page.
      // You can specify whatever fields you are expecting to be submitted.
      // e.g. domain, username, password, 2FA token, etc.
      // You can pass any HTML attribute to the <input> tag through the object.
      credentials: {
        email: {
          label: '사용자ID',
          type: 'email',
          placeholder: 'E-Main ID를 입력하세요.',
        },
        password: { label: '패스워드', type: 'password' },
      },
      async authorize(credentials, req) {
          // Add logic here to look up the user from the credentials supplied
          if (req.method !== 'POST') {
              return;
          }

          // Next.js에서 Nest.js API를 호출
          const res = await fetch(process.env.NESTAUTH_URL + '/api/user/login', {
              method: 'POST',
              headers: {
                  'Content-Type': 'application/json'
              },
              body: JSON.stringify({ email: credentials?.email, password: credentials?.password }),
          });
          const user = await res.json();
          console.log("Next-auth login => ", JSON.stringify(user));
          if (user && user.resultIdx === 0) {
            return user;
          } else {
            return;
          }
      }
    }),
  ],
  secret: process.env.SECRET,
  callbacks: {
    session({ session, token, user }) {
      return session; // The return type will match the one returned in `useSession()`
    },
  },
  pages: {
    signIn: '/login',
  }
});

pages 옵션을 통해 Next-Auth에서 제공되는 로그인 페이지가 아닌 커스텀 페이지를 추가해보겠습니다. 

pages/Login/index.tsx 파일 추가

※ Navbar는 "tailwindcss를 이용해 네비게이션 바 탑재"에서 추가됩니다.

import React, { useState, useRef } from "react";
import { useSession, signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { Flex, Button, Heading, VStack } from '@chakra-ui/react';
import Head from 'next/head';
import Navbar from '@/pages/components/Navbar';

const Login: React.FC = (props) => {
  const [formStatus, setFormStatus] = useState<string>('');

  const emailInputRef = useRef<HTMLInputElement>(null);
  const passwordInputRef = useRef<HTMLInputElement>(null);

  async function submitHandler(event: React.SyntheticEvent) {
    event.preventDefault();

    const enteredEmail = emailInputRef.current?.value;
    const enteredPassword = passwordInputRef.current?.value;

    const result = await signIn("credentials", {
      redirect: false,
      email: enteredEmail,
      password: enteredPassword,
    });

    if (!result?.error) {
      setFormStatus(`Log in Success!`);
      router.replace("/");
    } else {
      setFormStatus(`Error Occured : ${result.error}`);
    }
  } // end of submitHandler function

  const { data: session, status } = useSession();
  const router = useRouter();

  if (status === "authenticated") {
    router.replace("/");
    return (
      <div>
        <h1>Log in</h1>
        <div>You are already logged in.</div>
        <div>Now redirect to main page.</div>
      </div>
    );
  }

  return (
    <div>
      <Head>
        <title>Based Next.js & Nest.js</title>
        <meta
          name="description"
          content="Thirdparty: Prisma & Next-auth & Chakra-UI"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Navbar />
      <Flex justify="center">
        <VStack mb={6}>
          <div className="container px-5 py-10 mx-auto w-100">
            <div className="text-center mb-12">
              <h1 className="text-4xl md:text-4xl text-gray-700 font-semibold">
                로그인
              </h1>
            </div>
            <form
              onSubmit={submitHandler}
              className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
            >
              <div className="mb-4">
                <label
                  className="block text-gray-700 text-sm font-bold mb-2"
                  htmlFor="email"
                >
                  이메일 ID
                </label>
                <input
                  className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
                  id="email"
                  type="text"
                  placeholder="Email"
                  required
                  ref={emailInputRef}
                />
              </div>
              <div className="mb-6">
                <label
                  className="block text-gray-700 text-sm font-bold mb-2"
                  htmlFor="password"
                >
                  패스워드
                </label>
                <input
                  className="shadow appearance-none border border-red-500 rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
                  id="password"
                  type="password"
                  required
                  ref={passwordInputRef}
                />
                <p className="text-red-500 text-xs italic">
                  {/* Please choose a password. */}
                  {formStatus}
                </p>
              </div>
              <div className="flex items-center justify-between">
                <button
                  className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
                  type="submit"
                >
                  로그인
                </button>
              </div>
            </form>
          </div>
        </VStack>
      </Flex>
    </div>
  );
};

export default Login;

 

백엔드 쪽 처리를 위한 API를 추가해줍니다. 

 

server/user/user.entity.ts 파일 수정

기존 Dto외에 Login을 위한 Request, Response용 Dto를 새로 생성했습니다.

 프론트는 제가 전문분야는 아니라서 결과번호를 res.status를 이용하거나 별도 구조를 만들어서 데이터를 추가하는 형태로 할려고 했는데 안되서 아래처럼 구현하였습니다.

import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
    @ApiProperty({description: '이메일', required: true })
    email: string;
    @ApiProperty({description: '이름', required: true })
    name: string;
    @ApiProperty({description: '암호', required: true })
    password: string;
}

export class UpdateUserDto {
    @ApiProperty({description: '이메일', required: false })
    email?: string;
    @ApiProperty({description: '이름', required: false })
    name?: string;
}

export class LoginUserDto {
    @ApiProperty({description: '이메일', required: true })
    email: string;
    @ApiProperty({description: '암호', required: true })
    password: string;
}

export class LoginUserResultDto {
    @ApiProperty({description: '아이디', required: true })
    id: number;
    @ApiProperty({description: '이메일', required: true })
    email: string;
    @ApiProperty({description: '이름', required: true })
    name: string;
    @ApiProperty({description: '결과', required: true })
    resultIdx: number;
    @ApiProperty({description: '결과메시지', required: true })
    result: string;
}

server/user/user.service.ts 파일 수정

회원가입을 위한 Create에 패스워드를 해시처리하는 구문을 추가하고 Login하는 함수를 추가했습니다.

import { Injectable } from "@nestjs/common";
import { PrismaService } from "@/server/prisma/prisma.service";
import { CreateUserDto, UpdateUserDto } from "./user.entity";
import bcryptjs from "bcryptjs";

@Injectable()
export class UserService {
  constructor(private readonly db: PrismaService) {}

  async findUserById(id: number) {
    return await this.db.user.findUnique({
      where: {
        id,
      },
    });
  }

  async findUserByEmail(email: string) {
    return await this.db.user.findUnique({
      where: {
        email,
      },
    });
  }

  async getList() {
    return await this.db.user.findMany();
  }

  async create(data: CreateUserDto) {
    // Password를 hash로 변환
    let hashedPassword = await bcryptjs.hash(data.password, 12);
    data.password = Buffer.from(hashedPassword, "utf8").toString('base64');

    return await this.db.user.create({
      data
    });
  }

  async update(id: number, data: UpdateUserDto) {
    return await this.db.user.update({
      where: {
        id,
      },
      data,
    });
  }

  async delete(id: number) {
    return await this.db.user.delete({
      where: {
        id,
      },
    });
  }

  async login(email: string) {
    return await this.db.user.findUnique({
      where: { email },
      select: {
        id: true,
        email: true,
        name: true,
        password: true,
      },
    });
  }
}

server/user/user.controller.ts 파일 수정

사용자 로그인 API를 추가했습니다.

import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import { user } from "@prisma/client";
import { CreateUserDto, LoginUserDto, LoginUserResultDto } from "./user.entity";
import { UserService } from "./user.service";

import { ApiOperation, ApiTags } from '@nestjs/swagger';
import bcryptjs from 'bcryptjs';

@ApiTags('user')  // Swagger에 Tag를 생성
@Controller("api/user")  // main.ts에서 api를 전역으로 한 걸 주석처리해서 여기서 처리
export class UserController {
  constructor(private readonly userService: UserService) { }

  @ApiOperation({ summary: '사용자 조회' })  // Swagger에 API를 생성
  @Get("/:id")
  async findUserById(@Param("id") id: number): Promise<user | null> {
    return await this.userService.findUserById(id);
  }

  @ApiOperation({ summary: '사용자 이메일로 조회' })  // Swagger에 API를 생성
  @Get("/email/:email")
  async findUserByEmail(@Param("email") email: string): Promise<user | null> {
    return await this.userService.findUserByEmail(email);
  }

  @ApiOperation({ summary: '사용자 목록 조회' })  // Swagger에 API를 생성
  @Get("/")
  async getList(): Promise<user[]> {
    return await this.userService.getList();
  }

  @ApiOperation({ summary: '사용자 생성' })  // Swagger에 API를 생성
  @Post("/create")
  async create(@Body() createUserDto: CreateUserDto): Promise<user> {
    return await this.userService.create({
      email: createUserDto.email,
      name: createUserDto.name,
      password: createUserDto.password
    });
  }

  @ApiOperation({ summary: '사용자 수정' })  // Swagger에 API를 생성
  @Post("/update/:id")
  async update(@Param("id") id: number, @Body() updateUserDto: CreateUserDto): Promise<user> {
    return await this.userService.update(id, {
      email: updateUserDto.email,
      name: updateUserDto.name});
  }

  @ApiOperation({ summary: '사용자 삭제' })  // Swagger에 API를 생성
  @Post("/delete/:id")
  async delete(@Param("id") id: number): Promise<user> {
    return await this.userService.delete(id);
  }

  @ApiOperation({ summary: '사용자 로그인' })  // Swagger에 API를 생성
  @Post("/login")
  async login(@Body() loginUserDto: LoginUserDto): Promise<LoginUserResultDto> {
    const LoginUser = await this.userService.login(loginUserDto.email);
    console.log("Nest login => ", JSON.stringify(LoginUser));
    if (LoginUser !== null) {
      // 패스워드 검증
      let DecodePwd = Buffer.from(LoginUser.password, "base64").toString('utf8');;
      const isPasswordValid = await bcryptjs.compare(loginUserDto.password, DecodePwd);
      if (isPasswordValid) {
        return {
          id: LoginUser.id,
          email: LoginUser.email,
          name: LoginUser?.name ?? '',
          resultIdx: 0,
          result: '로그인 성공'
        };
      }
      else {
        return {
          id: 0,
          email: '',
          name: '',
          resultIdx: -1,
          result: '패스워드 검증이 실패했습니다.'
        };
      }
    } else {
      return {
        id: 0,
        email: '',
        name: '',
        resultIdx: -999,
        result: '로그인 실패'
      };
    }
  }
}

tailwindcss, Chakra-UI를 기존 Next.js프로젝트에 적용

더보기
# tailwindcss 전역설치
npm i tailwindcss -G
# 프로젝트에 설치
npm i tailwindcss postcss autoprefixer -D
# 프로젝트에 tailwindcss 및 postcss 환경파일 생성
npx tailwindcss init -p

tailwindcss.config.js 파일 수정

여기서 유심히 봐야할 부분은 purge부분입니다. 페이지 소스 파일들이 있는 곳을 정확하게 넣어야 적용됩니다. 

/** @type {import('tailwindcss').Config} */
module.exports = {
  mode: 'jit',
  purge: [
    './public/**/*.html',
    './pages/**/*.{js,jsx,ts,tsx,vue}',
    './pages/components/**/*.{js,jsx,ts,tsx,vue}'
  ],
  darkMode: false, // or 'media' or 'class'
  content: [
    './pages/**/*.{html,js}',
    './components/**/*.{html,js}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

postcss.config.js 파일 수정

module.exports = {
    plugins: [
      "postcss-import",
      "tailwindcss",
      "autoprefixer"
    ]
  };

css/tailwind.css 파일 추가

/* purgecss start ignore */
@import 'tailwindcss/base';
/* purgecss end ignore */
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

pages/_app.tsx 파일 수정

위에서 추가됐기 때문에 여기서는 추가된 항목만 넣어 설명하겠습니다. 

(꼭 이렇게 추가되어야 하나 하는 의문이....??)

~~
import '@/css/tailwind.css';
~~

확인하기

이 구문은 tailwindcss 설명에 추가된 명령인데, 궂이 하지 않아도 실행할 때 나오기는 합니다. 실행 전 제대로 적용됐는지 확인하기 위한 구문이라고 생각하면 될 것 같습니다.

npx tailwindcss -i ./css/tailwind.css -o ./dist/output.css

Chakra-UI 설치

npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion @chakra-ui/icons react-icons@4.2.0

tailwindcss를 이용해 네비게이션 바 탑재 & Session을 이용한 Sign In/Out 적용

상단에 메뉴를 추가하기 위한 네비게이션 바인 "navbar" 컴포넌트를 추가하고 navbar에 로그인/로그아웃을 붙여 보겠습니다. 아울러 반응형 웹 형태로 데스크탑/모바일 형태로 보이도록 구현합니다. (이 부분은 아래 참고 블로그에서 응용했음을 알립니다.)

사전에 패키지 설치

# heroicons 패키지 설치
# https://heroicons.com/ 의 아이콘들을 사용할 수 있습니다.
npm i @heroicons/react@v1

네비게이션 바 만들고, Layout 구성하기

더보기

pages/components/Navbar.tsx 파일생성

next-auth의 세션정보를 이용하여 로그인했을 때와 안했을 때의 정보를 표시하기 위해 분기처리하여 제공합니다.

자세한 내용은 참고란의 "Tailwind CSS 반응형 Sidebar Navbar만들기" 블로그를 참고해주세요.

import React, { useState } from 'react';
import classNames from 'classnames';
import { DarkModeSwitch } from '@/pages/components/DarkModeSwitch';
import { MenuIcon, XIcon } from '@heroicons/react/outline';
import { signIn, signOut, useSession } from 'next-auth/react';

const Navbar = () => {
  const [menuToggle, setMenuToggle] = useState(false);
  const { data: session, status } = useSession();

  console.log('Session User Name: ' + JSON.stringify(session));
  console.log('Session User Status: ' + JSON.stringify(status));

  return (
    //   navbar goes here
    <nav className="bg-gray-100">
      <div className="max-w-6xl mx-auto px-4">
        <div className="flex justify-between">
          <div className="flex space-x-4">
            {/* logo */}
            <div>
              <a href="/" className="flex items-center py-5 px-2 text-gray-700">
                <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
                  <path
                    fillRule="evenodd"
                    d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z"
                    clipRule="evenodd"
                  />
                </svg>
                <span className="font-bold">Enjoydev.NET</span>
              </a>
            </div>
            {/* primary nav  */}
            <div className="hidden md:flex items-center space-x-1">
              <a href="#" className="py-5 px-3 text-gray-700 hover:text-gray-900">
                메뉴1
              </a>
              <a href="#" className="py-5 px-3 text-gray-700 hover:text-gray-900">
                메뉴2
              </a>
            </div>
          </div>
          {/* secondary nav */}
          {status === 'authenticated' ? (
            <div className="hidden md:flex items-center space-x-1">
              <div className="py-5 px-3 text-gray-700 hover:text-gray-900">{session.user?.name}</div>
              <button className="py-5 px-3" onClick={() => signOut()}>
                로그아웃
              </button>
            </div>
          ) : (
            <div className="hidden md:flex items-center space-x-1">
              <a href="/api/auth/signin" className="py-5 px-3">
                로그인
              </a>
              <a href="/signup" className="py-2 px-3 bg-yellow-400 hover:bg-yellow-300 text-yellow-900 hover:text-yellow-800 rounded transition duration-300">
                회원가입
              </a>
            </div>
          )}
          {/* mobile menu */}
          <div className="md:hidden flex items-center">
            <button onClick={() => setMenuToggle(!menuToggle)}>{menuToggle ? <XIcon className="w-6 h-6" /> : <MenuIcon className="w-6 h-6" />}</button>
          </div>
        </div>
      </div>

      {/* mobile menu items */}
      <div className={`${!menuToggle ? 'hidden' : ''} md:hidden`}>
        {status === 'authenticated' ? (
          <div>
            <a href="#" className="block py-2 px-4 text-sm hover:bg-gray-200">
              Menu1
            </a>
            <a href="#" className="block py-2 px-4 text-sm hover:bg-gray-200">
              Menu2
            </a>
            <button className="block py-2 px-4 text-sm hover:bg-gray-200" onClick={() => signOut()}>
              Log out
            </button>
          </div>
        ) : (
          <div>
            <a href="/api/auth/signin" className="block py-2 px-4 text-sm hover:bg-gray-200">
              Login
            </a>
            <a href="/signup" className="block py-2 px-4 text-sm hover:bg-gray-200">
              Signup
            </a>
          </div>
        )}
      </div>
    </nav>
  );
};

export default Navbar;

pages/components/Footer.tsx 파일생성

하단에 고정적으로 표시하기 위해 추가합니다. 

import React from "react";
import {
  Flex,
  Stack,
  Text,
  VStack,
  Divider,
  ButtonGroup,
  IconButton,
} from "@chakra-ui/react";
import { FaGithub } from "react-icons/fa";
import Link from "next/link";

interface MenuItemProps {
  m: React.ReactNode;
  children: string;
  to: string;
}
const MenuItem = ({ children, to = "/" }: MenuItemProps) => {
  return (
    <Text
      ms={{ base: 2, sm: 2, md: 2, xl: 2 }}
      mr={{ base: 2, sm: 2, md: 2, xl: 2 }}
      display="block"
    >
      <Link href={to}>{children}</Link>
    </Text>
  );
};

const Footer = () => {
  return (
    <VStack direction={["column", "column", "column", "column"]}>
      <Divider />
      <Flex px={{ base: "4", md: "8" }} py="4" as="footer" wrap="wrap" w="100%" mx="auto">
        <Stack
          direction="row"
          spacing="4"
          align="center"
          justify="space-between"
          w="100%"
        >
          <ButtonGroup variant="ghost" color="gray.600">
            <IconButton
              as="a"
              href="https://github.com/Benjamin-Shin/nextjs_nestjs_base"
              target="_blank"
              aria-label="GitHub"
              icon={<FaGithub fontSize="20px" />}
            />
          </ButtonGroup>
        </Stack>
      </Flex>
      <MenuItem m={4} to="/">
        &copy; Enjoydev.NET. All rights reserved.
      </MenuItem>
    </VStack>
  );
};

export default Footer;

pages/DarkModeSwitch.tsx 파일생성

Chakra-UI에서 제공되는 화이트와 다크 모드를 스위치하기 위해 추가합니다.

import { useColorMode, IconButton } from "@chakra-ui/react";
import { MoonIcon, SunIcon } from "@chakra-ui/icons";

export const DarkModeSwitch = () => {
  const { colorMode, toggleColorMode } = useColorMode();
  return (
    <IconButton
      aria-label="Toggle Dark Switch"
      icon={
        colorMode === "dark" ? (
          <SunIcon color="black" />
        ) : (
          <MoonIcon color="black" />
        )
      }
      onClick={toggleColorMode}
      ms={{ base: 2, sm: 2, md: 2, xl: 2 }}
      mr={{ base: 2, sm: 2, md: 2, xl: 2 }}
      display="block"
      w={10}
      h={10}
    />
  );
};

pages/Layout.tsx 파일생성

Layout에 footer를 고정 표시하기 위해 추가합니다.

import React from "react";
import Footer from "@/pages/components/Footer";
import { Container } from "@chakra-ui/react";

type Props = {
  children: React.ReactNode;
};

export default function Layout(props: Props) {
  return (
    <Container maxW="container.xl" p={0}>
      {props.children}
      <Footer />
    </Container>
  );
}

pages/_app.tsx 파일 수정

이 파일은 위에서 수정했기 때문에 수정된 부분만 표시하겠습니다.

~
import Navbar from '@/pages/components/Navbar';
import Layout from '@/pages/components/Layout';
~
export default function App({Component, pageProps: { session, ...pageProps },}: AppProps) {
  return (
    <SessionProvider session={session}>
      <ChakraProvider resetCSS>
        <Navbar />
        <Layout>
          <Component {...pageProps} />
        </Layout>
      </ChakraProvider>
    </SessionProvider>
  );
}

 

회원가입 기능만들기

더보기

pages/signup.tsx 파일생성

createUser는 Next.js의 API를 호출하고 있습니다. 실제 /api/auth/signup은 미들웨어 형태로 다음에 추가될 이 파일에서 nest.js에서 생성된 API를 호출합니다. 

회원가입이 완료되면 로그인 페이지로 Redirect됩니다.

import React, { useState, useRef } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";

async function createUser(
  name: string,
  email: string,
  password: string
): Promise<any> {
  const response = await fetch("/api/auth/signup", {
    method: "POST",
    body: JSON.stringify({ name, email, password }),
    headers: {
      "Content-Type": "application/json",
    },
  });

  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.message || "Something went wrong!");
  }

  return data;
}

const Signup: React.FC = (props) => {
  const [formStatus, setFormStatus] = useState<string>('');

  const nameInputRef = useRef<HTMLInputElement>(null);
  const emailInputRef = useRef<HTMLInputElement>(null);
  const passwordInputRef = useRef<HTMLInputElement>(null);

  const { status } = useSession();
  const router = useRouter();

  async function submitHandler(event: React.SyntheticEvent) {
    event.preventDefault();

    const enteredName = nameInputRef.current?.value;
    const enteredEmail = emailInputRef.current?.value;
    const enteredPassword = passwordInputRef.current?.value;

    // optional: Add validation

    try {
      const result = await createUser(
        enteredName? enteredName : "",
        enteredEmail? enteredEmail : "",
        enteredPassword? enteredPassword : ""
      );
      console.log(result);
      setFormStatus(`Sign up Success: ${result.message}`);
      window.location.href = "/";
      router.replace("/login");
    } catch (error) {
      console.log(error);
      setFormStatus(`Error Occured: ${error}`);
    }
  } // end of submitHandler function

  if (status === "authenticated") {
    router.replace("/");
    return (
      <div>
        <h1>Sign Up</h1>
        <div>You are already signed up.</div>
        <div>Now redirect to main page.</div>
      </div>
    );
  }

  return (
    <div className="container px-5 py-10 mx-auto w-2/3">
      <div className="text-center mb-12">
        <h1 className="text-4xl md:text-4xl text-gray-700 font-semibold">
          회원가입
        </h1>
      </div>
      <form
        onSubmit={submitHandler}
        className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
      >
        <div className="mb-4">
          <label
            className="block text-gray-700 text-sm font-bold mb-2"
            htmlFor="name"
          >
            이름
          </label>
          <input
            className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            id="name"
            type="text"
            placeholder="이름을 입력하세요."
            required
            ref={nameInputRef}
          />
        </div>
        <div className="mb-4">
          <label
            className="block text-gray-700 text-sm font-bold mb-2"
            htmlFor="email"
          >
            이메일 ID
          </label>
          <input
            className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
            id="email"
            type="text"
            placeholder="Email을 입력하세요."
            required
            ref={emailInputRef}
          />
        </div>
        <div className="mb-6">
          <label
            className="block text-gray-700 text-sm font-bold mb-2"
            htmlFor="password"
          >
            패스워드
          </label>
          <input
            className="shadow appearance-none border border-red-500 rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
            id="password"
            type="password"
            required
            ref={passwordInputRef}
          />
          <p className="text-red-500 text-xs italic">
            {/* Please choose a password. */}
            {formStatus}
          </p>
        </div>
        <div className="flex items-center justify-between">
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
            type="submit"
          >
            회원가입
          </button>
        </div>
      </form>
    </div>
  );
};

export default Signup;

 

pages/api/auth/signup.ts 파일생성

이 파일에서 Nest.js에서 생성된 API를 호출합니다. 환경파일에 NESTAUTH_URL을 추가해서 처리하고 있습니다. 어짜피 NEXTAUTH_URL과 값은 동일합니다. 

import { NextApiRequest, NextApiResponse } from 'next';
import bcryptjs from 'bcryptjs';

async function handler(req: NextApiRequest, res: NextApiResponse) {
    if (req.method !== 'POST') {
        return;
    }

    const data = req.body;
    const { name, email, password } = data;

    // Next.js에서 Nest.js API를 호출
    const result = await fetch(process.env.NESTAUTH_URL + '/api/user/create', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ email: email, name: name, password: password }),
    });

    if (result) {
        res.status(201).json({ message: 'Created user!', error: false });
    } else {
        res.status(422).json({ message: 'Prisma error occured', error: true })
    }
}

export default handler;

커스텀 로그인 페이지 만들기

pages/Login/index.tsx 파일생성

[...nextauth].ts에 pages로 login을 넣었던 것을 기억하실 겁니다. 그때 연결되는 페이지로 생각하시면 됩니다. 

import React, { useState, useRef } from "react";
import { useSession, signIn } from "next-auth/react";
import { useRouter } from "next/router";
import { Flex, Button, Heading, VStack } from '@chakra-ui/react';
import Head from 'next/head';
import Navbar from '@/pages/components/Navbar';

const Login: React.FC = (props) => {
  const [formStatus, setFormStatus] = useState<string>('');

  const emailInputRef = useRef<HTMLInputElement>(null);
  const passwordInputRef = useRef<HTMLInputElement>(null);

  async function submitHandler(event: React.SyntheticEvent) {
    event.preventDefault();

    const enteredEmail = emailInputRef.current?.value;
    const enteredPassword = passwordInputRef.current?.value;

    const result = await signIn("credentials", {
      redirect: false,
      email: enteredEmail,
      password: enteredPassword,
    });

    if (!result?.error) {
      setFormStatus(`Log in Success!`);
      router.replace("/");
    } else {
      setFormStatus(`Error Occured : ${result.error}`);
    }
  } // end of submitHandler function

  const { data: session, status } = useSession();
  const router = useRouter();

  if (status === "authenticated") {
    router.replace("/");
    return (
      <div>
        <h1>Log in</h1>
        <div>You are already logged in.</div>
        <div>Now redirect to main page.</div>
      </div>
    );
  }

  return (
    <div>
      <Head>
        <title>Based Next.js & Nest.js</title>
        <meta
          name="description"
          content="Thirdparty: Prisma & Next-auth & Chakra-UI"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Flex justify="center">
        <VStack mb={6}>
          <div className="container px-5 py-10 mx-auto w-100">
            <div className="text-center mb-12">
              <h1 className="text-4xl md:text-4xl text-gray-700 font-semibold">
                로그인
              </h1>
            </div>
            <form
              onSubmit={submitHandler}
              className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
            >
              <div className="mb-4">
                <label
                  className="block text-gray-700 text-sm font-bold mb-2"
                  htmlFor="email"
                >
                  이메일 ID
                </label>
                <input
                  className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
                  id="email"
                  type="text"
                  placeholder="Email"
                  required
                  ref={emailInputRef}
                />
              </div>
              <div className="mb-6">
                <label
                  className="block text-gray-700 text-sm font-bold mb-2"
                  htmlFor="password"
                >
                  패스워드
                </label>
                <input
                  className="shadow appearance-none border border-red-500 rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
                  id="password"
                  type="password"
                  required
                  ref={passwordInputRef}
                />
                <p className="text-red-500 text-xs italic">
                  {/* Please choose a password. */}
                  {formStatus}
                </p>
              </div>
              <div className="flex items-center justify-between">
                <button
                  className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
                  type="submit"
                >
                  로그인
                </button>
              </div>
            </form>
          </div>
        </VStack>
      </Flex>
    </div>
  );
};

export default Login;

최종소스

Github바로가기

참고

Next-Auth에서 Credentials 사용하기

 

[ Next.js ] - next-auth 사용법 (credentials)

next-auth next에서 사용되는 패키지의 일종으로 로그인과 같은 사용자 인증 기능을 제공하여 준다. Github, Google, Facebook, Credentials 등의 인증 옵션을 제공한다. 여러 인증 옵션 중에서 Credentials는 사용

mihee0703.tistory.com

Chakra UI Components Reference

 

Chakra UI - A simple, modular and accessible component library that gives you the building blocks you need to build your React a

Simple, Modular and Accessible UI Components for your React Applications. Built with Styled System

chakra-ui.com

Next-Auth 커스텀 로그인 화면 만들기

 

 

카카오 네이버 구글 커스텀 로그인 화면 만들기 NextAuth React Next

안녕하세요? 오늘은 지난 시간부터 알아본 NextAuth 강좌의 연장인데요. 우리는 지금까지 카카오 로그인, 네이버 로그인, 구글 로그인에 대해 알아보았습니다. 1편: 카카오 로그인 https://cpro95.tistory

cpro95.tistory.com

Tailwind CSS 반응형 Sidebar Navbar만들기

 

Tailwind CSS 강좌 2편 - 반응형 Sidebar Navbar 만들기

안녕하세요? 얼마전부터 Tailwind CSS 강좌를 이어 가고 있는데요. 처음에는 어려웠지만 계속 도전해 보니까 예전에 배웠는 HTML + CSS 지식이 다시금 살아나서 좋았습니다. Tailwind CSS 의 장점은 본인

cpro95.tistory.com

반응형