코딩/React

Supabase로 로그인 구현하기 with NextJS 2편

드리프트 2022. 1. 5. 23:25
728x170

 

안녕하세요?

 

이번 시간에는 지난 회부터 시작한 Supabase를 이용한 로그인 구현 2편을 이어나가도록 하겠습니다.

 

1편은 아래 링크 참조바랍니다.

 

https://cpro95.tistory.com/617

 

Supabase로 로그인 구현하기 with NextJS 1편

안녕하세요? 최근 NextJS로 여러가지 로그인 구현 웹앱을 만들려고 노력하고 있는데요. 최근에는 NextAuth와 Prisma를 이용해서 카카오 로그인, 네이버 로그인, 구글 로그인 등 다방면으로 구현해 봤

cpro95.tistory.com

 

지난 시간에는 우리의 NextJS 템플릿을 Typescript, TailwindCSS를 이용해서 구축했는데요.

 

물론, Supabase API URL, Key도 설정했고요.

 

먼저 프로젝트의 뼈대부터 구성해 보도록 하겠습니다.

 

참고로, Typescript를 쓰기 때문에 JSX 컴포넌트는 확장자가 tsx, js 함수는 ts로 확장자를 주어야 합니다.

 

Layout, Navbar는 제가 지금까지 작성해왔던 반응형 메뉴를 계속 재사용할 예정입니다.

 

그리고, 아이콘은 react-icons를 이용할 예정인데요.

 

react-icons가 좀 더 많은 아이콘을 이용할 수 있습니다.

 

그리고 classnames 패키지도 설치할 예정입니다.

 

npm i react-icons classnames

 

먼저, 뼈대 부분인 _app.tsx, Layout.tsx, Navbar.tsx를 경로에 맞게 아래 코드로 작성 바랍니다.

 

/pages/_app.tsx

import React from "react";
import Head from "next/head";
import "../styles/globals.css";

import Layout from "../components/Layout";

function MyApp({ Component, pageProps }) {
  return (
    <React.Fragment>
      <Head>
        <meta content="width=device-width, initial-scale=1" name="viewport" />
      </Head>
      <Layout>
        <Component {...pageProps} />
      </Layout>
    </React.Fragment>
  );
}

export default MyApp;

 

/components/Layout.tsx

import React from "react";
import NavBar from "./Navbar";

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

export default function Layout(props: Props) {
  return (
    <div className="w-full p-0">
      <NavBar />
      {props.children}
    </div>
  );
}

 

/components/Navbar.tsx

import React, { useState } from "react";
import { FaBars, FaBuffer, FaTimes } from "react-icons/fa";

