Supabaseで匿名認証とアカウントの本登録への昇格の方法

目次

はじめに

Supabaseには通常の認証機能に加えて匿名認証機能があります。

匿名認証はソーシャルゲームやECショップなどでよく使われており、登録せずにゲストアカウントで利用したうえで、必要になったタイミングで本アカウント登録をするようなユーザにとってストレスのない利用フローを作成することができます。

今回はNext.js x Supabaseのテンプレートを匿名認証向けに修正して、実際に試してみましょう。

Supabaseのプロジェクト準備

Supabaseダッシュボードからプロジェクトを作成しましょう。

(具体的な流れは他の記事でも紹介しているため割愛します。)

現在、作成したダッシュボードに入ると下記の警告が表示されると思います。

内容を見てもらうとわかる通り、プロジェクトメンバーのemailのみに制限されている状況な上、メール送信数の制限も1時間に二通と厳しいため、必要に応じてresendなどでsmtpサーバ作成しましょう。

連携方法は下記のurlを見てもらえればわかると思います。
https://resend.com/docs/send-with-supabase-smtp

※上記のsmtpサーバ設定を行ってもSupabase内のメール送信数制限自体は変わっていないため、自分で設定する必要があります。

適当な数に変更しておきましょう。

サンプルテーブル作成

下記のような匿名ユーザには見えないUIを作成したいため、データベースとRLSを設定しましょう。

サンプルテーブルの設定は下記のような形になります。

RLSのポリシーはSELECTで添付画像のような形で作成してください。

適当なデータを入れておきましょう。

匿名ログイン用の設定

匿名ログインができるようにProject SettingsAuthentication項目を確認し、Allow anonymous sign-insのチェックをONにしましょう。

ここまででSupabase側の設定は以上です。

Next.js側の設定

下記コマンドを実行してsupabase向けに設定されたNext.jsプロジェクトを作成しましょう。

npx create-next-app -e with-supabase

envファイル設定

Next.jsのプロジェクト直下にある.env.example.env.localに名前を変更しましょう。
その後、SupabaseのDashboardのProject SettingsからAPI項目を確認し、環境変数を設定しましょう。

