티스토리 뷰

구현 및 동작이 가능한지 여부를 확인하기 위한 간단한 튜토리얼. 상세한 설명은 없습니다.

프로젝트 초기화

Next.js 프로젝트 생성

$ yarn create next-app
yarn create v1.22.22
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Installed "create-next-app@14.2.5" with binaries:
      - create-next-app
✔ What is your project named? … my-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
...
Success! Created my-app at /home/piatoss/my-app

Done in 17.89s

Chakra UI 설치

$ yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion

RainbowKit + Wagmi 설치

$ yarn add @rainbow-me/rainbowkit wagmi viem @tanstack/react-query

SIWE + NextAuth.js 설치

$ yarn add next-auth siwe ethers@^5
  • ethers.js는 siwe의 종속성으로 필요하므로 함께 설치

불필요한 파일, 코드 제거

디렉토리 구조

export default function Home() {
  return <div></div>;
}
  • page.tsx에 작성된 코드 지우기
import type { Metadata } from "next";
import { Inter } from "next/font/google";
  • layout.tsx에서 global.css 파일 임포트 구문 지우기

.env.local 파일 생성

NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
NEXTAUTH_URL=
NEXTAUTH_SECRET=
  • NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: Wallet Connect Cloud에서 프로젝트 생성하고 하나 받아오기
  • NEXTAUTH_URL: NextAuth.js를 사용해 로그인할 때 사용자의 도메인을 검사하기 위해 사용
  • NEXTAUTH_SECRET: 로그인한 사용자의 토큰을 생성할 때 사용하는 비밀키

Providers, Context 생성

providers.tsx 파일 생성

"use client";

import { ChakraProvider } from "@chakra-ui/react";
import { getDefaultConfig, RainbowKitProvider } from "@rainbow-me/rainbowkit";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { zkSync, zkSyncSepoliaTestnet } from "viem/zksync";
import { WagmiProvider } from "wagmi";
import { SessionProvider } from "next-auth/react";
import { AuthProvider } from "@/context";

const config = getDefaultConfig({
  appName: "Your Project Name",
  projectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID || "",
  chains: [zkSync, zkSyncSepoliaTestnet],
  ssr: true,
});

const queryClient = new QueryClient();

const Providers = ({ children }: { children: React.ReactNode }) => {
  const [mounted, setMounted] = useState<boolean>(false);
  useEffect(() => {
    setMounted(true);
  }, []);

  return (
    <ChakraProvider>
      <WagmiProvider config={config}>
        <QueryClientProvider client={queryClient}>
          <SessionProvider refetchInterval={0}>
            <RainbowKitProvider
              showRecentTransactions={true}
              coolMode
              initialChain={zkSyncSepoliaTestnet}
            >
              <AuthProvider>{mounted && children}</AuthProvider>
            </RainbowKitProvider>
          </SessionProvider>
        </QueryClientProvider>
      </WagmiProvider>
    </ChakraProvider>
  );
};

export default Providers;

 

  • ChakraProvider: 스타일을 적용하기 위해 가장 상위에 배치
  • WagmiProvider, QueryClientProvider: 공식 문서에 명시된 순서로 배치
  • SessionProvider: QueryClientProvider에 의존하므로 하위에 배치
  • RainbowKitProvider: 공식 문서에 따라 WagmiProvider, QueryClientProvider 하위에 배치
  • AuthProvider: 사용자 정의 콘텍스트를 제공하기 위한 프로바이더로, WagmiProvider, SessionProvider에 의존하므로 하위에 배치, RainbowKitProvider와 순서는 상관없음

layout.tsx 파일 수정

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Providers from "./providers";
import "@rainbow-me/rainbowkit/styles.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
  • import "@rainbow-me/rainbowkit/styles.css"로 RainbowKit css 파일 임포트
  • <Providers>{children}</Providers>로 모든 페이지에서 프로바이더를 통해 제공되는 값, 기능에 접근가능하게 하기

AuthProvider 생성하기

"use client";

import { createContext, useCallback, useEffect, useState } from "react";
import { getCsrfToken, signIn, signOut, useSession } from "next-auth/react";
import { SiweMessage } from "siwe";
import { useAccount, useSignMessage, useSwitchChain } from "wagmi";
import { zkSync, zkSyncSepoliaTestnet } from "viem/zksync";
import { useToast } from "@chakra-ui/react";
import { Session } from "next-auth";

interface AuthContextValues {
  address: `0x${string}`;
  session: Session | null;
  isLoading: boolean;
  isWalletConnected: boolean;
  isSignedIn: boolean;
  handleSignIn: () => void;
  handleSignOut: () => void;
}

