0
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?

React(Next.js)のStatic Exports設定で認証ロジックを実装する④

Last updated at Posted at 2024-09-27

株式会社 ONE WEDGE@Shankouです。
前回に引き続き、認証ロジックの実装を行っていきたいと思います。

今回はアクセス制限の実装に手をつけていきます。

src/features/Auth/hooks/useRefreshToken.ts

最初にRefreshTokenとAccessTokenを扱うhooksを作成します。
まずはsrc/features/Auth/hooks/useRefreshToken.tsを作成していきます。

今回のポイントとしてはuseSyncExternalStoreを使っていることでしょうか。
こちらは、React外部のデータソースのストアを監視し、その変更を検知することができるようになるというものです。
もっと分かりやすくいうとlocalStoragesessionStorageの変更を検知するReact hooksが作成できます。
タネは意外と簡単で更新時に登録したイベントを発火させることでReactがそれを拾ってくれる形ですね。

一点注意が必要なのは、useRefreshTokenReact Componentの中からしか呼び出しができないことです。
通常のアクセスはgetRefreshToken関数やsetRefreshToken関数から行ってください。

src/features/Auth/hooks/useRefreshToken.ts
import { useSyncExternalStore } from 'react'

const EVENT_NAME = 'updaterefreshtoken'
const subscribe = (callback: () => void) => {
  window.addEventListener(EVENT_NAME, callback)
  return () => {
    window.removeEventListener(EVENT_NAME, callback)
  }
}

export const REFRESH_TOKENE_KEY_NAME = 'refreshToken'

export const getRefreshToken = () => {
  if (typeof window !== 'undefined') {
    return window.localStorage.getItem(REFRESH_TOKENE_KEY_NAME) || ''
  }
  return ''
}

export const setRefreshToken = (token: string) => {
  if (typeof window !== 'undefined') {
    window.localStorage.setItem(REFRESH_TOKENE_KEY_NAME, token)
    // RefreshTokenの更新イベントを発火させる
    window.dispatchEvent(new Event(EVENT_NAME))
  }
}

export const useRefreshToken = () => {
  const refreshToken = useSyncExternalStore(
    subscribe,
    () => getRefreshToken(),
    () => ''
  )

  return { refreshToken, setRefreshToken }
}

src/features/Auth/hooks/useAccessToken.ts

useRefreshTokenとほぼ同じになります。

src/features/Auth/hooks/useAccessToken.ts
import { useSyncExternalStore } from 'react'

const EVENT_NAME = 'updateaccesstoken'
const subscribe = (callback: () => void) => {
  window.addEventListener(EVENT_NAME, callback)
  return () => {
    window.removeEventListener(EVENT_NAME, callback)
  }
}

export const ACCESS_TOKEN_KEY_NAME = 'accessToken'

export const getAccessToken = () => {
  if (typeof window !== 'undefined') {
    return window.sessionStorage.getItem(ACCESS_TOKEN_KEY_NAME) || ''
  }
  return ''
}

export const setAccessToken = (token: string) => {
  if (typeof window !== 'undefined') {
    window.sessionStorage.setItem(ACCESS_TOKEN_KEY_NAME, token)
    // AccessTokenの更新イベントを発火させる
    window.dispatchEvent(new Event(EVENT_NAME))
  }
}

export const useAccessToken = () => {
  const accessToken = useSyncExternalStore(
    subscribe,
    () => getAccessToken(),
    () => ''
  )

  return { accessToken, setAccessToken }
}

src/app/()/login/page.tsx

というわけで、前回作成したsrc/app/()/login/page.tsxを改修して、setAccessTokensetRefreshTokenを適用していきます

// sessionStorage.setItem('accessToken', result.accessToken) => setAccessToken(result.accessToken)
setAccessToken(result.accessToken)
// localStorage.setItem('refreshToken', result.refreshToken) => setRefreshToken(result.refreshToken)
setRefreshToken(result.refreshToken)
src/app/()/login/page.tsx
'use client'

import CustomInputField from '@/components/elements/CustomInputField'
import { setAccessToken } from '@/features/Auth/hooks/useAccessToken'
import { setRefreshToken } from '@/features/Auth/hooks/useRefreshToken'
import { PostLoginRequest, zPostLoginRequest } from '@/features/Auth/types/request/PostLoginRequest.type'
import { PostLoginResponse } from '@/features/Auth/types/response/PostLoginResponse.type'
import { useRouter } from 'next/navigation'
import { ReactElement } from 'react'
import { useForm, SubmitHandler } from 'react-hook-form'

export default function Login(): ReactElement {
  const route = useRouter()
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<PostLoginRequest>()
  const onSubmit: SubmitHandler<PostLoginRequest> = async (data) => {
    try {
      const params: PostLoginRequest = zPostLoginRequest.parse(data)
      /** 本来であればここでAPIを呼び出します **/
      // const result: PostLoginResponse | false = await myAxios.post('/login', params)
      //   .then((res) => zPostLoginResponse.parse(res))
      //   .catch(() => false)

      // if (result === false) {
      //   // APIエラー処理
      //   return
      // }

      /** 今回はフロント周りの動作のみのため、一旦適当なデータを用意 **/
      const result: PostLoginResponse = { accessToken: 'hogehoge', refreshToken: 'fugafuga' }
      
      setAccessToken(result.accessToken)
      setRefreshToken(result.refreshToken)
      
      route.push('/mypage')
    } catch (e) {
      console.error(e)
      // エラー処理
    }
  }

  return (
    <>
      <h1>ログイン</h1>
      <div className='flex justify-center'>
        <form className='m-2 w-96' onSubmit={handleSubmit(onSubmit)}>
          <CustomInputField
            type='email'
            id='login-id'
            label='メールアドレス'
            className='mt-2'
            error={errors.email?.message}
            {...register('email', {
              required: 'メールアドレスを入力してください',
            })}
          />
          <CustomInputField
            type='password'
            autoComplete='current-password'
            minLength={8}
            id='password'
            label='パスワード'
            className='mt-2'
            error={errors.password?.message}
            {...register('password', {
              required: 'パスワードを入力してください',
              minLength: {
                value: 8,
                message: 'パスワードは8文字以上で入力してください',
              },
            })}
          />
          <button type='submit' className='mt-6 p-4 w-full border bg-slate-200/60'>ログイン</button>
        </form>
      </div>
    </>
  )
}

