はじめに
Next.jsにチャレンジしていく過程をメモ/備忘録として記録していきます。
同じようにこれから始める方の参考となればと思います。
やりたいこと
Next.jsで学習用としてTODOアプリを作ってみます。
バックエンドはDjangoにて作成しAPIでデータ取得としますが、その部分は別の記事にしていきたいと思います。
今回は下記の続きをpart2としてやっていきます。
データを取得するバックエンド側のAPIをトークンを使ってリクエストを行うものにしたので、
アプリの中身はまだ何もないですが、ログイン機能から実装したいと思います。
(ユーザー作成は現時点では考えずにやっていきます)
環境
下記のDocker開発環境にて行います。
トークンについて
下記のdjangorestframework-simplejwtを使っており、emailとpasswordでアクセストークンとリフレッシュトークンを取得します。
下記リクエストを送ると、アクセストークンとリフレッシュトークンが返ってきます。
リクエスト
・URL:/auth/token
・body:email,password
レスポンス
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTcxODYzNDQ0MSwiaWF0IjoxNzE4MDI5NjQxLCJqdGkiOiJkMDRjNDBiY2Y1NzM0ZDUyOTEzNmI4MmYzZTQ4YjZlZCIsInVzZXJfaWQiOiIwMUhTRTFBR0tHUktQUlk0NjhFRllGTlBUNiJ9.piSGgWpO58_b53c9mqyHhjUM4V73dAca6gzV8X_oH6I",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzE4MDM2ODQxLCJpYXQiOjE3MTgwMjk2NDEsImp0aSI6ImMyZTBlOTA0YTU4ZjQ1MWFhYmM4MWE3OTk2MGZjODQ3IiwidXNlcl9pZCI6IjAxSFNFMUFHS0dSS1BSWTQ2OEVGWUZOUFQ2In0.0mjWYzL9tBZoOCNZ8lWci6EMapuKmC9g9OmdP5bluXw"
}
ログイン機能
処理について
下記のような流れを考えてみました。
・ログイン処理としてアクセストークン/リフレッシュトークンをcookieへ保存
・トップページ、ログインページ以外はミドルウェアでアクセストークンを保持しているかチェック
・保持していなければログイン画面へリダイレクト
・ログイン画面ではリフレッシュトークンを保持していたら、ワンクリックで再ログインをする
ディレクトリ、ファイル準備
今後、機能部分は切り離したいため、featuresディレクトリを作成。
ログインもフォーム部分は別ファイルへ。
(ロジックが含まれるコンポーネントはfeaturesというイメージで分けてます)
cookieとfetchも共通部分を切り出しするためにutilディレクトリを作成。
ここは特定の機能というよりは、全体でよく使うものを格納するイメージで分けています。
.
├── app
│ ├── login
│ │ └── page.tsx
...
├── features
│ └── auth
│ ├── login
│ │ ├── Form.tsx
│ │ └── actions.ts
│ └── refresh-login
│ ├── Modal.tsx
│ └── actions.ts
├── util
│ ├── cookies
│ │ ├── next-path.ts
│ │ └── token.ts
│ └── fetch
│ ├── error-message.ts
│ └── post.ts
├── middleware.ts
...
util/cookiesのファイル
まずは、cookieを扱っていくので、設定値を切り出し。
何ヶ所か同じことを書くので共通化と、後で変更しやすくするためです。
setするための設定値をgetするという名前がどうもややこしいですが、他に思い浮かばず・・・
import { cookies } from "next/headers";
// ========== access token ==========
export function getTokenSetProps(value: string) {
return {
name: "token",
value: btoa(String(value)),
maxAge: Number(process.env.TOKEN_MAX_AGE),
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
};
}
export function getTokenRemoveProps() {
return {
name: "token",
value: "",
maxAge: 0,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
};
}
export function setToken(value: string) {
cookies().set(getTokenSetProps(value));
}
export function removeToken() {
cookies().set(getTokenRemoveProps());
}
export function getToken() {
const token = cookies().get('token')?.value;
if(token) return atob(String(token));
}
// ========== refresh token ==========
export function getRefreshTokenSetProps(value: string) {
return {
name: "refreshToken",
value: btoa(String(value)),
maxAge: Number(process.env.REFRESH_TOKEN_MAX_AGE),
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
};
}
export function getRefreshTokenRemoveProps() {
return {
name: "refreshToken",
value: "",
maxAge: 0,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
};
}
export function setRefreshToken(value: string) {
cookies().set(getRefreshTokenSetProps(value));
}
export function removeRefreshToken() {
cookies().set(getRefreshTokenRemoveProps());
}
export function getRefreshToken() {
const refreshToken = cookies().get('refreshToken')?.value;
if(refreshToken) return atob(String(refreshToken));
}
ログイン後に表示するパスを保存するため、nextPathというものも作成。
import { cookies } from "next/headers";
export function getNextPathSetProps(value: string) {
return {
name: "nextPath",
value: value,
};
}
export function getNextPathRemoveProps() {
return {
name: "nextPath",
value: "",
maxAge: 0,
};
}
export function setNextPath(value: string) {
cookies().set(getNextPathSetProps(value));
}
export function removeNextPath() {
cookies().set(getNextPathRemoveProps());
}
export function getNextPath() {
return cookies().get('nextPath')?.value;
}
util/fetchのファイル
続いて、色んなところでfetchを使うので、共通化出来るところを切り出し。
バックエンドのURLもクライアントサイドから使われたくないため、サーバーコンポーネントからのみ使われるようにserver-only
パッケージを利用します。
npm install server-only
import 'server-only';
import { getToken } from '../cookies/token';
type Props = {
url: string,
hasToken?: boolean,
params?: object,
customErrorMessage?: { [key: number]: string },
};
type FetchProps = {
method: string,
headers: {
'Content-Type': string,
'Authorization'?: string,
},
body?: string,
};
export default async function fetchPost({ url, hasToken = false, params = undefined, customErrorMessage = {} }: Props) {
let fetchProps: FetchProps = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
};
if (hasToken) {
fetchProps.headers['Authorization'] = `Bearer ${getToken()}`;
};
if (params) {
fetchProps.body = JSON.stringify(params);
};
const response = await fetch(process.env.BACKEND_API_SERVER_URL + url, fetchProps);
if (!response.ok) {
if(response.status in customErrorMessage) {
throw new Error(customErrorMessage[response.status]);
};
throw new Error(response.statusText);
};
return await response.json();
}
独自のメッセージにしたいところだけをカスタムエラーメッセージとして設定。
export function getLoginCustomErrorMessage(): { [key: number]: string } {
return {
400: "無効なリクエストです。",
401: "メールアドレスまたはパスワードが間違っています。",
};
}
export function getRefreshLoginCustomErrorMessage(): { [key: number]: string } {
return {
400: "無効なリクエストです。",
401: "再ログイン出来ませんでした。この画面を閉じてEmailとPasswordを入力してログインしてください",
};
}
middleware.ts
下準備は出来たので、続いてアクセストークンをチェックしてログイン画面へリダイレクトさせるためにミドルウェアを作成していきます。
やりたいことは、
トップページとログインページ以外へのアクセスの時に下記を判定
・アクセストークンをCookieに保有しているか確認
・保持していればそのまま、リクエストを通す
・保持していなければ、ログイン画面へリダイレクト。
リフレッシュトークンは保持している場合は「refresh=true」をつけてリダイレクト
Next.jsではmiddleware.tsは1プロジェクトに1ファイルのサポートとなっていて、プロジェクトのルートに配置が必要となっていました。
複数ファイルに分割したい場合は .ts
や .js
ファイルで分割してmiddleware.tsにインポートすればよいようです。
今回は、やることはシンプルなので、直接書いていきます。
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getRefreshToken, getToken } from '@/util/cookies/token';
import { getNextPathSetProps } from '@/util/cookies/next-path';
// This function can be marked `async` if using `await` inside
export async function middleware(request: NextRequest) {
const token = getToken();
if (token) {
return NextResponse.next();
}
const queryParams = getRefreshToken() ? '?refresh=true' : '';
const redirectResponse = NextResponse.redirect(new URL('/login' + queryParams, request.url));
redirectResponse.cookies.set(getNextPathSetProps(request.nextUrl.pathname));
return redirectResponse;
}
// See "Matching Paths" below to learn more
export const config = {
matcher: [
'/nextodo/:path*',
],
}
ログイン画面準備
ログイン画面を作成します。
ログイン画面はdaisyUIのHeroを使わせてもらいます。
テキストはあとでちゃんと考えるので仮置きのまま進めていきます。
ログインフォームと、リフレッシュ用のモーダルはコンポーネントにして配置。
import LoginForm from '@/features/auth/login/Form';
import RefreshLoginModal from '@/features/auth/refresh-login/Modal';
export default function Page() {
return (
<>
<RefreshLoginModal />
<div className="hero min-h-screen bg-base-200">
<div className="hero-content flex-col lg:flex-row-reverse">
<div className="text-center lg:text-left">
<h1 className="text-5xl font-bold">Login now!</h1>
<p className="py-6">some text.</p>
</div>
<div className="card shrink-0 w-full max-w-sm shadow-2xl bg-base-100">
<LoginForm />
</div>
</div>
</div>
</>
);
}
Server Actionsについて
今回は複雑な処理もないため、気になっていたServer Actionsを使ってみたいと思います。
エンドポイントを別途作成する必要がないため、比較的少ない記述量で実装できそうです。
プログレッシブエンハンスメントにより JavaScript の読み込み完了前でも、フォーム操作が出来るようになり、通信が不安定な環境などで有効なようです。
その恩恵を受けつつ、フォームアクションのStateを管理してエラーメッセージを表示させるためにuseFormStateを使います。
公式より以下のブログが非常に分かり易く、参考にさせていただきました(ほんとに感謝!!!)
login/actions.ts
ログインボタンを押した時のアクションを作っていきます。
流れは
・入力されるフォームデータよりemailとpasswordを取得して、トークンを取得
・レスポンスから、cookieにアクセストークン、リフレッシュトークンをセット
・ミドルウェアでログイン画面へリダイレクトする際に保存していたnextPathを取得してリダイレクト
'use server';
import { getNextPath, removeNextPath } from "@/util/cookies/next-path";
import { setRefreshToken, setToken } from "@/util/cookies/token";
import { getLoginCustomErrorMessage } from "@/util/fetch/error-message";
import fetchPost from "@/util/fetch/post";
import { redirect } from "next/navigation";
export type State = {
message: string,
};
export async function login(prevState: State ,formData: FormData): Promise<State> {
try {
const response = await fetchPost({
url: '/auth/token/',
params: {
email: formData.get('email'),
password: formData.get('password'),
},
customErrorMessage: getLoginCustomErrorMessage(),
});
setToken(response.access);
setRefreshToken(response.refresh);
} catch (error: any) {
prevState.message = error.message ?? 'エラーが発生しました。';
return prevState;
}
const nextPath = getNextPath() ?? '/nextodo';
removeNextPath();
redirect(String(nextPath));
}
login/Form.tsx
続いてフォームになります。
先ほどのアクションをuseFormStateに設定。
結果によって状態を更新出来るものになっており、無事ログイン出来れば何も表示はなく
エラーが発生した場合にメッセージを更新して表示するようにします。
'use client';
import { useFormState } from "react-dom";
import { login, State } from "./actions";
export default function LoginForm() {
const initialState: State = {
message: '',
};
const [state, dispatch] = useFormState(login, initialState);
return (
<>
<form action={dispatch} className="card-body">
{/* ----------------------- email ----------------------- */}
<div className="form-control">
<label className="label">
<span className="label-text">Email</span>
</label>
<input
type="email"
placeholder="email"
className="input input-bordered w-full"
required
name='email'
/>
</div>
{/* ----------------------- password ----------------------- */}
<div className="form-control">
<label className="label">
<span className="label-text">Password</span>
</label>
<input
type="password"
placeholder="password"
className="input input-bordered w-full"
required
name='password'
/>
</div>
{/* エラーがある場合にエラーメッセージ表示 */}
<div>
{state.message && (
<p className="text-red-500">{state.message}</p>
)}
</div>
{/* ----------------------- submit ----------------------- */}
<div className="form-control mt-6">
<button className="btn btn-secondary">Login</button>
</div>
{/* ------------------------------------------------------ */}
</form>
</>
);
}
ここまでで、とりあえずログインは出来るようになりました。
続いてリフレッシュトークン部分を作成していきます。
refresh-login/actions.ts
まずはこちらも、ボタンを押した時のアクションを作成します。
想定としてはトークンは期限切れで、リフレッシュトークンはまだ生きているという状態です。
流れは
・まずはリフレッシュトークンが存在するか再度チェック
見つからない場合は401と同じエラーを返します。
・リフレッシュトークンが存在すれば、fetch
・レスポンスから、cookieにアクセストークン、リフレッシュトークンをセット
・nextPathを取得してリダイレクト
(ログイン時とほぼ一緒です)
'use server';
import { getNextPath, removeNextPath } from "@/util/cookies/next-path";
import { getRefreshToken, setRefreshToken, setToken } from "@/util/cookies/token";
import { getRefreshLoginCustomErrorMessage } from "@/util/fetch/error-message";
import fetchPost from "@/util/fetch/post";
import { redirect } from "next/navigation";
export type State = {
message: string,
};
export async function refreshLogin(prevState: State): Promise<State> {
const refreshToken = getRefreshToken();
const refreshLoginCustomErrorMessage = getRefreshLoginCustomErrorMessage();
if (!refreshToken) {
prevState.message = refreshLoginCustomErrorMessage[401] ?? 'エラーが発生しました。';
return prevState;
}
try {
const response = await fetchPost({
url: '/auth/token/refresh/',
params: {
refresh: refreshToken,
},
customErrorMessage: refreshLoginCustomErrorMessage,
});
setToken(response.access);
setRefreshToken(response.refresh);
} catch (error: any) {
prevState.message = error.message ?? 'エラーが発生しました。';
return prevState;
}
const nextPath = getNextPath() ?? '/nextodo';
removeNextPath();
redirect(String(nextPath));
}
refresh-login/Modal.tsx
続いてモーダルです。
こちらもdaisyUIを使わせてもらいます。
ログインフォーム同様にuseFormStateにアクションを設定し、エラーが発生した場合にメッセージを表示するようにします。
エラー発生時はいずれにしろモーダルを閉じてログインをする必要があるのですが、
エラーになったことを明示するために、自動では閉じずにモーダルは残したままにしました。
後程トーストに置き換えたいです。
'use client';
import { useSearchParams } from "next/navigation";
import { useFormState } from "react-dom";
import { useEffect, useRef } from "react";
import { refreshLogin, State } from "./actions";
export default function RefreshLoginModal() {
const initialState: State = {
message: '',
};
const [state, dispatch] = useFormState(refreshLogin, initialState);
const dialog = useRef<HTMLDialogElement>(null);
const searchParams = useSearchParams();
const refresh = searchParams.get('refresh');
if(refresh){
useEffect(() => {
dialog.current?.showModal();
}, [refresh]);
}
const closeHandler = () => {
dialog.current?.close();
}
return (
<dialog className="modal" ref={dialog}>
<div className="modal-box text-center">
<h3 className="font-bold text-lg">タイムアウトしました</h3>
<p className="my-4">
自動ログインボタンを押すか<br/>
この画面を閉じて<br/>
EmailとPasswordでログインしてください。
</p>
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onClick={closeHandler} >✕</button>
<form action={dispatch}>
<button className="btn btn-primary">自動ログイン</button>
{state.message && (
<p className="text-red-500 pt-4">{state.message}</p>
)}
</form>
</div>
</dialog>
);
}
テスト
1.ログイン前にどこかnextodoのページへアクセス
→ログイン画面へリダイレクト。nextPathもcookieへ格納されている
2.email/passwordでログイン
→上記1でアクセスしようとしたページへ推移。tokenがcookieへ格納されている。
3.cookieのtokenを消してアクセス
→ログイン画面へリダイレクトされ、リフレッシュログインのモーダルが表示
4.自動ログインボタン押す
→上記3でアクセスしようとしていたページへ推移。
5.間違ったemail/passwordでログイン
→エラーメッセージ表示OK
6.自動ログイン前にrefreshTokenを消して実行
→エラーメッセージ表示OK
想定通りの動作となりました。
さいごに
・現状まだ理解が浅いところがあり、まずはフォームだけクライアントコンポーネントにしようと思います。
・第1回から日が空いてしまいましたが、これでやっとアプリの中身が作れますので、第3回もはりきってやっていきたいと思います。
参考