next.js + Redux + Firebase Authentication で next-redux-wrapperを使って認証情報を処理したメモ

## 概要
react-redux-firebase を導入しようとしたが難しすぎた。
「[Zeit Now + Next.js のページにFirebase認証を導入した際、環境変数にハマったメモ](https://qiita.com/hibohiboo/items/6edf851b3a5dc6601b37)」で参考にしたソースではreduxを使っていなかったのでreduxを使う形に直す。
[TypeScript と Redux に入門しながら Next.js でサンプルアプリケーションを作ってみた](https://qiita.com/monzou/items/8ff07258a42042075d7b#redux-%E3%82%92%E7%B5%84%E3%81%BF%E5%90%88%E3%82%8F%E3%81%9B%E3%81%A6%E3%81%BF%E3%82%8B)で紹介されていた[next-redux-wrapper](https://github.com/kirill-konshin/next-redux-wrapper)が使いやすそうだったので導入。
[作成したソース](https://github.com/hibohiboo/create-now/tree/e6868142716d617d73426b11962894a383221c52/)

**追記**
[v9に直したメモ](https://qiita.com/hibohiboo/items/9fe72a434552949270a5)

## フォルダ構成

```yaml
- .env # 環境変数設定ファイル
- .env.build
- now.json # now設定ファイル
- next.config.js # next設定ファイル
- api
  - login.ts # セッション用API (cookieを使用。トークンをデコードしてユーザ情報を返却）
  - logout.ts # セッション終了API
- src
  - lib
    - firebase
      - initFirebase.ts # firebase初期化
  - pages
    - index.tsx # トップページ
    - _app.tsx # テンプレート
    - auth
      - login.tsx # ログインページ
    - mypage
      - index.tsx # ログイン時のみ閲覧可能なページ
    - infos
      - agreement.tsx # 利用規約
      - privacy-policy.tsx # プライバシーポリシー
  - store
    - index.ts # state
    - modules
      - authModule.ts # 認証情報を保存するreducer
  - utils
    - auth
       - firebaseAdmin.ts # トークン検証（API用）
       - firebaseSessionHandler # セッション用APIを叩く
     - middleware
       - commonMiddleware.ts # middlewareとして登録(API用)
       - cookieSession.ts # セッションを追加する（API用）
       - cookiSessionRefresh.ts # cookie初期化(API用)
```


## ソース

### firebaseの準備

`!firebase.apps.length`で判定して、不要なinitializeAppを行わないようにしている。

```ts:src/lib/firebase/initFirebase.ts
import firebase from 'firebase/app'
import 'firebase/auth'

const config = {
  apiKey: process.env.FIREBASE_API_KEY,
  authDomain: process.env.FIREBASE_AUTH_DOMAIN,
  projectId: process.env.FIREBASE_PROJECT_ID,
}

if (!firebase.apps.length) {
  firebase.initializeApp(config)
}

export const auth = firebase.auth()
```

### reducer action stateの準備

* 取得先によってデータの持ち方が異なるオブジェクトを標準化
  * cookieからの値
  * firebaseの返すユーザ

```src/store/modules/authModule.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { useSelector } from 'react-redux'
import { get, has } from 'lodash'
import { auth } from '~/lib/firebase/initFirebase'
import { setSession } from '~/utils/auth/firebaseSessionHandler'

export interface AuthUser {
  id: string
  displayName: string
  photoURL: string
}

export interface AuthUserInfo {
  authUser?: AuthUser
  token?: string
}
interface ServerSidePayload {
  firebaseUser: firebase.User | null
  token: string | null
}

const createAuthUser = (firebaseUser: firebase.User | null): AuthUser => {
  if (!firebaseUser || !firebaseUser.uid) {
    return null
  }
  return {
    id: get(firebaseUser, 'uid'),
    displayName: has(firebaseUser, 'name')
      ? get(firebaseUser, 'name') // cookie
      : has(firebaseUser, 'displayName')
      ? get(firebaseUser, 'displayName') // client SDK
      : get(firebaseUser, 'display_name'), // admin SDK
    photoURL: has(firebaseUser, 'picture')
      ? get(firebaseUser, 'picture') // cookie
      : has(firebaseUser, 'photoURL')
      ? get(firebaseUser, 'photoURL') // client SDK
      : get(firebaseUser, 'photo_url'), // admin SDK
  }
}

const createAuthUserInfo = ({
  firebaseUser = null,
  token = null,
} = {}): AuthUserInfo => {
  return {
    authUser: createAuthUser(firebaseUser),
    token,
  }
}

export const init: AuthUserInfo = { authUser: undefined, token: undefined }

const authModule = createSlice({
  name: 'auth',
  initialState: init,
  reducers: {
    createServerSide: (state, action: PayloadAction<ServerSidePayload>) => {
      const { authUser, token } = createAuthUserInfo(action.payload)
      state.authUser = authUser
      state.token = token
    },
    createClientSide: (state) => {
      const user = auth.currentUser
      setSession(user)
      state.authUser = createAuthUser(user)
    },
    logout: (state) => {
      state.authUser = null
      state.token = null
      setSession(null)
      auth.signOut()
    },
  },
})

export const useAuth = () => {
  return useSelector(
    (state: { auth: ReturnType<typeof authModule.reducer> }) =>
      state.auth.authUser,
  )
}

export default authModule
```

### stateの準備

* next-redux-wrapperが`initialState`でよしなにやってくれるので設定

```src/store/index.ts
import {
  configureStore,
  getDefaultMiddleware,
  EnhancedStore,
} from '@reduxjs/toolkit'
import { rootReducer } from './rootState'
import logger from 'redux-logger'
import { MakeStore } from 'next-redux-wrapper'

export const setupStore = (preloadedState?): EnhancedStore => {
  const middlewares = [...getDefaultMiddleware()]

  if (process.env.NODE_ENV === 'development') {
    middlewares.push(logger)
  }

  return configureStore({
    reducer: rootReducer,
    middleware: middlewares,
    devTools: true,
    preloadedState,
  })
}

export const makeStore: MakeStore = (initialState) => setupStore(initialState)
```

### _app で SSRでユーザ取得処理
* `typeof window === 'undefined'`でSSRの処理か、フロントの処理かを判断
* `!ctx.store.getState().auth.authUser`でユーザが要ればあらたに取得しに行く処理を行わない

```tsx:src/pages/_app.tsx
import * as React from 'react'
import { Provider } from 'react-redux'
import App, { AppContext } from 'next/app'
import { ThemeProvider } from '@material-ui/core/styles'
import CssBaseline from '@material-ui/core/CssBaseline'
import Head from 'next/head'
import withRedux, { ReduxWrapperAppProps } from 'next-redux-wrapper'
import { get } from 'lodash'
import { makeStore } from '~/store'
import { RootState } from '~/store/rootState'
import theme from '../theme'
import authModule from '~/store/modules/authModule'

class MyApp extends App<ReduxWrapperAppProps<RootState>> {
  public static async getInitialProps({ Component, ctx }: AppContext) {
    const { createServerSide, createClientSide } = authModule.actions
    if (typeof window === 'undefined') {
      const { req, res } = ctx
      // eslint-disable-next-line @typescript-eslint/no-var-requires
      const { addSession } = require('../utils/middleware/cookieSession')
      addSession(req, res)
      const payload = {
        firebaseUser: get(req, 'session.decodedToken', null),
        token: get(req, 'session.token', null),
      }
      ctx.store.dispatch(createServerSide(payload))
    } else {
      if (!ctx.store.getState().auth.authUser) {
        ctx.store.dispatch(createClientSide())
      }
    }

    return {
      pageProps: {
        // Call page-level getInitialProps
        ...(Component.getInitialProps
          ? await Component.getInitialProps(ctx)
          : {}),
        // Some custom thing for all pages
        pathname: ctx.pathname,
      },
    }
  }
  componentDidMount() {
    // Remove the server-side injected CSS.
    const jssStyles = document.querySelector('#jss-server-side')
    if (jssStyles) {
      jssStyles.parentElement.removeChild(jssStyles)
    }
  }
  public render() {
    const { Component, pageProps, store } = this.props
    return (
      <Provider store={store}>
        <React.Fragment>
          <Head>
            <title>Create Now</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
          <ThemeProvider theme={theme}>
            {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
            <CssBaseline />
            <Component {...pageProps} />
          </ThemeProvider>
        </React.Fragment>
      </Provider>
    )
  }
}

export default withRedux(makeStore, {
  debug: process.env.NODE_ENV === 'development',
})(MyApp)
````

### 利用例
* ログインしていないときはトップに戻す
* ログイン中のユーザ情報を表示
* ログアウト

```tsx:/src/pages/mypage/index.tsx
import React, { useEffect } from 'react'
import Router from 'next/router'
import {
  Container,
  Box,
  Avatar,
  AppBar,
  Toolbar,
  IconButton,
  Typography,
} from '@material-ui/core'
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles'
import { useDispatch } from 'react-redux'
import Footer from '../../components/organisms/common/Footer'
import authModule, { useAuth } from '~/store/modules/authModule'

// スタイルを適用する
const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    menuButton: {
      color: 'inherit',
      padding: '8px',
    },
    headerTitleStyle: {
      flexGrow: 1,
      color: 'inherit',
    },
    avatar: {
      margin: '8px',
    },
  }),
)

const MyPage = () => {
  const authUser = useAuth()
  const dispatch = useDispatch()
  const logoutHandler = () => {
    try {
      dispatch(authModule.actions.logout())
      Router.push('/')
    } catch (e) {
      console.error(e)
    }
  }

  // CSSを適用する。
  const classes = useStyles()

  useEffect(() => {
    if (!authUser) {
      Router.push('/')
    }
  }, [])

  return (
    <>
      {!authUser ? (
        <></>
      ) : (
        <>
          <AppBar position="static" aria-label="Global Navi">
            <Toolbar>
              <Typography
                className={classes.headerTitleStyle}
                variant="subtitle1"
              >
                Create Now
              </Typography>
              <IconButton className={classes.menuButton} aria-label="Menu">
                {!authUser.photoURL ? (
                  <Avatar className={classes.avatar}></Avatar>
                ) : (
                  <Avatar
                    alt={authUser.displayName}
                    src={authUser.photoURL}
                  ></Avatar>
                )}
              </IconButton>
            </Toolbar>
          </AppBar>
          <Container>
            <Box height="100vh">
              <h2>マイページ</h2>
              <div>ユーザー: {authUser.displayName}</div>
              <button onClick={logoutHandler}>ログアウト</button>
            </Box>
            {/* <pre className="text-xs">{JSON.stringify(authUser, null, 2)}</pre> */}
            <Footer />
          </Container>
        </>
      )}
    </>
  )
}
export default MyPage
```


## 参考
[TypeScript と Redux に入門しながら Next.js でサンプルアプリケーションを作ってみた](https://qiita.com/monzou/items/8ff07258a42042075d7b)