meteorのaccounts-passwordを使ったログイン認証画面のカスタマイズ

　MeteorはWebアプリを超高速に作れるプラットフォームで、いろいろ便利な機能を提供してくれているけど、その中でもユーザアカウント機能は本当に超簡単に追加することができます。accounts-password と accounts-uiの２つのパッケージをaddするだけで、ユーザ登録とログイン認証機能がすぐに実装できます。UI込みでです。（但しreactを使う時は少しのコーディングが必要ですが。）

　今回はaccounts-uiを使わずに、React + React-router + Material-ui でカスタマイズ画面を作って、以下の機能を実装しましたのでその報告です。accounts-passwordの機能だけを使っています。accounts-uiを使う場合はあまりにも簡単なのですが、使わない場合の例があまり見当たらないので、備忘録も兼ての報告です。

1.  ユーザ登録（メール確認無し）
2.  ユーザ登録（メール確認あり）
3. パスワード再発行
4. ログイン
5. ログアウト
6. パスワード変更


　実際にはマルチユーザ対応のカレンダーアプリを作りながら試しましたが、まずは以下のパッケージをインストールします。（カレンダーアプリについては公開できる時が来たら、またその報告を書きたいと思います。）

```sh:
$ meteor create mycalendar2
$ cd mycalendar2/
$ meteor npm install --save react react-dom prop-types react-router react-router-dom
$ meteor npm install --save material-ui react-tap-event-plugin formsy-react
$ meteor npm install --save react-big-calendar moment 
$ meteor add react-meteor-data
$ meteor add accounts-password
```

　まずnpmパッケージについてですが、meteor上でreactを使ってログイン／ユーザ登録等の複数の画面作りを行いますので、reactやreact-router関連のパッケージを入れます。デザインはMaterial-UIに任せます。Formのバリデーションにformsy-reactを使います。カレンダー表示のために react-big-calendar+momentを使います。

　次にmeteorパッケージについてです。meteorとreactの連携のためにreact-meteor-dataを使います。meteorのアプリですのでデータ管理については、今のところreduxは使わず、minimongoのみを使う予定です。アカウント関係についてですが、accounts-passwordをインストールすると、accounts-baseも同時にインストールされます。今回の目的はカスタマイズUIを作ることなので、accounts-uiは使いません。

　
# 1.ユーザ登録（メール確認無し）
　まず、ユーザテーブルについてですが、usernameとemail、passwordの３つの項目を持たせることにします。これは本文を通して変わりません。またreact画面については通常２つのcomponentを作って実装します。imports/ui/viewsにcontainerのcomponentを置きます。imports/ui/componentsに具体的な画面を定義するcomponentを置きます。

　メール確認なしのユーザ登録の場合は、ユーザ登録画面のcomponentでusernameとemail、passwordを入力させ、componentのイベントハンドラーでユーザ登録を行います。

```js:imports/ui/views/RegisterView.js
```

　ここのポイントはAccounts.createUser()を使って、クライアントから直接ユーザ登録を行っている点です。サーバ側のコードは必要ありません。これで登録したメールアドレスとパスワードでログインできるようになります。下位のRegisterFormのコードも示しておきます。

```js:imports/ui/components/RegisterForm.js
import React from 'react';
import Formsy from 'formsy-react';
import { RaisedButton, Paper } from 'material-ui';
import DefaultInput from './DefaultInput';

export default class RegisterForm extends React.Component {
  constructor(props) {
    super(props);
    this.enableButton = this.enableButton.bind(this);
    this.disableButton = this.disableButton.bind(this);
    this.state = {
      canSubmit: false
    }; 
  }

  enableButton() {
    this.setState({
      canSubmit: true
    });
  }

  disableButton() {
    this.setState({
        canSubmit: false
    });
  }

  render() {
    return (
      <Formsy.Form onSubmit={this.props.onSubmit} onValid={this.enableButton} onInvalid={this.disableButton}>
        <Paper zDepth={1} style={{padding: 32}}>
          <h3>ユーザ登録フォーム</h3>
          <DefaultInput name='username' title='ユーザ名' value="" required />
          <DefaultInput name='email' validations='isEmail' validationError='This is not a valid email' title='メール' value="" required />
          <DefaultInput type='password' name='password' title='パスワード' value="" required />
          <div style={{marginTop: 24}}>
            <RaisedButton
            disabled={!this.state.canSubmit}
            secondary={true}
            type="submit"
            style={{margin: '0 auto', display: 'block', width: 150}}
            label={'登録'} />
          </div>
        </Paper>
    </Formsy.Form>
    );
  }
}
```