NEXT_PUBLIC_SUPABASE_URL=${URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${anon key}

TypeScript用Supabaseのテーブルの型生成

Supabaseのテーブル情報を楽に扱うために型生成を行います。
下記のコマンドを実行してください。

npx supabase gen types typescript --project-id ${Project ID} --schema public > utils/types/supabasetype.ts

不要なファイル削除

次はテンプレート内のファイルを編集していきます。
まず不要なファイルを削除します。

削除対象は下記のファイルです。

  • components/deploy-button.tsx
  • components/next-logo.tsx
  • components/supabase-logo.tsx
  • components/theme-switcher.tsx
  • components/tutorial/code-block.tsx
  • components/tutorial/connect-supabase-steps.tsx
  • components/tutorial/fetch-data-steps.tsx
  • components/tutorial/sign-up-user-steps.tsx
  • components/tutorial/tutorial-step.tsx
  • components/typography/inline-code.tsx
  • components/ui/checkbox.tsx
  • components/ui/dropdown-menu.tsx

ファイルの編集、新規追加

編集、新規追加するファイルについてそれぞれ説明します。

app/actions.ts

匿名ログイン含め、認証関係のactionをファイル内に追加します。

~~~ 既存のコード ~~~
export const signInAnonymouslyAction = async () => {
  const supabase = createClient();
  const { data, error } = await supabase.auth.signInAnonymously()
  if (error) {
    return { error: error.message };
  }
  return redirect("/protected");
};
export const convertAnonymousAccountAction = async (formData: FormData) => {
  const supabase = createClient();
  
  // ユーザーが匿名かどうかを確認
  const { data: { user } } = await supabase.auth.getUser();
  
  if (!user || user.is_anonymous !== true) {
    return encodedRedirect("error", "/convert-account", "匿名ユーザーのみがアカウントを更新できます");
  }
  const email = formData.get("email") as string;
  const { data, error } = await supabase.auth.updateUser({
    email: email,
  });
  if (error) {
    return encodedRedirect("error", "/convert-account", error.message);
  }
  return encodedRedirect("success", "/convert-account", "メールアドレスに送信された確認メールをクリックして、アカウントを更新してください。");
};

app/convert-account/page.tsx

本アカウント登録のためのページを作成します。

import { convertAnonymousAccountAction } from "@/app/actions";
import { FormMessage } from "@/components/form-message";
import { SubmitButton } from "@/components/submit-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Message } from "@/components/form-message";
export default function ConvertAccount({ searchParams }: { searchParams: Message }) {
  return (
    <form className="flex flex-col w-full max-w-md p-4 gap-2 [&>input]:mb-4">
      <h1 className="text-2xl font-medium">アカウントを本登録する</h1>
      <p className="text-sm text-foreground/60">
        メールアドレスを入力して、匿名アカウントを本登録アカウントに変更してください。
      </p>
      <Label htmlFor="email">メールアドレス</Label>
      <Input
        type="email"
        name="email"
        placeholder="you@example.com"
        required
      />
      <SubmitButton formAction={convertAnonymousAccountAction}>
        アカウントを更新
      </SubmitButton>
      <FormMessage message={searchParams} />
    </form>
  );
}

app/convert-account/set-password/page.tsx

本アカウント登録後のパスワード設定画面を作成します。

import { resetPasswordAction } from "@/app/actions";
import { FormMessage } from "@/components/form-message";
import { SubmitButton } from "@/components/submit-button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export default function SetPassword({ searchParams }: { searchParams: { message?: string } }) {
  return (
    <form className="flex flex-col w-full max-w-md p-4 gap-2 [&>input]:mb-4">
      <h1 className="text-2xl font-medium">パスワードを設定してください</h1>
      <p className="text-sm text-foreground/60">
        新しいパスワードを設定してください。
      </p>
      <Label htmlFor="password">新しいパスワード</Label>
      <Input
        type="password"
        name="password"
        placeholder="新しいパスワード"
        required
      />
      <Label htmlFor="confirmPassword">パスワードの確認</Label>
      <Input
        type="password"
        name="confirmPassword"
        placeholder="パスワードの確認"
        required
      />
      <SubmitButton formAction={resetPasswordAction}>
        パスワードを設定
      </SubmitButton>
      {searchParams.message && <FormMessage message={{ error: searchParams.message }} />}
    </form>
  );
}

app/layout.tsx

トップページのレイアウトを修正します。

import { EnvVarWarning } from "@/components/env-var-warning";
import HeaderAuth from "@/components/header-auth";
import { hasEnvVars } from "@/utils/supabase/check-env-vars";
import Link from "next/link";
import "./globals.css";

const defaultUrl = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : "http://localhost:3000";

export const metadata = {
  metadataBase: new URL(defaultUrl),
  title: "Next.js and Supabase Starter Kit",
  description: "The fastest way to build apps with Next.js and Supabase",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className="bg-background text-foreground">
        <main className="min-h-screen flex flex-col items-center">
          <div className="flex-1 w-full flex flex-col gap-20 items-center">
            <nav className="w-full flex justify-center border-b border-b-foreground/10 h-16">
              <div className="w-full max-w-5xl flex justify-between items-center p-3 px-5 text-sm">
                <div className="flex gap-5 items-center font-semibold">
                  <Link href={"/"}>匿名認証テスト</Link>
                </div>
                {!hasEnvVars ? <EnvVarWarning /> : <HeaderAuth />}
              </div>
            </nav>
            <div className="flex flex-col gap-20 max-w-5xl p-5">
              {children}
            </div>
          </div>
        </main>
      </body>
    </html>
  );
}

app/page.tsx

アプリのトップページです。
邪魔な記述の削除と匿名アカウント登録ボタンを置いています。