src/features/Auth/types/AuthenticationStatus.type.ts

それでは、AuthProviderの前に認証ステータス状態を表す定義ファイルを作成します。

src/features/Auth/types/AuthenticationStatus.type.ts
export const Waiting = Symbol('AUTHENTICATION_STATUS: WAITING')
export const Processing = Symbol('AUTHENTICATION_STATUS: PROCESSING')
export const Failed = Symbol('AUTHENTICATION_STATUS: FAILED')
export const Successful = Symbol('AUTHENTICATION_STATUS: SUCCESSFUL')

export type AuthenticationStatus = typeof Waiting | typeof Processing | typeof Failed | typeof Successful

src/features/Auth/providers/AuthProvider.tsx

こちらが認証状態を常時監視してステータスを通知してくれるAuthProvider.tsxになります。

src/features/Auth/providers/AuthProvider.tsx
'use client'

import { ReactNode, createContext, useState, useEffect, useContext } from 'react'

import {
  AuthenticationStatus,
  Waiting,
  Processing,
  Failed,
  Successful,
} from '@/features/Auth/types/AuthenticationStatus.type'
import { usePathname, useSearchParams } from 'next/navigation'
import { useAccessToken } from '@/features/Auth/hooks/useAccessToken'
import { useRefreshToken } from '@/features/Auth/hooks/useRefreshToken'

const AuthenticationStatusContext = createContext<AuthenticationStatus>(Waiting)
export function useAuthenticationStatusContext() {
  return useContext(AuthenticationStatusContext)
}

type AuthProviderProps = {
  children: ReactNode
}
export default function AuthProvider({ children }: AuthProviderProps) {
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const [authenticationStatus, setAuthenticationStatus] = useState<AuthenticationStatus>(Waiting)
  const { accessToken } = useAccessToken()
  const { refreshToken } = useRefreshToken()
  let status = authenticationStatus

  // 認証確認処理
  useEffect(() => {
    if (status !== Processing) {
      setAuthenticationStatus((status = Waiting))
    }

    if (status === Waiting) {
      setAuthenticationStatus((status = Processing))
      ; (async () => {
        // accessTokenの有効期間を確認して、必要であればrefreshTokenを用いて更新を行う (後ほど実装)
        // await refreshIfExpired()
        if (refreshToken && accessToken) {
          setAuthenticationStatus((status = Successful))
        } else {
          setAuthenticationStatus((status = Failed))
        }
      })()
    }
  }, [
    // pathname & searchParamsを依存関係に含めることでページ遷移ごとに
    // accessToken & refreshTokenを依存関係に含めることで各Tokenの更新ごとに認証確認処理を実行させる
    pathname, searchParams, accessToken, refreshToken
  ])

  return (
    <AuthenticationStatusContext.Provider value={authenticationStatus}>
      {children}
    </AuthenticationStatusContext.Provider>
  )
}

src/app/layout.tsx

作成したAuthProvider.tsxを導入していきます。
layout.tsxに組み込んで行きましょう。
注意事項としてはlayout.tsxReact hooksは入れられないという点です。
使いたい場合はどうすればいいのか、解決方法は次項で説明します。

src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Suspense } from 'react'
import AuthProvider from '@/features/Auth/providers/AuthProviders'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: '',
  description: '',
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang='ja'>
      <body className={`min-h-screen bg-white ${inter.className}`}>
        <Suspense>
          <AuthProvider>
            {children}
          </AuthProvider>
        </Suspense>
      </body>
    </html>
  )
}

src/app/(authenticated)/template.tsx

前項の解決方法がこちら!
template.tsxという特殊存在です。layout.tsxpage.tsxの間へ自動的に組み込まれるので、明示的にインポートする必要はありません。
src/app/(authenticated)に配置したtemplate.tsxなので、認証済みのページにアクセスしようとした場合にのみ適用されます。
layout.tsxレベルで組み込みたい処理はこちらに記述するといいでしょう。

今回はuseAuthenticationStatusContextを用いて認証状態を確認し、未認証状態ならログインページに転送するという処理を行っています。

src/app/(authenticated)/template.tsx
'use client'

import { useAuthenticationStatusContext } from '@/features/Auth/providers/AuthProviders'
import { Failed } from '@/features/Auth/types/AuthenticationStatus.type'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'

type TemplateProps = {
  children: React.ReactNode
}

export default function Template({ children }: TemplateProps) {
  const router = useRouter()
  const authenticationStatus = useAuthenticationStatusContext()

  useEffect(() => {
    switch (authenticationStatus) {
      case Failed:
        router.push('/login')
        break
      default:
        break
    }
  }, [authenticationStatus])

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

実はまだ続く

当初の目的であった認証状態でアクセス制限を行うロジックは概ね組めました。
ですが、もう少しだけ調整したい箇所が残っています。
APIの呼び出し時に自動でTokenを組み込んだりとか、refreshIfExpired関数とか、、、

それはまた次回ということで今回は締めたいと思います。

next >

0
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
0
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?