# 2.ユーザ登録（メール確認あり）
　ユーザ登録時にメール確認を必要とする場合です。今回は、メール送信を行いますので、サーバ側のコードを必要とします。ユーザ登録画面にはパスワード入力欄の必要はありません。以下にコードを示します

```js:imports/ui/views/RegisterView.js
import React from 'react';
import RegisterForm from '../components/RegisterForm.js';
import { handleErrorFunc } from '../App';


export default class RegisterView extends React.Component {
  constructor(props) {
    super(props);
    this.register = this.register.bind(this);
    this.state = {
      error: null
    };
  }


  register (newUser) {
{"username":"taro","email":"xxxxx@gmail.com"}

      Meteor.call('createNewUser', newUser, (err,result) => {
          if (err) {
            handleErrorFunc(err.message);
          } else {
            this.props.history.push({pathname: '/login'}); 
          }
      });
  }

  render () {
    return (
      <div>
        <h1>登録画面</h1>
        <div style={{maxWidth: 450, margin: '0 auto'}}>
          <RegisterForm
            onSubmit={this.register} />
        </div>
      </div>
    );
  }
}
```

　登録のハンドラーはMeteor.call('createNewUser', newUser, ...)でサーバ側のコードを呼ぶ点が前回と大きく異なります。newUserにはpassword情報はありません。下位のcomponentはpassword欄が無い点のみが異なります。

```js:imports/ui/components/RegisterForm.js
import React from 'react';
import Formsy from 'formsy-react';
import { RaisedButton, Paper } from 'material-ui';
import DefaultInput from './DefaultInput';

export default class RegisterForm extends React.Component {
  constructor(props) {
    super(props);
    this.enableButton = this.enableButton.bind(this);
    this.disableButton = this.disableButton.bind(this);
    this.state = {
      canSubmit: false
    }; 
  }

  enableButton() {
    this.setState({
      canSubmit: true
    });
  }

  disableButton() {
    this.setState({
        canSubmit: false
    });
  }

  render() {

    return (
      <Formsy.Form onSubmit={this.props.onSubmit} onValid={this.enableButton} onInvalid={this.disableButton}>
        <Paper zDepth={1} style={{padding: 32}}>
          <h3>ユーザ登録フォーム</h3>
          <DefaultInput name='username' title='ユーザ名' value="" required />
          <DefaultInput name='email' validations='isEmail' validationError='This is not a valid email' title='メール' value="" required />
          <div style={{marginTop: 24}}>
            <RaisedButton
            disabled={!this.state.canSubmit}
            secondary={true}
            type="submit"
            style={{margin: '0 auto', display: 'block', width: 150}}
            label={'登録'} />
          </div>
        </Paper>
    </Formsy.Form>
    );
  }
}
```

　サーバ側ですが、server/main.jsでメール内容のカスタマイズを行い、imports/api/events.jsでmethodのサーバ側コードを書いておきます。


```js:server/main.js
import { Meteor } from 'meteor/meteor';
import '../imports/api/events.js';

Meteor.startup(() => {
  // code to run on server at startup

Accounts.emailTemplates.siteName = "マイカレンダー";
Accounts.emailTemplates.from = "マイカレンダー <no-reply@mypress.jp>";

Accounts.emailTemplates.enrollAccount = {
  subject(user) {
    return "（マイカレンダー）パスワード設定";
  },
  text(user, url) {
    url = url.replace('\#\/enroll-account', 'reset2-password')
    const mess = `ユーザ登録が成功しました。
以下のリンクをクリックするとパスワードリセット画面に飛びます。
（注意）このメールに返信は行わないようお願いします。

`; // end
    return(mess+url);
  }
};

});
```

　meteorのAPIであるAccounts.emailTemplatesを使うことによって、メール内容をカスタマイズしています。公式サイトをご確認ください。
http://docs.meteor.com/api/passwords.html

ここでの注意点は以下のコードです。

```js:
    url = url.replace('\#\/enroll-account', 'reset2-password')
```