import Hero from "@/components/hero";
import { signInAnonymouslyAction } from "./actions";
import { SubmitButton } from "@/components/submit-button";
import { createClient } from "@/utils/supabase/client";

export default async function Index() {
  const supabase = createClient();
  // ユーザーが匿名かどうかを確認
  const { data: { user } } = await supabase.auth.getUser();

  return (
    <>
      <Hero />
      <div className="flex justify-center mt-8">
        <form>
          <SubmitButton formAction={signInAnonymouslyAction}>匿名でアカウント登録</SubmitButton>
        </form>
      </div>
    </>
  );
}

app/protected/page.tsx

認証後にのみアクセスできるページです(匿名認証でもアクセス可能ですが、匿名認証の場合はsample-tableの内容は見えません)

import { createClient } from "@/utils/supabase/server";
import { InfoIcon } from "lucide-react";
import { redirect } from "next/navigation";
import SampleTable from "@/components/sample-table/sample-table";
import Link from "next/link";

export default async function ProtectedPage() {
  const supabase = createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return redirect("/sign-in");
  }

  const isAnonymous = user.is_anonymous

  return (
    <div className="flex-1 w-full flex flex-col gap-12">
      {isAnonymous && (
        <div className="flex justify-center">
          <Link href="/convert-account" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
          本アカウント登録
          </Link>
        </div>
      )}
      {!isAnonymous && (
        <div className="flex justify-center">
        <Link href="/convert-account/set-password" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
        パスワードを設定/更新する
        </Link>
      </div>
      )}
        <div className="w-full">
          <div className="bg-accent text-sm p-3 px-5 rounded-md text-foreground flex gap-3 items-center">
          <InfoIcon size="16" strokeWidth={2} />
          This is a protected page that you can only see as an authenticated
          user
        </div>
      </div>
      <div className="flex flex-col gap-2 items-start">
        <h2 className="font-bold text-2xl mb-4">Your user details</h2>
        <pre className="text-xs font-mono p-3 rounded border max-h-32 overflow-auto">
          {JSON.stringify(user, null, 2)}
        </pre>
      </div>
      <SampleTable />
    </div>
  );
}

components/header-auth.tsx

ヘッダーの認証用ボタンなどです。
(sign upは一旦いらないので削除しています)

import { signOutAction } from "@/app/actions";
import { hasEnvVars } from "@/utils/supabase/check-env-vars";
import Link from "next/link";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { createClient } from "@/utils/supabase/server";

export default async function AuthButton() {
  const {
    data: { user },
  } = await createClient().auth.getUser();

  if (!hasEnvVars) {
    return (
      <>
        <div className="flex gap-4 items-center">
          <div>
            <Badge
              variant={"default"}
              className="font-normal pointer-events-none"
            >
              Please update .env.local file with anon key and url
            </Badge>
          </div>
          <div className="flex gap-2">
            <Button
              asChild
              size="sm"
              variant={"outline"}
              disabled
              className="opacity-75 cursor-none pointer-events-none"
            >
              <Link href="/sign-in">Sign in</Link>
            </Button>
            <Button
              asChild
              size="sm"
              variant={"default"}
              disabled
              className="opacity-75 cursor-none pointer-events-none"
            >
              <Link href="/sign-up">Sign up</Link>
            </Button>
          </div>
        </div>
      </>
    );
  }
  return user ? (
    <div className="flex items-center gap-4">
      Hey, {user.email}!
      <form action={signOutAction}>
        <Button type="submit" variant={"outline"}>
          Sign out
        </Button>
      </form>
    </div>
  ) : (
    <div className="flex gap-2">
      <Button asChild size="sm" variant={"outline"}>
        <Link href="/sign-in">Sign in</Link>
      </Button>
    </div>
  );
}

components/hero.tsx

タイトルのみに変更しています。

export default function Header() {
  return (
    <div className="flex flex-col gap-16 items-center">
      <h1 className="text-4xl font-bold">匿名認証テスト</h1>
    </div>
  );
}

