Next.jsとSupabaseで認証機能ありのリアルタイムチャットを作成する。

Next.jsとSupabaseで認証機能ありのリアルタイムチャットを作成する。

先日、リアルタイムチャットの作り方についてはご紹介しましたが、今の時代に『誰でも入り放題なチャットルーム』を作った場合、BOTやスパム業者等がたくさん入ってくることになるでしょう。

そこで今回は、認証機能をリアルタイムチャットと組み合わせて、ログインした人のみが投稿でき、ログインしていない人は閲覧だけができるチャットアプリを作成してみます。

Supabase側の設定

①:Supabaseプロジェクトの作成

Supabaseにログイン(登録していなければアカウント登録から)して、
トップページの「New Project」を押します。

すると、下記の様な画面が表示されます。

適当なプロジェクト名とデータベースのパスワードを入れて、新しいプロジェクトを作成しましょう。
※Regionはできれば自分の住んでいる地域の近くがいいです。私は日本にしました。

②:ユーザ作成

Authenticationを開き、ユーザを2人作ります。
メールアドレスとパスワードが必要ですが、メールアドレスはテスト用としてexample.comのものを利用します。
『example.com』は例示用に確保されているセカンドレベルドメインなので、うっかりそのまま実装しても事故が起きにくいです。

右上のAdd UserCreate New Userを押すと、下記の画面が出て来ます。

任意のパスワードで下記のメールアドレスのユーザを作ってください

※ユーザ作成時Auto Confirm User?のチェックは外さないでください。

下記のように追加ができていればOKです

③:テーブル作成

今回、ユーザプロフィール(シンプルに名前のみ)定義用のテーブルと、チャット情報のテーブルが必要になりますので、それぞれ作成しましょう。

ユーザプロフィールテーブル

まずチャットアプリ内に表示する、ユーザプロフィールを作ります。
SupabaseのDashboardのTable Editorメニューから、New Tableボタンを押します。
すると新規テーブルの設定画面が出るので、添付画像のように設定してください。

ポイントはID横のリンクボタンです。こちらをクリックすると下記の画面が出てきて、外部キーを参照する設定が出来ます。
今回、IDを①で作成したユーザに結びつけたいので、下記の様な設定にします。

※“Action if referenced row is removed”にCascadeという設定をしていますが、参照先のキーが削除されたらデータを削除する設定です。
つまりユーザが削除された場合、このテーブル上のデータも同時に削除されます。

これでユーザプロフィールのテーブルは作成出来ました。

チャット情報テーブル

チャット情報テーブルは下記の情報を元に作成します。

  • メッセージのid
  • 作成日時
  • メッセージの内容
  • ユーザのid
  • ルーム名(チャンネル名)

ユーザプロフィールと同じように、Supabase DashboardのTable EditorメニューからNew Tableボタンを押します。
すると新規テーブルの設定画面が出るので、添付画像のように設定してください。

こちらはリアルタイム機能を使いたいため、「Enable Realtime」の有効化チェックを入れましょう。

また、ユーザプロフィールと同じく外部キーの参照を行っています。
uidの右のリンクボタンをクリックし、添付画像の様な設定にしましょう。

チャット情報テーブルの作成も完了です。

アクセス制限のためのポリシーは後ほど作成します。

Next.js事前準備

①:認証機能のリポジトリをクローン

今回はこちらの記事で紹介している認証機能(Supabase Auth)を元に実装を進めたいので、
まずはこちらの記事内にあるgithubのリンク からgit cloneしてリポジトリを持ってきてください。

②:起動確認

Supabase Authが正しく動くかをまず確認しましょう。

クローンしたプロジェクトの直下に.env.localを新規作成し、
内容を下記の通り入力します

# Update these with your Supabase details from your project settings > API
# https://app.supabase.com/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL={ここにProject URLを入れる}
NEXT_PUBLIC_SUPABASE_ANON_KEY={ここにProject API Anon Keyを入れる}

