1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

railsAPI+Next.jsでのdevice_token_authを使ったログイン機能作成の備忘録

Posted at

初の個人開発でのポートフォリオ作成でログイン機能作成時に結構詰まったので記事にします。
注意:初めての個人開発なので間違ってたり効率的でない部分も多々あると思いますので、ふーんこういうのもあるんだくらいで見ていただけると幸いです。

バージョン
Next.js  14.1.4
RailsAPI 7.0.8

参考記事

上記の記事を参考にしてログイン機能を作成しました。
上記の記事とまったく一緒の部分は割愛します。

RailsAPI

app/models/user.rb:confirmableを設定することで、メール認証が可能になります。(自分の環境だとletter_openerを設定してもメールが確認できなかったので外してます。)
app/models/user.rb以下のように逆転して記載するとエラーが起きるという記事を見たので注意して下さい。

app/models/user.rb
class User < ApplicationRecord
            # Include default devise modules.
            include DeviseTokenAuth::Concerns::User
            devise :database_authenticatable, :registerable,
                    :recoverable, :rememberable, :validatable, :omniauthable
end

config/initializers/devise_token_auth.rb:'authorization' => 'authorization'を記載しないとNoMethodError: undefined method "downcase" for nil:NilClassというエラーが発生するので注意して下さい。
app/config/routes.rbskip: [:omniauth_callbacks],を追加しないとGETリクエストでomniauth/sessionsにリダイレクトして404(not found)になってしまいます。(原因不明)

app/config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for 'User', at: 'auth', skip: [:omniauth_callbacks],controllers: {
        registrations: 'api/v1/auth/registrations'
      }

      namespace :auth do
        resources :sessions, only: [:index]
      end
    end
  end

  mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development?
end
app/models/user.rb
class User < ApplicationRecord
            # Include default devise modules.
            devise :database_authenticatable, :registerable,
                    :recoverable, :rememberable, :validatable, :omniauthable
            include DeviseTokenAuth::Concerns::User
end

config/initializers/devise_token_auth.rb
# ...省略...

# ヘッダー名の設定
  config.headers_names = {
    :'access-token' => 'access-token',
    :'client' => 'client',
    :'expiry' => 'expiry',
    :'uid' => 'uid',
    :'token-type' => 'token-type',
    :'authorization' => 'authorization'
  }

# ...省略...

Next.js

App Routerを使用しています。
「ビューを作成」の部分もほとんど一緒ですが、一部React→Next.jsでの変更されている箇所があります。
・importがimport {} from "react-router-dom"からimport {} from "next/navigation"に変更します。
import { useRouter } from "next/navigation"を使用する場合"use client"がないとエラーが起きます。
app/layout.tsxでの<html><body>タグ有無。(これがないとコンソールでエラーになります(3日間くらいこれに苦しめられた。。。))
AuthContextの設定の際に初期値を決めないと、別のコンポーネントでimportした時にis not a functionというエラーが発生します。(コンソールログで確認したところ{}と表示されたのでそもそも認識されてないっぽい)
<AuthProvider><Header />の順番にしないとHeaderの表示が切り替わらず、ログインしていなくてもログアウトボタンが表示されてしまいます。

(現在ログイン機能のみ作っているのでサインアップはあとで追記)

app/layout.tsx
import React from "react";
import "@/globals.css";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>{children}</body>
    </html>
  );
}
app/components/header/header.tsx
"use client";
import React, { useContext } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Cookies from "js-cookie";

import { signOut } from "@/lib/api/auth";
import { AuthContext } from "@/context/AuthContext";

export const Header: React.FC = () => {
  const { loading, isSignedIn, setIsSignedIn } = useContext(AuthContext);
  const router = useRouter();

  const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => {
    try {
      const res = await signOut();

      if (res.data.success === true) {
        // サインアウト時には各Cookieを削除
        Cookies.remove("_access_token");
        Cookies.remove("_client");
        Cookies.remove("_uid");

        setIsSignedIn(false);
        router.push("/");

        console.log("Succeeded in sign out");
      } else {
        console.log("Failed in sign out");
      }
    } catch (err) {
      console.log(err);
    }
  };

  const AuthButtons = () => {
    // 認証完了後はサインアウト用のボタンを表示
    // 未認証時は認証用のボタンを表示
    if (!loading) {
      if (isSignedIn) {
        console.log(isSignedIn);
        return (
          <>
          <ul className="flex ml-auto ml-64">
            <li className="">
              <Link href="/">アカウント設定</Link>
            </li>
            <li className="mx-10">
              <button onClick={handleSignOut}>ログアウト</button>
            </li>
          </ul>
        </>
      )} else {
        console.log(isSignedIn);
        return (
          <>
            <ul className="flex ml-auto ml-64">
              <li className="">
                <Link href="/login">ログイン</Link>
              </li>
              <li className="mx-10">
                <Link href="/signup">サインアップ</Link>
              </li>
            </ul>
          </>
        );
      }
    } else {
      return <></>;
    }
  };

  return (
    <header>
      <div className="py-6 flex">
        <div className="mx-10">
          <Link href="/top" className="">
            タスマネ
          </Link>
        </div>
        <ul className="ml-24 grid grid-cols-5 gap-24">
          <li className="">
            <Link href="/mypage">MYページ</Link>
          </li>
          <li className="">
            <Link href="/">お金管理</Link>
          </li>
          <li className="">
            <Link href="/">目標管理</Link>
          </li>
          <li className="">
            <Link href="/">タスク管理</Link>
          </li>
          <li className="">
            <Link href="/">タイマー</Link>
          </li>
        </ul>
        <AuthButtons />
      </div>
    </header>
  );
};