components/sample-table/sample-table.tsx

sample-tableのfetchのためのコンポーネントです。

"use client";

import React, { useState, useEffect } from 'react';
import { createClient } from '@/utils/supabase/client';
import { Database } from '@/utils/types/supabasetype';
import { log } from 'console';
// Supabaseクライアントの設定
const supabase = createClient();

const SampleTable: React.FC = () => {
  const [samples, setSamples] = useState<Database['public']['Tables']['sample-table']['Row'][]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchSamples = async () => {
      try {
        const { data, error } = await supabase
          .from('sample-table')
          .select('*');

        if (error) throw error;

        setSamples(data);
        setLoading(false);

        console.log(data)
      } catch (err) {
        setError('データの取得中にエラーが発生しました');
        setLoading(false);
      }
    };

    fetchSamples();
  }, []);

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>{error}</div>;

  return (
    <table>
      <thead>
        <tr>
          <th>ID</th>
          <th>作成日</th>
          <th>テキスト</th>
        </tr>
      </thead>
      <tbody>
        {samples.map((sample) => (
          <tr key={sample.id}>
            <td>{sample.id}</td>
            <td>{sample.created_at}</td>
            <td>{sample.text}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
};

export default SampleTable;

utils/supabase/client.ts

supabaseのclient側です。型生成した情報をこちらで利用しています。

import { createBrowserClient } from "@supabase/ssr";
import { Database } from "@/utils/types/supabasetype";

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

utils/supabase/server.ts

clientと同様に型を流し込んでいます。

import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { Database } from "@/utils/types/supabasetype";

export const createClient = () => {
  const cookieStore = cookies();

  return createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) => {
              cookieStore.set(name, value, options);
            });
          } catch (error) {
            // The `set` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    },
  );
};

ここまででプロジェクトの設定は以上になります。
ここからは実際にプロジェクトを起動して確認しましょう。

プロジェクトを実際に起動して確認

プロジェクト直下で下記のコマンドを実行し、開発環境のサーバを立ち上げましょう。

npm run dev

起動時の画面は下記のようになっています。

まずは「匿名アカウント登録」ボタンを押して、匿名認証を完了しましょう。
クリックして少しすると/protectedに遷移します。
このページは認証済みのアカウントでないと見れないため、匿名認証が完了したことがわかります。

また、ページ内のUser Detailを見るとroleがauthenticatedis_anonymousがtrueになっていることが確認できます。

では、ここから本アカウント登録をして匿名アカウントを昇格させましょう。
「本アカウント登録」ボタンを押してください。

すると、下記のページに遷移します。
ここからメールアドレスを入力してアカウントとメールアドレスをリンクさせましょう。

アカウント更新ボタンを押すと、メールが送られた旨が表示されます。

Email変更確認メールが届くので、リンクをクリックします。

すると/protectedページにまた戻ってきますが、メールアドレスが設定され右上やUser Detailの中に反映されていることがわかります。
※まだ本アカウント登録は終わっていません。

ここからさらにパスワードを設定する必要があります。

「パスワードを設定/更新する」ボタンを押してパスワード設定画面に遷移しましょう。

パスワード入力が終わったら「パスワードを設定」ボタンを押しましょう。

すると、サインアウトしてsign-inページに遷移します。

先ほど設定したログイン情報を利用してログインしましょう。

本アカウント登録が完了し、認証もできたことでsample-tableの内容が表示されるようになりました!

これで実装が確認できました!

参考資料

作成したソースコード:https://github.com/TodoONada/supabase-nextjs-anonymous

公式ドキュメント:https://supabase.com/docs/guides/auth/auth-anonymous

SupabaseのTIPSリンク集
  • システム開発、アプリ開発
  • マッチングアプリ開発
  • インフラ構築支援等、なんでもご相談ください。
よかったらシェアしてね!
  • URLをコピーしました!
目次