概要
- webで使っているdeviseにSPAからログインできるようにapiを追加
- 実際にログイン。ログアウトするボタンをreact + axiosで実装
環境
- rails 5
- deviseをインストールして初期設定が完了していること
- react
- axios
- 拙記事の続きです
動作動画
ユーザー登録
ログイン
ログアウト
手順
クロスオリジンからのリクエストの許容(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が更新されてしまう所の処理が結構苦労した