const Navbar = () => {
  const [menuToggle, setMenuToggle] = useState(false);

  let status = "not authenticated";
  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">
                <FaBuffer className="w-6 h-6" />
                <span className="font-bold px-2">Home</span>
              </a>
            </div>

            {/* primary nav */}
            <div className="hidden md:flex items-center space-x-1">
              <a
                href="/features"
                className="py-5 px-3 text-gray-700 hover:text-gray-900"
              >
                Features
              </a>
              <a
                href="#"
                className="py-5 px-3 text-gray-700 hover:text-gray-900"
              >
                Pricing
              </a>
            </div>
          </div>
          {/* secondary nav */}
          {status === "authenticated" ? (
            <div className="hidden md:flex items-center space-x-1">
              <button
                className="py-5 px-3"
                onClick={(evt) => {
                  evt.preventDefault();
                  alert("Log out");
                }}
              >
                Log out
              </button>
            </div>
          ) : (
            <div className="hidden md:flex items-center space-x-1">
              <a href="/login" className="py-5 px-3">
                Login
              </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"
              >
                Signup
              </a>
            </div>
          )}
          {/* mobile menu */}
          <div className="md:hidden flex items-center">
            <button onClick={() => setMenuToggle(!menuToggle)}>
              {menuToggle ? (
                <FaTimes className="w-6 h-6" />
              ) : (
                <FaBars className="w-6 h-6" />
              )}
            </button>
          </div>
        </div>
      </div>

      {/* mobile menu items */}
      <div className={`${!menuToggle ? "hidden" : ""} md:hidden`}>
        <a
          href="/features"
          className="block py-2 px-4 text-sm hover:bg-gray-200"
        >
          Features
        </a>
        <a href="#" className="block py-2 px-4 text-sm hover:bg-gray-200">
          Pricing
        </a>

        {status === "authenticated" ? (
          <button
            className="block py-2 px-4 text-sm hover:bg-gray-200"
            onClick={(evt) => {
              evt.preventDefault();
              alert("Log out");
            }}
          >
            Log out
          </button>
        ) : (
          <div>
            <a
              href="/login"
              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/index.tsx

import Head from "next/head";

export default function Home() {
  return (
    <div className="flex flex-col items-center justify-start py-36 min-h-screen">
      <Head>
        <title>Supabase Auth Tutorial</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <h1 className="text-6xl font-bold">
        Welcome to{" "}
        <a className="text-blue-600" href="https://nextjs.org">
          Next.js! with Supabase
        </a>
      </h1>
    </div>
  );
}

 

일단 Navbar 부분의 log out 부분은 나중을 위해서 임시방편으로 console.log로 대체했습니다.

 

그리고 status 부분도 임시방편이니까 나중에 고치도록 하겠습니다.

 

이제 "npm run dev"로 우리 코드를 실행해 볼까요?

 

 

잘 실행되었네요.

 

이제 Supabase를 이용한 로그인 구현의 첫 번째 단계인 Signup에 대해 알아봅시다.

 

 

 

1. Signup 구성

 

우리의 Navbar 코드를 보시면 signup 버튼은 링크가 /signup입니다.

 

그래서 /pages/signup.tsx 파일을 만들어야 합니다.

 

최종 결과는 위 그림과 같이 나옵니다.

 

코드를 보기 전에 먼저 유틸리티 함수를 좀 알아보겠습니다.

 

폼(Form) 컨트롤에 대한 것인데요.

 

폼 컨트롤 패키지는 여러 가지가 있지만 그것까지 공부하려면 어렵고 해서 일단 인터넷에서 발견한 아주 간단한 유틸리티 함수를 가져와 봤습니다.

 

먼저, 코드를 볼까요?

 

/lib/utils.ts

import { useState } from 'react'

export function useFormFields<T>(
    initialValues: T
): [T, (event: React.ChangeEvent<HTMLInputElement>) => void, () => void] {
    const [values, setValues] = useState<T>(initialValues);
    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        event.persist();
        const { target } = event;
        const { name, value } = target;
        setValues({ ...values, [name]: value });
    }
    const resetFormFields = () => setValues(initialValues);
    return [values, handleChange, resetFormFields];
}

useFormFields 함수를 조금만 살펴보면, React Hook 스타일로 리턴되는 게 values, handleChange, resetFormFields 세 개가 리턴됩니다.

 

타입 스크립트 제네릭을 이용해서 뭔가 어려운 거처럼 보이는데요.

 

나중에 실제 사용 내용을 보면 이해가 갈 겁니다.

 

그리고 Signup 같은 API 콜 관련 서버사이드 로직을 호출할 때 나올 수 있는 메시지를 핸들링할 수 있는 유틸리티 함수도 하나 만들었습니다.

 

import { useState } from 'react'

export function useFormFields<T>(
    initialValues: T
): [T, (event: React.ChangeEvent<HTMLInputElement>) => void, () => void] {
    ....
    ....
    ....
    ....
}

export type MessageType = "default" | "success" | "error";

export type MessageProps = {
    type: MessageType;
    payload: string;
};

export function useMessage<MessageProps>(initialValues: MessageProps): [MessageProps, (mes: MessageProps) => void] {
    const [message, setMessage] = useState<MessageProps>(initialValues);

    const handleMessage = (mes: MessageProps) => setMessage(mes);
    return [message, handleMessage];

}

 

useMessage 훅은 MessageProps 타입을 보시면 type와 payload가 있습니다.

 

type은 그 위에 MessageType으로 3가지로 정의했고, 즉, "default", "success", "error"입니다.

 

그리고 payload가 진짜 메시지인데요.

 

useMessage 훅을 이용해서 message 객체와 handleMessage 함수를 리턴하는 형식입니다.

 

이것도 사용되는 코드를 보시면 쉽게 이해할 수 있을 겁니다.

 

이제 유틸리티 함수도 만들었으니까 본격적인 signup.tsx 파일을 만들어 볼까요?

 

import React, { useState, useRef } from "react";
import { FaLock } from "react-icons/fa";
import { supabase } from "../lib/supabase";
import classNames from "classnames";
import { useFormFields, MessageProps, useMessage } from "../lib/utils";

type SignUpFieldProps = {
  email: string;
  password: string;
};

type SupabaseSignupPayload = SignUpFieldProps; // type alias

const FORM_VALUES: SignUpFieldProps = {
  email: "",
  password: "",
};

const MESSAGE_VALUES: MessageProps = {
  type: "default",
  payload: "",
};

const Signup: React.FC = (props) => {
  const [loading, setLoading] = useState(false);

  const [values, handleChange, resetFormFields] =
    useFormFields<SignUpFieldProps>(FORM_VALUES);

  const [message, handleMessage] = useMessage<MessageProps>(MESSAGE_VALUES);

  // sign-up a user with provided details
  const signUp = async (payload: SupabaseSignupPayload) => {
    try {
      setLoading(true);
      const { error } = await supabase.auth.signUp(payload);
      if (error) {
        console.log(error);
        handleMessage({ payload: error.message, type: "error" });
      } else {
        handleMessage({
          payload:
            "Signup successful. Please check your inbox for a confirmation email!",
          type: "success",
        });
      }
    } catch (error) {
      console.log(error);
      handleMessage({
        payload: error.error_description || error,
        type: "error",
      });
    } finally {
      setLoading(false);
    }
  };

  // Form submit handler to call the above function
  const handleSumbit = (event: React.FormEvent) => {
    event.preventDefault();
    signUp(values);
    resetFormFields();
  };

  return (
    <div className="container px-5 py-10 mx-auto w-2/3">
      <div className="w-full text-center mb-4 flex  flex-col place-items-center">
        <FaLock className="w-6 h-6" />
        <h1 className="text-2xl md:text-4xl text-gray-700 font-semibold">
          Sign Up
        </h1>
      </div>
      {message.payload && (
        <div
          className={classNames(
            "shadow-md rounded px-3 py-2 text-shadow transition-all mt-2 text-center",
            message.type === "error"
              ? "bg-red-500 text-white"
              : message.type === "success"
              ? "bg-green-300 text-gray-800"
              : "bg-gray-100 text-gray-800"
          )}
        >
          {message?.payload}
        </div>
      )}
      <form
        onSubmit={handleSumbit}
        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"
          >
            Email
          </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"
            name="email"
            type="email"
            placeholder="Your Email"
            required
            value={values.email}
            onChange={handleChange}
          />
        </div>
        <div className="mb-6">
          <label
            className="block text-gray-700 text-sm font-bold mb-2"
            htmlFor="password"
          >
            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"
            name="password"
            type="password"
            placeholder="Your password"
            required
            value={values.password}
            onChange={handleChange}
          />
        </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"
          >
            Sign Up
          </button>
        </div>
      </form>
      {loading && (
        <div className="shadow-md rounded px-3 py-2 text-shadow transition-all mt-2 text-center">
          Loading...
        </div>
      )}
    </div>
  );
};

export default Signup;

 

뭔가 복잡해 보이는데요.

 

form 부분은 간단합니다.

 

useFormFields 훅을 어떻게 사용하는지 한번 볼까요?

 

먼저, 초기값으로 아래와 같이 FORM_VALUES 값을 줬습니다.

const FORM_VALUES: SignUpFieldProps = {
  email: "",
  password: "",
};

그래서 useFormFields 훅의 values 객체가 바로 위의 email과 password 값을 가지는 React State 객체가 됩니다.

 

그리고 useMessage 훅도 보시면 아래와 같이 MESSAGE_VALUES 값을 줬습니다.

const MESSAGE_VALUES: MessageProps = {
  type: "default",
  payload: "",
};

그래서, useMessage 훅에서 리턴되는 message 객체는 위와 같이 type와 payload를 가지게 됩니다.

 

먼저 UI 부분을 보시면 input 태그에 value 부분은 values.email처럼 위에서 설정한 useFormFields 훅의 values 객체를 참조했고,

 

onChange 부분 또한 useFormFields 부분의 handleChange 함수를 할당했습니다.

 

password 부분의 input 태그도 같은 방식입니다.

 

그리고 마지막으로 Sign Up 부분의 submit 타입의 button을 클릭하면 form 이 제출(submit)되는데요.

 

이때 핸들링할 함수는 handleSubmit으로 지정했습니다.

<form
    onSubmit={handleSumbit}
    className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
>

그럼 handleSubmit 함수는 어떻게 생겼을까요?

// Form submit handler to call the above function
  const handleSumbit = (event: React.FormEvent) => {
    event.preventDefault();
    signUp(values);
    resetFormFields();
  };

위와 같이 signUp 함수와 함께 resetFormFields 함수를 실행하기만 합니다.

 

resetFormFields 함수는 useFormFields 훅에서 리턴되는 함수인데요.

 

form 있는 값은 초기화시켜주는 함수입니다.

 

결론은 signUp 함수가 중요한데요.

 

signUp(values)라고 코드가 되어 있는데요.

 

values 값은 뭘까요?

 

바로 useFormFields 훅에서 리턴된 values 객체입니다.

 

우리가 폼(form)에서 입력한 값이 React State로 지정되어 관리되고 signUp함수에도 그대로 전달되고 있는 거죠.

 

그럼 가장 어려운 signUp 함수를 알아볼까요?

 

그전에 Supabase의 signUp 방식은 어떤지 알아보는 게 중요하겠죠.

 

아래 코드를 보시면 Supabase의 signUp 함수는 supabase.auth 네임스페이스(namespace) 밑에 있습니다.

// Basic
const { user, session, error } = await supabase.auth.signUp({
  email: 'example@email.com',
  password: 'example-password',
})

간단하게 email과 pasword가 있는 객체를 전달하면 되고,

 

supabase.auth.signUp 함수가 리턴하는 거는 user, session, error 세 개가 있습니다.

 

오늘 시간에는 여기서 error 만 체크해 보도록 하겠습니다.

 

그럼 우리의 signUp 함수를 알아볼까요?

 

/ sign-up a user with provided details
  const signUp = async (payload: SupabaseSignupPayload) => {
    try {
      setLoading(true);
      const { error } = await supabase.auth.signUp(payload);
      if (error) {
        console.log(error);
        handleMessage({ payload: error.message, type: "error" });
      } else {
        handleMessage({
          payload:
            "Signup successful. Please check your inbox for a confirmation email!",
          type: "success",
        });
      }
    } catch (error) {
      console.log(error);
      handleMessage({
        payload: error.error_description || error,
        type: "error",
      });
    } finally {
      setLoading(false);
    }
  };

signUp 함수는 payload를 받아서 supabase.auth.signUp에 전달하고 리턴되는 error를 이용해서 handleMessage 훅으로 전달합니다.

 

그리고 setLoading을 이용해서 Loading... 상태를 관리하고요.

 

이제 테스트해볼까요?

 

 

 

일단 가짜 이메일과 패스워드는 4글자로 적었습니다.

 

밑에 Sign Up 버튼을 눌러볼까요?

 

handleMessage 훅에 의해 message 객체에 supabase 에러코드가 전달되고 그걸 위에 그림처럼 보여주는데요.

 

{message.payload && (
        <div
          className={classNames(
            "shadow-md rounded px-3 py-2 text-shadow transition-all mt-2 text-center",
            message.type === "error"
              ? "bg-red-500 text-white"
              : message.type === "success"
              ? "bg-green-300 text-gray-800"
              : "bg-gray-100 text-gray-800"
          )}
        >
          {message?.payload}
        </div>
 )}

위의 코드가 바로 message가 있을 때 화면에 보여주는 로직입니다.

 

그럼, 다시 패스워드를 6글자 이상으로 고치고 Sign Up 버튼을 눌러볼까요?

 

 

일단 위 그림처럼 Signup은 성공했다고 하네요.

 

가입한 이메일을 확인해 보라고 하네요.

 

이메일이 가짜기 때문에 확인할 필요는 없고, Supabase 어드민 패널을 살펴보도록 하겠습니다.

 

 

Supabase 어드민 패널에서 위에서 세 번째인 Authenticaion을 클릭합니다.

 

 

위 그림을 보시면 아까 우리가 Sign Up 했던 test@test.com 사용자가 보이네요.

 

그리고 자동으로 Provider, Created, Last Sign In, User UID 등이 생성되었습니다.

 

Supabase의 가입방식은 이메일 확인이 필요한데요.

 

우리가 가입하면 Supabase가 가입 확인 이메일을 보냅니다.

 

그래서 사용자가 이메일에서 확인 링크를 클릭해야만 유저가 정식 등록되는 방식입니다.

 

그러면 위의 test@test.com 사용자를 삭제해 볼까요?

 

User UID 항목 오른쪽에 보면... 점 세 개가 보이는데 클릭해보면 위 그림처럼 3개의 메뉴가 나옵니다.

 

3개의 메뉴는 이름 그대로의 기능을 하는데요.

 

Delete User를 클릭해 봅시다.

 

당연히 컨펌 메뉴가 보이겠죠.

 

Confirm을 눌러줍시다.

 

아까 있던 사용자가 사라졌습니다.

 

그럼 다시 Sign Up 부분에서 정식 이메일로 진행해 볼까요?

 

제 Daum 메일 계정인데요.

 

위와 같이 Confirm Your Signup 메일이 왔습니다.

 

아래 파란색 링크를 클릭하면 Supabase의 유저 가입이 정식 완료됩니다.

 

그리고 Supabase 어드민 패널을 보면 Last Sign In 부분이 바뀌었습니다.

이제 Supabase의 유저 가입 부분이 끝났습니다.

 

다음 시간에는 Supabase의 로그인(Log in) 부분에 대해 알아보겠습니다.

 

 

그리드형