devise導入済みのrailsへのreact SPAからログインする機能の実装

# 概要
* webで使っているdeviseにSPAからログインできるようにapiを追加
* 実際にログイン。ログアウトするボタンをreact + axiosで実装

# 環境
* rails 5
* deviseをインストールして初期設定が完了していること
* react
* axios
* [拙記事](https://qiita.com/junara/items/b7ec169de7432d7b8fe7)の続きです

# 動作動画
## ユーザー登録
[![https://gyazo.com/24d2cb8a08d600bb31c297d63dada928](https://i.gyazo.com/24d2cb8a08d600bb31c297d63dada928.gif)](https://gyazo.com/24d2cb8a08d600bb31c297d63dada928)

## ログイン
[![https://gyazo.com/687b9647f9917d0a3bb97eacc4a1abbb](https://i.gyazo.com/687b9647f9917d0a3bb97eacc4a1abbb.gif)](https://gyazo.com/687b9647f9917d0a3bb97eacc4a1abbb)

## ログアウト
[![https://gyazo.com/8e07487806509f3a4178428f27e2050b](https://i.gyazo.com/8e07487806509f3a4178428f27e2050b.gif)](https://gyazo.com/8e07487806509f3a4178428f27e2050b)

# 手順

## クロスオリジンからのリクエストの許容（devlopmentのみ）

下記を追加

```config/environments/development.rb
  config.web_console.whitelisted_ips = %w( 0.0.0.0/0 ::/0 )

```

* 下記を参考にしました
	* https://qiita.com/kazukgw@github/items/5e08ce893b709e16e1e1


## ルーティングの修正
* deviseに独自のコントローラを追加できるようにルーティングを追加します。
	* すでに導入されている場合はそれを利用してください。

```config/routes.rb
  # devise_for :users
  devise_for :users, :controllers => {sessions: 'sessions', registrations: 'registrations'}

```

## コントローラの修正
* jsonのリクエスト時（respond_to :json）に動く、ログイン用のメソッド（create）とログアウト用のメソッドを（destroy）をオーバーライドします。
* ログインとログアウト時にcsfr_tokenが更新される場合があるので、csrf_tokenが発行し、react側で受け取れるようにjsonの出力も追加します。

```app/controllers/registrations_controller.rb
class RegistrationsController < Devise::RegistrationsController
  respond_to :json

  def create
    super do
      if request.format.json?
        render :json => {
          'status' => 'ok',
          'csrf_token' => form_authenticity_token,
          'result' => {
            'user' => {
              'id' => @user.id,
              'email' => @user.email
            }
          }
        } and return
      end
    end
  end
end

```

```app/controllers/sessions_controller.rb
class SessionsController < Devise::SessionsController
  respond_to :json

  def create
    @user = current_user
    super do
      if request.format.json?
        render :json => {
          'status' => 'ok',
          'csrf_token' => form_authenticity_token,
          'result' => {
            'user' => {
              'id' => @user.id,
              'email' => @user.email
            }
          }
        } and return
      end
    end
  end

  def destroy
    super do
      if request.format.json?
        render :json => {
          'csrf_param' => request_forgery_protection_token,
          'csrf_token' => form_authenticity_token
        }
        return
      end
    end
  end
end

```

## ログイン用関数の追加
* reactにApiモジュールとしてログインとログアウト用のメソッドを追加します。
* getCsrfTokenでソースからcsrf-tokenを取得します。
* setAxiosDefaultsでaxiosの初期設定にcsfr-tokenと追加します。
* updateCsrfTokenをログイン、ログアウトのメソッドから呼び出すことでcsrf_tokenを更新します。

```app/javascript/modules/Api.jsx
import axios from 'axios';

const getCsrfToken = () => {
  if (!(axios.defaults.headers.common['X-CSRF-Token'])) {
    return (
      document.getElementsByName('csrf-token')[0].getAttribute('content')
    )
  } else {
    return (
      axios.defaults.headers.common['X-CSRF-Token']
    )
  }
};

const setAxiosDefaults = () => {
  axios.defaults.headers.common['X-CSRF-Token'] = getCsrfToken();
  axios.defaults.headers.common['Accept'] = 'application/json';
};

setAxiosDefaults();

const updateCsrfToken = (csrf_token) => {
  axios.defaults.headers.common['X-CSRF-Token'] = csrf_token;
};

export const sessionApi = {
  login: ({email, password}) => {
    return (axios.post('/users/sign_in', {
        user: {
          email: email,
          password: password,
          remember_me: 1
        }
      })
        .then(response => {
          console.log('success');
          updateCsrfToken(response.data.csrf_token);
          return (response)
        })

    )
  },
  logout: () => {
    return (
      axios.delete(
        '/users/sign_out'
      )
        .then(response => {
          console.log('success');
          updateCsrfToken(response.data.csrf_token);
          return (response)
        })
    )
  }
};

export const registrationApi = {
  signUp: ({email, password, password_confirmation, name}) => {
    return (axios.post('/users', {
        user: {
          name: name,
          email: email,
          password: password,
          password_confirmation: password_confirmation,
        }
      })
        .then(response => {
          console.log('success');
          updateCsrfToken(response.data.csrf_token);
          return (response)
        })
    )
  }
};
```

## 新規登録・ログインボタンの追加
* this.state.userの状態に従ってログインボタン・ログアウトボタンを切り替えます。
	* ログインボタンは人のアイコン、ログアウトボタン（ログインしている事を示すアイコンも兼用）はemailの冒頭2文字のアバターです。
* ログインボタンを押すとダイアログが出てemail, passwordを入力します。ログインボタンでログインできます
* ログアウトボタンを押すとログアウトするためのボタンが出ます。

```app/javascript/containers/common/UserButton.jsx
import React from 'react';
import TextField from 'material-ui/TextField';
import Button from 'material-ui/Button';
import Avatar from 'material-ui/Avatar';
import Dialog, {
  DialogContent,
  DialogTitle,
  DialogActions,
} from 'material-ui/Dialog';

class UserButton extends React.Component {

  render() {
    const displayButton = () => {
      if (this.props.user === null) {
        return (
          <div>
            <Button color='contrast'
                    onClick={
                      this.props.handleDialog({
                        name: 'signUpDialogOpen', open: true
                      })
                    }
            >
              新規ユーザー追加
            </Button>
            <Button color="contrast"
                    onClick={this.props.handleDialog({
                        name: 'loginDialogOpen', open: true
                      }
                    )}
            >
              ログインする
            </Button>
          </div>
        )
      } else {
        return (
          <Avatar color="contrast"
                  onClick={this.props.handleDialog(
                    {name: 'logoutDialogOpen', open: true})}
          >
            {this.props.user.email.slice(0, 2)}
          </Avatar>
        )
      }
    };
    return (
      <div>
        {displayButton()}
        <Dialog onRequestClose={this.props.handleDialog({
          name: 'signUpDialogOpen',
          open: false
        })}
                open={this.props.signUpDialogOpen}>
          <DialogTitle>
            新規ユーザー追加
          </DialogTitle>
          <DialogContent>
            <TextField
              autoFocus
              fullWidth
              label="name"
              value={this.props.name}
              helperText="半角英数"
              onChange={this.props.handleFormChange('name')}
              type='text'
            />
            <TextField
              fullWidth
              label="email"
              value={this.props.email}
              helperText="半角英数"
              onChange={this.props.handleFormChange('email')}
              type='email'
            />
            <TextField
              fullWidth
              label="password"
              value={this.props.password}
              helperText="半角英数"
              onChange={this.props.handleFormChange('password')}
              type='password'
            />
            <TextField
              fullWidth
              label="password_confirmation"
              value={this.props.password_confirmation}
              helperText="半角英数"
              onChange={this.props.handleFormChange('password_confirmation')}
              type='password'
            />
          </DialogContent>
          <DialogActions>
            <Button onClick={this.props.handleSubmitSignUp()}>
              新規追加する
            </Button>
          </DialogActions>
        </Dialog>

        <Dialog onRequestClose={this.props.handleDialog({
          name: 'loginDialogOpen',
          open: false,
          stateToBeChanged: {
            email: '',
            password: '',
            loginDialogOpen: false
          }
        })}
                open={this.props.loginDialogOpen}>
          <DialogTitle>
            ログイン
          </DialogTitle>
          <DialogContent>
            <TextField
              autoFocus
              fullWidth
              label="email"
              value={this.props.email}
              helperText="半角英数"
              onChange={this.props.handleFormChange('email')}
              type='email'
            />
            <TextField
              fullWidth
              label="password"
              value={this.props.password}
              helperText="半角英数"
              onChange={this.props.handleFormChange('password')}
              type='password'
            />
          </DialogContent>
          <DialogActions>
            <Button onClick={this.props.handleSubmitLogin()}>
              ログインする
            </Button>
          </DialogActions>
        </Dialog>

        <Dialog onRequestClose={this.props.handleDialog(
          {
            name: 'logoutDialogOpen',
            open: false
          })}
                open={this.props.logoutDialogOpen}
        >
          < DialogTitle>
            ログアウトしますか？
          </DialogTitle>
          <DialogActions>
            <Button onClick={this.props.handleSubmitLogout()}>
              ログアウトする
            </Button>
          </DialogActions>
        </Dialog>
      </div>
    )
  }
}

export default UserButton;
```

## reactアプリへの実装

```app/javascript/containers/draft/DraftApp.jsx
import React from 'react'
import Header from '../common/Header'
import DraftBody from './DraftBody'
import DraftBottomNavigation from './DraftBottomNavigation'
import {MuiThemeProvider} from 'material-ui/styles';
import theme from '../../assets/theme'
import Snackbar from 'material-ui/Snackbar';
import {sessionApi, registrationApi} from '../../modules/Api';
import SimpleDialog from '../common/SimpleDialog'

const initialDraftState = {
  draftTitle: '',
  draftMemo: ''
};

const initialLoginState = {
  email: '',
  password: '',
  password_confirmation: '',
  name: '',
  loginDialogOpen: false,
  logoutDialogOpen: false,
  signUpDialogOpen: false
};

const initialBottomNavigationValue = {
  bottomNavigationValue: 0
};

const initialSnackbarState = {
  snackbarOpen: false,
  snackbarMessage: '',
};

const initialSimpleDialogState = {
  simpleDialogTitle: '',
  simpleDialogContent: '',
  simpleDialogOpen: false
};

class DraftApp extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      bottomNavigationValue: 0,
      drafts: [],
      id: 0,
      ...initialDraftState,
      ...initialLoginState,
      ...initialSnackbarState,
      listDialogOpen: false,
      selectedDraftId: 0,
      user: null,
      ...initialSimpleDialogState
    }
  }

  handleChange = name => (event, value) => {
    this.setState({
      [name]: value
    });
  };

  handleDialog = ({name, open, stateToBeChanged}) => (event) => {
    event.preventDefault();
    this.setState({
      [name]: open,
      ...stateToBeChanged
    });
  };

  handleFormChange = name => (event) => {
    this.setState({
      [name]: event.target.value
    });
  };

  handleSubmit = (event) => {
    event.preventDefault();
    const id = this.state.id + 1;
    const draft = {
      id: id,
      title: this.state.draftTitle,
      memo: this.state.draftMemo,
      createdAt: this.getCreatedAt(new Date())
    };
    const drafts = this.state.drafts.concat(draft);
    this.setState({
      id: id,
      drafts: drafts,
      ...initialDraftState,
      ...initialBottomNavigationValue,
    });
    this.handleSnackbar({message: '保存しました'});
  };

  getCreatedAt = (date) => {
    return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日${date.getHours()}時${date.getMinutes()}分${date.getSeconds()}秒`;
  };

  handleSnackbar = ({message}) => {
    this.setState({
      snackbarOpen: true,
      snackbarMessage: message
    })
  };

  handleSnackbarClose = () => (event) => {
    event.preventDefault();
    this.setState({
      ...initialSnackbarState
    })
  };

  handleSubmitLogin = () => (event) => {
    event.preventDefault();
    sessionApi.login({email: this.state.email, password: this.state.password})
      .then(response => {
        this.setState({
          user: response.data.result.user,
        })
      })
      .then(
        this.setState({
          ...initialLoginState
        })
      )
      .then(() => {
          this.handleSnackbar({message: 'ログインしました'});
        }
      )
      .catch(error => {
        console.log(error);
      })
  };

  handleSubmitLogout = () => (event) => {
    event.preventDefault();
    sessionApi.logout()
      .then(
        this.setState({
          user: null,
          ...initialLoginState
        })
      )
      .then(() => {
          this.handleSnackbar({message: 'ログアウトしました'});
        }
      )
      .catch(error => {
        console.log('error');
      });
  };

  handleSubmitSignUp = () => (event) => {
    event.preventDefault();
    registrationApi.signUp({
      name: this.state.name,
      email: this.state.email,
      password: this.state.password,
      password_confirmation: this.state.password_confirmation
    })
      .then(
        this.setState({
          ...initialLoginState
        })
      )
      .then(() => {
          this.handleSnackbar({message: '登録完了するためのメールを送信しました'});
        }
      )
      .then(() => {
          this.handleSimpleDialog({
            title: '仮登録完了しました',
            content: '登録完了するためのメールを送信しました。メールを確認して、登録を完了してください。',
            open: true
          })
        }
      )
      .catch(error => {
        console.log(error);
      })
  };

  handleSimpleDialog = ({title, content, open}) => {
    this.setState({
      simpleDialogTitle: title,
      simpleDialogContent: content,
      simpleDialogOpen: open
    })
  };

  render() {
    return (
      <MuiThemeProvider theme={theme}>
        <div>
          <Header
            handleDialog={this.handleDialog}
            handleFormChange={this.handleFormChange}
            handleSubmitLogin={this.handleSubmitLogin}
            handleSubmitLogout={this.handleSubmitLogout}
            handleSubmitSignUp={this.handleSubmitSignUp}
            {...this.state}
          />
          <DraftBody
            {...this.state}
            handleFormChange={this.handleFormChange}
            handleSubmit={this.handleSubmit}
            handleDialog={this.handleDialog}
            handleChange={this.handleChange}
          />
          <DraftBottomNavigation
            bottomNavigationValue={this.state.bottomNavigationValue}
            handleChange={this.handleChange}
          />
          <Snackbar
            open={this.state.snackbarOpen}
            onRequestClose={this.handleSnackbarClose()}
            message={this.state.snackbarMessage}
          />
          <SimpleDialog
            handleDialog={this.handleDialog}
            {...this.state}
          />
        </div>
      </MuiThemeProvider>
    )
  }
}

export default DraftApp
```

あとはバケツリレー


# 所感
* axiosというか、promiseすごい便利。promiseの目的どおりだけど、今までインデントが深くなっていたチェーンがthenでつなぐだけでいいのでコードが読みやすいし、使いやすい
* ログイン・ログアウトの繰り返しでcsrf_tokenが更新されてしまう所の処理が結構苦労した

# 参考
* https://stackoverflow.com/questions/7203304/warning-cant-verify-csrf-token-authenticity-rails
* https://blog.andrewray.me/how-to-set-up-devise-ajax-authentication-with-rails-4-0/
* https://qiita.com/kazukgw@github/items/5e08ce893b709e16e1e1
* https://qiita.com/sukechansan/items/738b4bdaffc07c337a70