　メールには、urlが含まれ、クリックするとパスワードリセット画面に飛ぶようになっています。urlにはtokenが含まれておりリセット画面で安全に自分のパスワードを設定することが可能となっています。ここではurlを自分のアプリ画面(routerで指定しているcomponent)に飛ぶように変更しています。以下にurl置換の例を示します。末尾のいかにも暗号みたいな文字列がtokenです。

```:url置換の例
【置換前】http://www.mypress.jp:3000/#/enroll-account/T9EH-_B52wKgci-_LrljWjEk1yJdj0LF5D14q88pw3o
【置換後】http://www.mypress.jp:3000/reset2-password/T9EH-_B52wKgci-_LrljWjEk1yJdj0LF5D14q88pw3o
```

　次にサーバ側のmethodです。

```js:imports/api/events.js
if (Meteor.isServer) {    
  Meteor.methods({
    createNewUser: function (newUser) {
      const current_pass = newUser.username + (Math.floor( Math.random() * 10001 ) + 12345);
      const userId = Accounts.createUser({username: newUser.username, email: newUser.email, password: current_pass});
      Accounts.sendEnrollmentEmail(userId);
    }
  });
}
```

　前回はクライアント側で呼んでいたAccounts.createUser()を、今回はサーバ側で呼んでいます。大きく異なるのは、パスワードは適当に乱数で生成している点です。このパスワードは後で、パスワードリセット画面で上書きされますので、使われることのないものです。最後にAccounts.sendEnrollmentEmail(userId)でメールを送信します。内容はmain.jsで設定したものとなります。メールに含まれるurlをクリックするとパスワードリセット画面に飛びます。


```js:imports/ui/views/ResetPasswordView.js
import React from 'react';
import ResetPasswordForm from '../components/ResetPasswordForm.js';
import { userIdFunc, handleErrorFunc } from '../App';

export default class ResetPasswordView extends React.Component {
  constructor(props) {
    super(props);
    this.resetPassword = this.resetPassword.bind(this);
  }

  resetPassword(cred) {
    const token = this.props.match.params.token;
    Accounts.resetPassword(token, cred.password, (err) => {
      if (err) {
        handleErrorFunc(err.message); //親コンポネントにエラーを表示する
      } else {
        userIdFunc(); //親コンポネントのメニュー表示を変更する
        this.props.history.push({pathname: '/'});
      }
    });
  }

  render () {
    return (
      <div>
        <h1>パスワードリセット画面</h1>
        <div style={{maxWidth: 450, margin: '0 auto'}}>
          <ResetPasswordForm
            onSubmit={this.resetPassword} />
        </div>
      </div>
    );
  }
}
```

　ここではAccounts.resetPassword(token, cred.password,...)を使ってパスワードをリセットします。tokenがその権利を保障してくれます。　リセット画面はreact-routerで以下のように定義されていますので、this.props.match.params.tokenで取り出すことができます。

```:リセット画面のRoute定義
 <Route path='/reset2-password/:token' component={ResetPasswordView} name='reset' />
```


　最後にmeteor上でのメール送信には以下の環境変数の設定が必要となります。嵌りどころなので重要です。

```:メール送信の環境変数
export MAIL_URL='smtp://localhost:25'
export ROOT_URL='http://www.mypress.jp:3000/'
```


# 3.パスワード再発行
　パスワード再発行は、再発行画面でメールを入力させ、そのメールに前回と同じパスワードリセット画面へのurlを含めます。リセット画面でパスワードを上書きさせることで再発行とします。

　以下がパスワード再発行画面のcomponentです。下位のRecoverFormは他と同じですので省略します。

```js:imports/ui/RecoverView.js
import React from 'react';
import RecoverForm from '../components/RecoverForm.js';
import { userIdFunc, handleErrorFunc } from '../App';

export default class RecoverView extends React.Component {
  constructor(props) {
    super(props);
    this.recover = this.recover.bind(this);
  }

  recover(cred) {
    Accounts.forgotPassword({ email: cred.email }, (err) => {
      if (err) {
        handleErrorFunc(err.message);
      } else {
        userIdFunc();
        this.props.history.push({pathname: '/'}); 
      }
    });
  }

  render () {
    return (
      <div>
        <h1>パスワード再発行画面</h1>
        <div style={{maxWidth: 450, margin: '0 auto'}}>
          <RecoverForm
            onSubmit={this.recover} />
        </div>
      </div>
    );
  }
}
```

　Accounts.forgotPassword()は、以下のserver.jsのカスタマイズに従って、入力されたメールアドレス宛にメールを送信します。