ログイン前にも閲覧できるページ

app/(auth)/layout.tsx
import React from "react";
import "@/globals.css";

import { Header } from "@/components/header/header";
import { Footer } from "@/components/footer/footer";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      <Header />
      <div className="bg-gray-100">{children}</div>
      <Footer />
    </>
  );
}
app/(auth)/login/page.tsx
import React from "react";
import { LoginForm } from "@/components/login_form/login_form";

const Login: React.FC = () => {
  return (
    <>
      <LoginForm />
    </>
  );
};

export default Login;

app/(auth)/sign_up/page.tsx
import React from "react";
import { SignUpForm } from "@/components/signup_form/signup_form";

const SignUp: React.FC = () => {
  return (
    <>
      <SignUpForm />
    </>
  );
};

export default SignUp;

ログイン後に閲覧できるページ

app/(authenticated)/layout.tsx
import React from "react";
import "@/globals.css";

import { Header } from "@/components/header/header";
import { Footer } from "@/components/footer/footer";
import { AuthProvider } from "@/context/AuthContext";

export default function AuthenticatedLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <>
      //<AuthProvider>でログイン規制 
      <AuthProvider>
        <Header />
        <div className="bg-gray-100">{children}</div>
        <Footer />
      </AuthProvider>
    </>
  );
}
app/(authenticated)/mypage/page.tsx
import React from "react";

const Mypage: React.FC = () => {
  return (
    <div>
      <p>マイページ</p>
    </div>
  );
};

export default Mypage;

その他

