72
60

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-10-15

概要

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

環境

  • rails 5
  • deviseをインストールして初期設定が完了していること
  • react
  • axios
  • 拙記事の続きです

動作動画

ユーザー登録

https://gyazo.com/24d2cb8a08d600bb31c297d63dada928

ログイン

https://gyazo.com/687b9647f9917d0a3bb97eacc4a1abbb

ログアウト

https://gyazo.com/8e07487806509f3a4178428f27e2050b

手順

クロスオリジンからのリクエストの許容(devlopmentのみ)

下記を追加

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

ルーティングの修正

  • 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が更新されてしまう所の処理が結構苦労した

参考

72
60
1

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
72
60

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?