初の個人開発でのポートフォリオ作成でログイン機能作成時に結構詰まったので記事にします。
注意:初めての個人開発なので間違ってたり効率的でない部分も多々あると思いますので、ふーんこういうのもあるんだくらいで見ていただけると幸いです。
バージョン
Next.js 14.1.4
RailsAPI 7.0.8
参考記事
上記の記事を参考にしてログイン機能を作成しました。
上記の記事とまったく一緒の部分は割愛します。
RailsAPI
・app/models/user.rb
に:confirmable
を設定することで、メール認証が可能になります。(自分の環境だとletter_openerを設定してもメールが確認できなかったので外してます。)
・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.rb
にskip: [:omniauth_callbacks],
を追加しないとGETリクエストでomniauth/sessions
にリダイレクトして404(not found)になってしまいます。(原因不明)
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
class User < ApplicationRecord
# Include default devise modules.
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :omniauthable
include DeviseTokenAuth::Concerns::User
end
# ...省略...
# ヘッダー名の設定
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の表示が切り替わらず、ログインしていなくてもログアウトボタンが表示されてしまいます。
(現在ログイン機能のみ作っているのでサインアップはあとで追記)
import React from "react";
import "@/globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>{children}</body>
</html>
);
}
"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>
);
};
ログイン前にも閲覧できるページ
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 />
</>
);
}
import React from "react";
import { LoginForm } from "@/components/login_form/login_form";
const Login: React.FC = () => {
return (
<>
<LoginForm />
</>
);
};
export default Login;
import React from "react";
import { SignUpForm } from "@/components/signup_form/signup_form";
const SignUp: React.FC = () => {
return (
<>
<SignUpForm />
</>
);
};
export default SignUp;
ログイン後に閲覧できるページ
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>
</>
);
}
import React from "react";
const Mypage: React.FC = () => {
return (
<div>
<p>マイページ</p>
</div>
);
};
export default Mypage;
その他
"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>
);
};
"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>
);
};
"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>
);
};