const AuthContext = createContext({
  address: `0x0`,
  session: null,
  isLoading: false,
  isWalletConnected: false,
  isSignedIn: false,
  handleSignIn: async () => {},
  handleSignOut: async () => {},
} as AuthContextValues);

const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const toast = useToast();

  const { data: session, status } = useSession();
  const {
    address,
    chainId,
    isConnected,
    isDisconnected,
    isConnecting,
    isReconnecting,
  } = useAccount();
  const { signMessageAsync } = useSignMessage();
  const { switchChain } = useSwitchChain();

  const isLoading = status === "loading" || isConnecting || isReconnecting;
  const isWalletConnected = isConnected;
  const isSignedIn = !!session && status === "authenticated";

  const handleSignIn = useCallback(async () => {
    try {
      if (!address || !isConnected) {
        throw new Error("Please connect your wallet first.");
      }

      const callbackUrl = "/";
      const nonce = await getCsrfToken();

      const message = new SiweMessage({
        domain: window.location.host,
        uri: window.location.origin,
        version: "1",
        address: address,
        statement: "Sign in with Ethereum to Fuzion",
        nonce: nonce,
        chainId: chainId,
      });

      const signature = await signMessageAsync({
        message: message.prepareMessage(),
      });

      const response = await signIn("ethereum", {
        message: JSON.stringify(message),
        signature,
        redirect: false,
        callbackUrl,
      });

      if (!response) {
        throw new Error("Failed to sign in.");
      }

      if (response.ok) {
        toast({
          title: "Success",
          description: "You have successfully signed in.",
          status: "success",
          duration: 5000,
          isClosable: true,
        });
      }

      if (response.error) {
        throw new Error(response.error);
      }
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : "Unknown error";
      toast({
        title: "Error",
        description: errorMessage,
        status: "error",
        duration: 5000,
        isClosable: true,
      });
    }
  }, [address, chainId, isConnected, signMessageAsync, toast]);

  const handleSignOut = useCallback(async () => {
    // Do nothing if the user is not signed in
    if (!isSignedIn) {
      return;
    }

    // Sign out and redirect to the home page
    await signOut({ callbackUrl: "/" });
  }, [isSignedIn, isConnected]);

  useEffect(() => {
    // chainId is not available yet
    if (!chainId) {
      return;
    }

    // Switch to zkSync Sepolia Testnet if the chain is not zkSync or zkSync Sepolia Testnet
    if (chainId != zkSync.id && chainId != zkSyncSepoliaTestnet.id) {
      console.log("Switching chain to zkSync Sepolia Testnet...");
      switchChain({
        chainId: zkSyncSepoliaTestnet.id,
      });
    }
  }, [chainId]);

  useEffect(() => {
    // Sign out if the user is signed in but disconnected or the address is different
    if (isSignedIn) {
      if (isDisconnected || (session as any).user.address !== address) {
        handleSignOut();
      }
    }
  }, [address, isDisconnected, isSignedIn, session]);

  return (
    <AuthContext.Provider
      value={{
        address: address ? address : `0x0`,
        session,
        isLoading,
        isWalletConnected,
        isSignedIn,
        handleSignIn,
        handleSignOut,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

export { AuthProvider, AuthContext };

콘텍스트에서 제공되는 값

  • address: 연결된 지갑 주소
  • session: 세션 데이터
  • isLoading: 지갑 연결 또는 로그인 중인지 여부
  • isWalletConnected: 지갑 연결 여부
  • isSignedIn: 로그인 여부
  • handleSignIn: 로그인 핸들러
  • handleSignOut: 로그아웃 핸들러

AuthProvider 예쁘게 내보내기

import { AuthProvider, AuthContext } from "./Auth";

export { AuthProvider, AuthContext };
  • context.index.ts 파일을 생성하고 그 안에서 AuthProvider와 AuthContext 불러오기
  • 이렇게 하면 '@/context'로 접근이 가능

useAuth 커스텀 훅 생성하기

import { AuthContext } from "@/context";
import { useContext } from "react";

const useAuth = () => {
  return useContext(AuthContext);
};

export default useAuth;
  • hooks 디렉토리를 생성하고 useAuth.ts 파일을 작성
import useAuth from "./useAuth";

export { useAuth };
  • 컨텍스트와 마찬가지로 index.ts 파일을 생성하여 예쁘게 내보낸다.

로그인, 로그아웃을 위한 API Routes 구현

AuthOptions 구성

import { AuthOptions } from "next-auth";
import CredentialsProvider, {
  CredentialInput,
} from "next-auth/providers/credentials";
import { getCsrfToken } from "next-auth/react";
import { SiweMessage } from "siwe";

export const authOptions: AuthOptions = {
  providers: [
    CredentialsProvider({
      id: "ethereum",
      name: "Ethereum",
      credentials: {
        message: {
          label: "Message",
          type: "text",
          placeholder: "0x0",
        } as CredentialInput,
        signature: {
          label: "Signature",
          type: "text",
          placeholder: "0x0",
        },
      },
      async authorize(credentials, req) {
        try {
          if (!(credentials?.message && credentials?.signature)) {
            throw new Error("Missing Credentials");
          }

          const siwe = new SiweMessage(JSON.parse(credentials.message));
          const nextAuthUrl = new URL(
            process.env.NEXTAUTH_URL || "http://localhost:3000/"
          );
          const csrf = await getCsrfToken({ req: { headers: req.headers } });

          const result = await siwe.verify({
            signature: credentials.signature,
            domain: nextAuthUrl.host,
            nonce: csrf,
          });

          if (result.success) {
            return {
              id: siwe.address,
            };
          }

          return null;
        } catch (error) {
          console.log(error);
          return null;
        }
      },
    }),
  ],
  session: { strategy: "jwt" },
  debug: process.env.NODE_ENV === "development",
  secret: process.env.NEXTAUTH_SECRET,
  callbacks: {
    async session({ session, token }: { session: any; token: any }) {
      session.user.address = token.sub;
      session.user.name = token.sub;
      return session;
    },
  },
  pages: {
    signIn: "/login",
  },
};

로그인, 로그아웃을 위한 API Routes 생성

import { authOptions } from "@/libs/auth";
import NextAuth from "next-auth/next";

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };
  • app/api/auth/[...nextauth] 디렉터리에 route.ts 파일을 생성

로그인한 사용자만 사용 가능한 API Route 생성

import { authOptions } from "@/libs/auth";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";

const GET = async (req: NextRequest) => {
  try {
    const session = await getServerSession(authOptions);

    console.log("session", session);

    if (session) {
      return NextResponse.json(
        {
          message: "This is a protected route. You are signed in.",
        },
        { status: 200 }
      );
    }

    return NextResponse.json(
      {
        message: "You must be signed in to access this route.",
      },
      { status: 401 }
    );
  } catch (error) {
    const message =
      error instanceof Error ? error.message : "Internal Server Error";
    return NextResponse.json({ message }, { status: 500 });
  }
};

export { GET };

지갑 연결, 로그인, 로그아웃을 위한 컴포넌트 구현

page.tsx 파일 수정

"use client";

import { useAuth } from "@/hooks";
import { Box, Button, Center, Stack, Text } from "@chakra-ui/react";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { useSession } from "next-auth/react";
import { useState } from "react";

export default function Home() {
  const { data: session } = useSession();
  const {
    isWalletConnected,
    isSignedIn,
    isLoading,
    handleSignIn,
    handleSignOut,
  } = useAuth();

  const [message, setMessage] = useState<string | null>(null);

  const handleProtected = async () => {
    try {
      const response = await fetch("/api/protected");
      const data = await response.json();
      const message = data.message as string;
      setMessage(message);
    } catch (error) {
      console.error(error);
      setMessage("An error occurred.");
    }
  };

  return (
    <Box display="flex" flexDirection="column" minH="100vh" p={4} bg="gray.100">
      <Center flexGrow={1}>
        <Stack spacing={4} align="center">
          <ConnectButton />
          {isWalletConnected && (
            <>
              <Box>
                <Button
                  colorScheme="blue"
                  onClick={isSignedIn ? handleSignOut : handleSignIn}
                  isLoading={isLoading}
                >
                  {isSignedIn ? "Sign out" : "Sign in"}
                </Button>
              </Box>
              {session ? (
                <Box>
                  <p>Signed in as {session.user!.name}</p>
                  <p>Expires: {session.expires}</p>
                </Box>
              ) : (
                <Box>
                  <p>Not signed in</p>
                </Box>
              )}
              <Stack spacing={4} align="center">
                <Button colorScheme="blue" onClick={handleProtected}>
                  Protected
                </Button>
                {message && <Text>{message}</Text>}
              </Stack>
            </>
          )}
        </Stack>
      </Center>
    </Box>
  );
}

기능 시연

지갑 연결 및 로그인

보호된 경로로 요청


참고

 

 

소개 — RainbowKit

지갑을 연결하는 최고의 방법 🌈

www.rainbowkit.com

 

Next-Auth in App Router of Next.js

Next.js has recently released a stable version of App Router enriched with in-built support for layouts, templates, routing, loading, and…

medium.com

 

최근에 올라온 글
최근에 달린 댓글
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Total
Today
Yesterday
글 보관함