```js:server/main.js
import { Meteor } from 'meteor/meteor';
import '../imports/api/events.js';

Meteor.startup(() => {
  // code to run on server at startup
Accounts.emailTemplates.siteName = "マイカレンダー";
Accounts.emailTemplates.from = "マイカレンダー <no-reply@mypress.jp>";

Accounts.emailTemplates.resetPassword = {
  subject(user) {
    return "（マイカレンダー）パスワードリセット";
  },
  text(user, url) {
    url = url.replace('\#\/reset', 'reset2')
    const mess = `以下のリンクをクリックするとパスワードリセット画面に飛びます。
（注意）このメールに返信は行わないようお願いします。

`; // end
    return(mess+url);
  }
};

});
```

　前回はAccounts.emailTemplates.enrollAccountでしたが、今回はAccounts.emailTemplates.resetPasswordをカスタマイズします。内容は同じで、パスワードリセット画面に飛ばすためのurlを含みます。安心してください。tokenもついています。


# 4.ログイン
　ログインは今までと同じようなcomponentを2つ作るだけです。下位componentは今までと同じようですので省略します。

```js:imports/ui/views/LoginView.js
import React from 'react';
import LoginForm from '../components/LoginForm.js';
import { userIdFunc, handleErrorFunc } from '../App';

export default class LoginView extends React.Component {
  constructor(props) {
    super(props);
    this.login = this.login.bind(this);
  }

  login(cred) {
    Meteor.loginWithPassword(cred.email, cred.password, (err) => {
      if (err) {
        handleErrorFunc(err.message);
      } else {
        userIdFunc();
        this.props.history.push({pathname: '/calendar'}); 
      }
    });
  }

  render () {
    return (
      <div>
        <h1>ログイン画面</h1>
        <div style={{maxWidth: 450, margin: '0 auto'}}>
          <LoginForm
            onSubmit={this.login} />
        </div>
      </div>
    );
  }
}
```

　ここで使うAPIはMeteor.loginWithPassword(cred.email, cred.password,...)です。

# 5.ログアウト

　ログアウトはログインすると、ログイン・メニューに代わり表示されるります。リンクをクリックされるとcomponentDidMountの中でMeteor.logout()を呼んですぐにログアウトするようにしています。

```js:imports/ui/views/LogoutView.js
"use strict";
import React from 'react';
import { Link } from 'react-router-dom'
import { Paper } from 'material-ui';
import { userIdFunc, handleErrorFunc } from '../App';

class LogoutView extends React.Component {
  constructor(props) {
    super(props);
  }
  componentDidMount() {
    Meteor.logout( (err) => {
      if (err) {
        handleErrorFunc(err.message);
      } else {
        userIdFunc();
      }
    });
  }

  render () {
    return (
      <div style={{width: 400, margin: "auto"}}>
        <Paper zDepth={3} style={{padding: 32, margin: 32}}>
          ログアウト成功！
        </Paper>
        <Link to='/'>ホーム</Link> 
      </div>
    );
  }
}
export default LogoutView;
```

# 6.パスワード変更
　パスワード変更もログインすると表示されるようになります。APIとしてはAccounts.changePassword(cred.password1, cred.password2, ...)を使い、現在のパスワードと新パスワードを指定することでパスワードを変更します。

```js:imports/ui/views/ChangePasswordView.js
import React from 'react';
import ChangePasswordForm from '../components/ChangePasswordForm.js';
import { userIdFunc, handleErrorFunc } from '../App';

export default class LoginView extends React.Component {
  constructor(props) {
    super(props);
    this.change = this.change.bind(this);
  }

  /* cred.password1=現パスワード、cred.password2=新パスワード、cred.password3=確認用パスワード */
  change(cred) {
    if(cred.password2 !== cred.password3) {
        handleErrorFunc('エラー：確認用パスワードが一致しません。');
    } else {
      Accounts.changePassword(cred.password1, cred.password2, (err) => {
          if (err) {
            handleErrorFunc(err.message);
          } else {
            this.props.history.push({pathname: '/calendar'}); 
          }
      });
    }
  }

  render () {
    return (
      <div>
        <h1>パスワード変更画面</h1>
        <div style={{maxWidth: 450, margin: '0 auto'}}>
          <ChangePasswordForm
            onSubmit={this.change} />
        </div>
      </div>
    );
  }
}
```