app/context/AuthContext.tsx
"use client";
import React, { createContext, useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";

import { getCurrentUser } from "@/lib/api/auth";
import { User } from "@/types/auth_interface";

export const AuthContext = createContext<{
  loading: boolean;
  setLoading: React.Dispatch<React.SetStateAction<boolean>>;
  isSignedIn: boolean;
  setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>>;
  currentUser: User | undefined;
  setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>>;
}>({
  loading: false,
  setLoading: () => {},
  isSignedIn: false,
  setIsSignedIn: () => {}, // ダミー関数の提供
  currentUser: undefined,
  setCurrentUser: () => {},
});

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const [loading, setLoading] = useState<boolean>(true);
  const [isSignedIn, setIsSignedIn] = useState<boolean>(false);
  const [currentUser, setCurrentUser] = useState<User | undefined>();
  const router = useRouter();
  const pathname = usePathname();

  const handleGetCurrentUser = async () => {
    try {
      const res = await getCurrentUser();

      if (res?.data.isLogin === true) {
        setIsSignedIn(true);
        setCurrentUser(res?.data.data);

        console.log(res?.data.data);
      } else {
        console.log("No current user");
      }
    } catch (err) {
      console.log(err);
    }

    setLoading(false);
  };

  useEffect(() => {
    handleGetCurrentUser();
  }, [setCurrentUser]);

  useEffect(() => {
    // 未認証の場合はsigninページへリダイレクト
    if (!loading && !isSignedIn && pathname !== "/signin") {
      router.push("/login");
    }
  }, [loading, isSignedIn, pathname]);

  return (
    <AuthContext.Provider
      value={{
        loading,
        setLoading,
        isSignedIn,
        setIsSignedIn,
        currentUser,
        setCurrentUser,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};
app/components/login_form/login_form.tsx
"use client";
import React, { useState, useContext } from "react";
import Cookies from "js-cookie";
import { useRouter } from "next/navigation";

import { SignInParams } from "@/types/auth_interface";

import { AuthContext } from "@/context/AuthContext";
import { signIn } from "@/lib/api/auth";
import { AlertMessage } from "@/components/alertmessage/AlertMessage";

export const LoginForm: React.FC = () => {
  const router = useRouter(); // Next.jsのルーターを利用

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext);
  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const params: SignInParams = {
      email: email,
      password: password,
    };

    try {
      const res = await signIn(params);
      console.log(res);

      if (res.status === 200) {
        Cookies.set("_access_token", res.headers["access-token"]);
        Cookies.set("_client", res.headers["client"]);
        Cookies.set("_uid", res.headers["uid"]);

        setIsSignedIn(true);
        setCurrentUser(res.data.data);

        router.push("/mypage"); // Next.jsのルーティングでリダイレクト

        console.log("Signed in successfully!");
      } else {
        setAlertMessageOpen(true);
      }
    } catch (err) {
      console.log(err);
      setAlertMessageOpen(true);
    }
  };

  return (
    <div className="mx-auto md:w-2/3 w-full px-10 pt-28 pb-16">
      <p className="text-4xl font-bold text-center">ログイン</p>
      <form onSubmit={handleSubmit} className="mb-0">
        <div className="mt-16">
          <label htmlFor="email" className="text-2xl">
            メールアドレス
          </label>
          <input
            type="email"
            id="email"
            placeholder="test@example.com"
            className="w-full my-5 py-3"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
            autoComplete="current-password"
          />
        </div>
        <div>
          <label htmlFor="password" className="text-2xl">
            パスワード
          </label>
          <input
            type="password"
            id="password"
            placeholder="password"
            className="w-full my-5 py-3"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            autoComplete="current-password"
          />
        </div>
        <div className="py-6 pb-24">
          <button
            type="submit"
            className="font-bold text-xl bg-blue-500 px-3 rounded-full text-white"
          >
            ログイン
          </button>
        </div>
      </form>
      <AlertMessage // エラーが発生した場合はアラートを表示
        open={alertMessageOpen}
        setOpen={setAlertMessageOpen}
        severity="error"
        message="Invalid emai or password"
      />
    </div>
  );
};

app/components/signup_form/signup_form.tsx
"use client";
import React, { useState, useContext } from "react";
import Cookies from "js-cookie";
import { useRouter } from "next/navigation";

import { AuthContext } from "@/context/AuthContext";
import { signUp } from "@/lib/api/auth";
import { AlertMessage } from "@/components/alertmessage/AlertMessage";
import { SignUpParams } from "@/types/auth_interface";

export const SignUpForm: React.FC = () => {
  const router = useRouter();

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext);

  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const [passwordConfirmation, setPasswordConfirmation] = useState<string>("");
  const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false);

  const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();

    const params: SignUpParams = {
      email: email,
      password: password,
      passwordConfirmation: passwordConfirmation,
    };

    try {
      const res = await signUp(params);
      console.log(res);

      if (res.status === 200) {
        // アカウント作成と同時にログインさせてしまう
        // 本来であればメール確認などを挟むべきだが、今回はサンプルなので
        Cookies.set("_access_token", res.headers["access-token"]);
        Cookies.set("_client", res.headers["client"]);
        Cookies.set("_uid", res.headers["uid"]);

        setIsSignedIn(true);
        setCurrentUser(res.data.data);

        router.push("/mypage");

        console.log("Signed in successfully!");
      } else {
        setAlertMessageOpen(true);
      }
    } catch (err) {
      console.log(err);
      setAlertMessageOpen(true);
    }
  };

  return (
    <div className="mx-auto md:w-2/3 w-full px-10 pt-24 pb-16">
      <p className="text-4xl font-bold text-center">ログイン</p>
      <form onSubmit={handleSubmit} className="mb-0">
        <div className="mt-16">
          <label htmlFor="email" className="text-2xl">
            メールアドレス
          </label>
          <input
            type="email"
            id="email"
            placeholder="test@example.com"
            className="w-full my-5 py-3"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
          />
        </div>
        <div>
          <label htmlFor="password" className="text-2xl">
            パスワード
          </label>
          <input
            type="password"
            id="password"
            placeholder="password"
            className="w-full my-5 py-3"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            autoComplete="current-password"
          />
        </div>
        <div>
          <label htmlFor="password" className="text-2xl">
            パスワード確認
          </label>
          <input
            type="password"
            id="password"
            placeholder="password"
            className="w-full my-5 py-3"
            value={passwordConfirmation}
            onChange={(e) => setPasswordConfirmation(e.target.value)}
            required
            autoComplete="current-password"
          />
        </div>
        <div className="py-6 pb-20">
          <button
            type="submit"
            className="font-bold text-xl bg-blue-500 px-3 rounded-full text-white"
          >
            ログイン
          </button>
        </div>
      </form>
      <AlertMessage // エラーが発生した場合はアラートを表示
        open={alertMessageOpen}
        setOpen={setAlertMessageOpen}
        severity="error"
        message="Invalid emai or password"
      />
    </div>
  );
};

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?