それぞれここに〜〜入れると書かれている部分に
SupabaseのダッシュボードのProject SettingsAPIから必要な情報をコピーします。

コピーが終わったら、

npm install

を実行した上で

npm run dev

を実行します。

下記の画面が表示されるか確認してください。

設定したメールアドレスでLoginを行い、Profileページが表示されたらOKです。

SupabaseのDBとの連携

DBの型生成

Next.jsで実装するためにまずはDatabaseの型を生成しましょう。
まずはプロジェクトのディレクトリ直下にtypesフォルダを作成します。(ファイルは生成されるため作成せずでOKです。)

下記で、Supabase CLI上でログインします。
(対話的に進めていけばログイン出来ます。)

npx supabase login

その後、Databaseの型生成用のコマンドを実行します。
($PROJECT_IDは、SupabaseのDashboardでプロジェクトを選択した際のURLの[https://supabase.com/dashboard/project/以下の文字列です。)](https://supabase.com/dashboard/project/%60%E4%BB%A5%E4%B8%8B%E3%81%AE%E6%96%87%E5%AD%97%E5%88%97%E3%81%A7%E3%81%99%E3%80%82%EF%BC%89)

npx supabase gen types typescript --project-id "$PROJECT_ID" --schema public > types/supabasetype.ts

これを実行すると、supabasetype.tsのファイルの中身が生成されていることが確認出来ます。

utils/supabase/supabase.ts

Databaseにアクセスするため、事前にsupabase.tsの中身を作成します。
先程生成したsupabasetype.tsもここで利用しています。

import { createClient } from '@supabase/supabase-js'
import { Database } from '@/types/supabasetype'

export const supabase = createClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);

.env.localに入力したURLとAPIキーがここで利用されます。

その他の実装

主要部分ではないレイアウトなどの実装をまずは行います。
※存在しないファイルは、それぞれ記述のファイルパスで作成してください。

app/page.tsx

トップページのレイアウトの作成を行います

import ThreadLink from '@/components/threadLink'

export default async function Index() {

  return (
    <div className="flex-1 w-full flex flex-col items-center">
      <h1 className="text-3xl font-bold pt-6 pb-10">認証機能ありリアルタイムチャットアプリ</h1>
      <ul>
        <ThreadLink channelName='thread1' linkName='スレッド1'></ThreadLink>
        <ThreadLink channelName='thread2' linkName='スレッド2'></ThreadLink>
        <ThreadLink channelName='thread3' linkName='スレッド3'></ThreadLink>
        <ThreadLink channelName='thread4' linkName='スレッド4'></ThreadLink>
        <ThreadLink channelName='thread5' linkName='スレッド5'></ThreadLink>
      </ul>
    </div>
  )
}

components/date.tsx

タイムスタンプ型の日付を見やすい形にして返すコンポーネントです。

type Props = {
  timestamp: string
}

export default function DateFormatter({ timestamp }: Props) {
  const date = new Date(timestamp)
  var jstDate = date.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" })

  return (
    <>
      {jstDate}
    </>
  )
}

components/threadLink.tsx

スレッドへのリンクを作成しています。
パラメータに各スレッド名を渡すことで、任意のスレッドを開いています。

import Link from 'next/link'

type Props = {
  channelName: string,
  linkName: string,
}

export default function ThreadLink({ channelName, linkName }: Props) {
  return (
    <li className='mb-4'>
      <Link className='text-gray-700 border-b-2 border-gray-700 hover:border-blue-700 hover:text-blue-700 text-xl' href={{
        pathname: '/chats',
        query: { channel_name: channelName },
      }}>{linkName}</Link>
    </li>
  )
}

主要部分の実装

app/profile/page.tsx

元々あるプロフィールページに、ユーザ名を変更できる機能を作成します。

"use client"
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react";

/**
 * ログイン後のマイページ
 */
const MyPage = () => {
  const supabase = createClientComponentClient();
  const [name, setName] = useState("");
  const [userID, setUserID] = useState("");

  useEffect(() => {
    getData();
  }, []);

  const getData = async () => {
    const { data: { user } } = await supabase.auth.getUser();

    if (user === null) return

    setUserID(user.id)


    const { data: profile, error } = await supabase
      .from('profiles')
      .select()
      .eq("id", user.id)

    if (error) {
      console.log(error);
      return
    }

    if (profile.length === 1) {
      setName(profile[0].name)
    }

  }

  const onChangeName = async (event: any) => {
    event.preventDefault();
    if (userID === "") {
      return
    }
    const { data, error } = await supabase
      .from('profiles')
      .upsert({ id: userID, name: name })
      .select()
    if (error) {
      console.log(error);
      return
    }
  }



  return (
    <div className="mx-auto max-w-xl px-4 sm:px-6 lg:px-8 pb-16 pt-20 text-center lg:pt-32">
      <h1 className="text-2xl font-bold">
        ログインに成功しました
      </h1>
      <div className="pt-10">
        <form onSubmit={onChangeName}>
          <label
            htmlFor="name"
            className="block mb-2 text-sm text-left font-medium text-gray-900"
          >
            名前
          </label>
          <div className="flex w-full">
            <input
              type="text"
              name="name"
              id="name"
              className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
              placeholder="山田 太郎"
              onChange={(e) => setName(e.target.value)}
              value={name}
              required
            />
            <button
              className="ml-2 min-w-fit text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
              type="submit"
            >
              更新
            </button>
          </div>
        </form>
      </div>
      <div className="pt-10">
        <form action="/auth/logout" method="post">
          <button
            className=" text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
            type="submit"
          >
            ログアウト
          </button>
        </form>
      </div>
    </div>
  )
}


export default MyPage;

今回、ユーザ名が既に存在している場合は更新、存在していない場合は追加するためupsertを利用しています。

const { data, error } = await supabase
      .from('profiles')
      .upsert({ id: userID, name: name })
      .select()

これにより更新にも追加にも対応してユーザ名を変更できるようにしています。

こちらの画面は下記のような見た目になります。

app/chats/page.tsx

各スレッドのページになります。
実装の詳細はリアルタイムチャットの記事を見ていただけると、理解が深まると思います。
上記との違いは、認証したユーザの情報を取り入れている部分になり、匿名チャットではなく、認証したユーザがチャットを行う形式にしています。

"use client"
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { Database } from "@/types/supabasetype"
import { useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import ChatUI from "@/components/chats/chat"

export default function Chats() {
  const supabase = createClientComponentClient()
  const searchParams = useSearchParams()
  let channelName = searchParams.get("channel_name")!!
  const [inputText, setInputText] = useState("")
  const [userID, setUserID] = useState("")
  const [messageText, setMessageText] = useState<Database["public"]["Tables"]["Chats"]["Row"][]>([])

  const fetchRealtimeData = () => {
    try {
      supabase
        .channel(channelName)
        .on(
          "postgres_changes",
          {
            event: "*",
            schema: "public",
            table: "Chats",
          },
          (payload) => {
            if (payload.eventType === "INSERT") {
              const { created_at, id, message, uid, channel, name } = payload.new
              setMessageText((messageText) => [...messageText, { id, created_at, message, uid, channel }])
            }
          }
        )
        .subscribe()

      return () => supabase.channel(channelName).unsubscribe()
    } catch (error) {
      console.error(error)
    }
  }

  // 初回のみ実行するために引数に空の配列を渡している
  useEffect(() => {
    (async () => {
      let allMessages = null
      try {
        const { data: { user } } = await supabase.auth.getUser()
        if (user != null) {
          setUserID(user.id)
        }

        const { data } = await supabase.from("Chats").select("*").eq('channel', channelName).order("created_at")

        allMessages = data
      } catch (error) {
        console.error(error)
      }
      if (allMessages != null) {
        setMessageText(allMessages)
      }
    })()
    fetchRealtimeData()
  }, [])

  const onSubmitNewMessage = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    if (inputText === "") return
    if (userID === "") {
      alert("ログインしないと投稿出来ません。")
      return
    }
    try {
      const { data: profile, error } = await supabase
        .from('profiles')
        .select()
        .eq("id", userID)

      if (error) {
        console.log(error);
        return
      }

      if (profile.length !== 1) {
        alert("投稿前にユーザ名を設定してください。")
      }

      await supabase.from("Chats").insert({ message: inputText, uid: userID, channel: channelName })
    } catch (error) {
      console.error(error)
      return
    }
    setInputText("")
  }

  return (
    <div className="flex-1 w-full flex flex-col items-center p-2">
      <h1 className="text-3xl font-bold pt-5 pb-10">{channelName}</h1>
      <div className="w-full max-w-3xl mb-10 border-t-2 border-x-2">
        {messageText.map((item, index) => (
          <ChatUI chatData={item} index={index} key={item.id}></ChatUI>
        ))}
      </div>

      <form className="w-full max-w-md pb-10" onSubmit={onSubmitNewMessage}>
        <div className="mb-5">
          <label htmlFor="message" className="block mb-2 text-sm font-medium text-gray-900">投稿内容</label>
          <textarea id="message" name="message" rows={4} className="block p-2.5 w-full text-sm text-gray-900
                 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500"
            placeholder="投稿内容を入力" value={inputText} onChange={(event) => setInputText(() => event.target.value)}>
          </textarea>
        </div>

        <button type="submit" disabled={inputText === ""} className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center disabled:opacity-25">
          送信
        </button>
      </form>
    </div>
  )
}

詳しくポイントを見ていきましょう。
ユーザID(ページ読み込み時に認証情報から取得)が存在していなければログインしていないとみなし、投稿出来なくしています。

if (userID === "") {
      alert("ログインしないと投稿出来ません。")
      return
    }

また、投稿前にユーザIDからユーザプロフィールテーブルを検索し、ユーザ名がなければ投稿出来なくしています。

const { data: profile, error } = await supabase
        .from('profiles')
        .select()
        .eq("id", userID)
~~~~~~~~~~~~~
if (profile.length !== 1) {
        alert("投稿前にユーザ名を設定してください。")
}

wait supabase.from("Chats").insert({ message: inputText, uid: userID, channel: channelName })

もちろん後ほどポリシーを設定し、物理的に認証ユーザ以外の投稿は出来なくしますが、より状況がわかりやすいようUIで伝える工夫をしています。

components/chats/chat.tsx

投稿されたチャットを表示するUIです。
元記事ではチャット情報のテーブルに保存されたユーザ名を参照していましたが、
認証したユーザのプロフィールテーブルからユーザ名を持ってくるよう変更しています。

"use client"
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { Database } from "@/types/supabasetype"
import DateFormatter from "@/components/date"
import { useState, useEffect } from "react";

type Props = {
  chatData: Database["public"]["Tables"]["Chats"]["Row"],
  index: number,
}

export default function ChatUI({ chatData, index }: Props) {
  const supabase = createClientComponentClient();
  const [userName, setUserName] = useState("")
  const getData = async () => {
    const { data: profile, error } = await supabase
      .from('profiles')
      .select()
      .eq("id", chatData.uid)

    if (error) {
      console.log(error);
      return
    }

    if (profile.length !== 1) {
      return
    }

    setUserName(profile[0].name)
  }

  useEffect(() => {
    getData()
  }, [])


  return (
    <div className="p-2 border-b-2">
      <div className="flex">
        <p className=" pr-2">{index + 1}</p>
        <h2 className="font-medium text-gray-900 truncate">{userName}</h2>
      </div>
      <div className="flex items-center justify-between">
        <p className="text-sm text-gray-500"><DateFormatter timestamp={chatData.created_at}></DateFormatter></p>
        <p className="w-32 text-sm text-gray-500 truncate">ID:{chatData.uid}</p>
      </div>
      <p className="mt-1 text-gray-600 whitespace-pre-wrap">{chatData.message}</p>
    </div>
  )
}

ここまでで実装は完了ですが、npm run devする前にポリシーの設定をしてチャット情報、プロフィールに適切にアクセスできるようにしましょう。

テーブルのポリシー設定

このチャットアプリは、

  • 認証したユーザがチャットを投稿、閲覧できる
  • 認証していないユーザはチャットを閲覧のみできる

としたいため、

チャットにユーザ名を表示するために使うプロフィールは

  • 誰でも見ることができる
  • 同じIDのユーザしか更新、追加出来ない

とし、

チャット情報は

  • 誰でも見ることができる
  • 認証されたユーザのみ投稿できる

という設定にします。

まずはユーザプロフィールのポリシーを設定しましょう

ユーザプロフィールテーブルのポリシー設定

Table Editorprofilesの右のボタンをクリックし、View Policiesを押しましょう。

表示された画面でNew policyボタンを押します。
For Full Customizationをクリックし、それぞれ下記の設定でポリシーを作ってください。

Selectの設定

anonauthenticatedもアクセス可能にすることで誰でも見ることができる状態にします。

Insertの設定

追加は認証ユーザかつユーザIDがログインしたユーザと同じ(自分)しか出来ないようにします。

Updateの設定

更新も認証ユーザかつユーザIDがログインしたユーザと同じ(自分)しか出来ないようにします。

チャット情報テーブルのポリシー設定

同じようにChatsでユーザのポリシーにアクセスします。

表示された画面でNew policyボタンを押します。
For Full Customizationをクリックし、それぞれ下記の設定でポリシーを作ってください。

Selectの設定

プロフィールと同じく誰でもアクセス可能にします。

Insertの設定

認証ユーザのみが投稿できるようにします。

これでポリシーの設定は完了です。

アプリの確認

実際に挙動を確認してみます。

npm run dev

してlocalhostにアクセスすると、下記のような画面が現れます。

まずはLoginからuser1@example.comでログインしてみましょう。
ログインすると下記のような画面が現れます。

ログインは終わったのでユーザ名なしで投稿できるか確認してみましょう。
Homeに戻りthread 1にアクセスします。

入力して投稿しようとするとユーザ名がない旨のアラートが表示され、投稿出来ません。

しっかりユーザ名を入れないと、投稿出来ない設定が出来ているようです。

ではProfileに戻ってユーザ名を入力します。

この状態で再度投稿をしてみると投稿が成功し、ユーザ1の投稿が追加されました。

表示されているIDにも注目するとランダムな値ではなくAuthentication画面で作成したユーザのUUIDが表示されていることがわかります。

この状態でログアウトして再度thread1を開いてみましょう。
先程と同じくユーザ1の投稿が表示されています。

この状態で投稿できるか確認してみると、ログインしていない旨が表示され投稿出来ないことが確認できます。

『認証したユーザのみが投稿できるチャットアプリ』ができていることを確認できました。
気になる方は更にuser1とuser2とログアウト状態の3つを別ウインドウで開き、リアルタイム性を確認しつつそれぞれの挙動を見てみるとよりわかりやすいと思います。

その他参考資料・Supabase開発記事のご紹介

今回ご紹介したコードのgithubはこちらとなります。
https://github.com/TodoONada/nextjs-supabase-auth-and-realtime

またTodoONada株式会社では、Supabase等を利用したアプリや、認証機能を組み合わる方法等についてご紹介しています。
今回のチャットと組み合わせることで、高性能なウェブサービスを作ることも可能です。
ぜひこちらの記事もご確認ください。

お問合せ&各種リンク

presented by

技術ブログ一覧へ戻る