Offline-first приложение с Hoodie & React. Часть вторая: авторизация

в 10:14, , рубрики: couchdb, hoodie, javascript, pouchdb, react.js, ReactJS, Разработка веб-сайтов

Наша цель, написать offline-first приложение — SPA которое загружается и сохраняет полную функциональность в отсутствии интернет-соединения. В первой части повествования мы научились пользоваться браузерной базой данных. Сегодня мы настроим синхронизацию с серверной бд и подключим авторизацию. В результате мы получим возможность редактировать наши данные на разных устройствах даже в оффлайне с последующей синхронизацией при появлении соединения.

CouchDB

Да, на сервере нам потребуется именно эта база данных. В настоящий момент активно разрабатывается Pouchdb-Server, который на базе LevelDB имитирует API CouchDB. Hoodie по умолчанию работает с ним, это сделано с целью упрощения установки для новичков. Но он сыр даже для целей разработки. Возможно, мне просто повезло, но я потратил 3 дня впервые пытаясь завести Hoodie и натыкаясь на странные ошибки, 3 дня issues и pull-request-ов. И на грани разочарования решил-таки установить нормальную CouchDB и все мои проблемы кончились. Поэтому я предлагаю вам сразу поставить последнюю, разве что вы тоже хотите предварительно внести посильный вклад в opensource.

В большинстве дистрибутивов CouchDB ставится штатными средствами.

Если же вы тоже используете debian

Вот инструкция которой пользовался я. Однако, база постоянно падала пока я не удалил /etc/init.d/coucdb и не отдал её под надзор supervisord-а, вот конфиг последнего:

[program:couchdb]
user=couchdb
environment=HOME=/usr/local/var/lib/couchdb
command=/usr/local/bin/couchdb
autorestart=true
stdout_logfile=NONE
stderr_logfile=NONE

Поставив базу создаём админа:

curl -X PUT $HOST/_config/admins/username -d '"password"'

И включаем CORS:

npm install -g add-cors-to-couchdb
add-cors-to-couchdb -u username -p password

Теперь остаётся лишь немного поправить команду для запуска сервера в package.json:

"server": "hoodie --port 8000 --dbUrl 'http://username:password@127.0.0.1:5984'"

Надеюсь, у вас всё получилось :)

Авторизация

В AppBar-е у нас будет иконка авторизации со контекстным меню. Поэтому мы вынесем его в отдельную компоненту и будем использовать её в App.js вместо AppBar:

import NavBar from './NavBar'

<NavBar account={hoodie.account} />

Туда мы передаём hoodie.account который предоставляет нам API для авторизации:

  • hoodie.account.SignUp({username, password)}
  • hoodie.account.SignIn({username, password)}
  • hoodie.account.SingOut()

И события на которые можно подписаться:

  • hoodie.account.on('signin', callback)
  • hoodie.account.on('signout', callback)

А вот и сама компонента:

NavBar.js

import React from 'react'
import AppBar from 'material-ui/AppBar'
import FlatButton from 'material-ui/FlatButton'
import IconButton from 'material-ui/IconButton'
import IconMenu from 'material-ui/IconMenu'
import MenuItem from 'material-ui/MenuItem'
import KeyIcon from 'material-ui/svg-icons/communication/vpn-key'
import AccountIcon from 'material-ui/svg-icons/action/account-circle'

import AuthDialog from './AuthDialog'

export default class NavBar extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      isSignedIn: this.props.account.isSignedIn(),
      openedDialog: null
    }
  }

  signOutCallback = () => this.setState({isSignedIn: false})
  signInCallback = () => this.setState({isSignedIn: true})

  componentDidMount() {
    this.props.account.on('signout', this.signOutCallback)
    this.props.account.on('signin', this.signInCallback)
  }

  componentWillUnmount() {
    this.props.account.on('signout', this.signOutCallback)
    this.props.account.on('signin', this.signInCallback)
  }

  render () {
    let authMenu;

    if (this.state.isSignedIn) {
      authMenu = (
        <IconMenu
          iconButtonElement={<IconButton><AccountIcon /></IconButton>}
          targetOrigin={{horizontal: 'right', vertical: 'top'}}
          anchorOrigin={{horizontal: 'right', vertical: 'top'}}
        >
          <MenuItem primaryText="Sign Out" onTouchTap={() => this.props.account.signOut()} />
        </IconMenu>
      )
    } else {
      authMenu = (
        <IconMenu
          iconButtonElement={<IconButton><KeyIcon /></IconButton>}
          targetOrigin={{horizontal: 'right', vertical: 'top'}}
          anchorOrigin={{horizontal: 'right', vertical: 'top'}}
        >
          <MenuItem primaryText="Sign Up" onTouchTap={() => this.setState({openedDialog: 'signup'})} />
          <MenuItem primaryText="Sign In" onTouchTap={() => this.setState({openedDialog: 'signin'})} />
        </IconMenu>
      )
    }

    return (
      <div>
        <AppBar
          title="Action Loop"
          showMenuIconButton={false}
          iconElementRight={authMenu}
        />
        <AuthDialog
          account={this.props.account}
          action={this.state.openedDialog}
          handleClose={() => this.setState({openedDialog: null})}
        />
      </div>
    )
  }
}

В state у нас лежит текущее состояние авторизации для отрисовки иконки и меню. И открытый в данный момент диалог (регистрация, вход или null — если всё закрыто). В componentDidMount мы подписываемся на события входа и выхода. И в render отображаем нужную иконку в соответствии с состоянием авторизации. Осталось нарисовать диалог авторизации:

AuthDialog.js

import React from 'react';
import Dialog from 'material-ui/Dialog';
import FlatButton from 'material-ui/FlatButton';
import TextField from 'material-ui/TextField';

export default class AuthDialog extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      username: '',
      password: '',
    };
  }

  handleConfirm = () => {
    const account = this.props.account;
    const username = this.state.username.trim();
    const password = this.state.password.trim();

    if (!username || !password) {
      return;
    }

    if (this.props.action == 'signup') {
      account.signUp({username, password})
        .then(() => {
          return account.signIn({username, password})
        })
        .catch(console.error)
    } else {
      account.signIn({username, password})
        .catch(console.error)
    }
    this.props.handleClose();
    this.clearState();
  }

  handleCancel = () => {
    this.props.handleClose();
    this.clearState();
  }

  handleSubmit = (e) => {
    e.preventDefault();
    this.handleConfirm();
  }

  clearState = () => {
    this.setState({
      username: '',
      password: ''
    })
  }

  render () {
    const buttons = [
      <FlatButton
        label="Cancel"
        primary={true}
        onTouchTap={this.handleCancel}
      />,
      <FlatButton
        label="Submit"
        type="submit"
        primary={true}
        keyboardFocused={true}
        onTouchTap={this.handleConfirm}
      />
    ];

    return (
      <div>
        <Dialog
          title={this.props.action == 'signup' ? 'Sign Up' : 'Sign In'}
          actions={buttons}
          modal={false}
          open={this.props.action !== null}
          onRequestClose={this.handleCancel}
          contentStyle={{maxWidth: 400}}
        >
          <form onSubmit={this.handleSubmit}>
            <TextField
              name="username"
              floatingLabelText="Username"
              onChange={(e) => this.setState({username: e.target.value})}
            />
            <TextField
              name="password"
              floatingLabelText="Password"
              onChange={(e) => this.setState({password: e.target.value})}
            />
          </form>
        </Dialog>
      </div>
    );
  }
}

Диалоги регистрации и входа у нас имеют идентичные поля формы, поэтому мы объединим их в один. Логика компоненты элементарна: в handleConfirm мы либо входим либо сначала регистрируемся, а затем входим.

Осталось перезагрузить сами loop-ы при авторизации. Добавим реакцию на события в App.js:

  componentDidMount() {
    hoodie.store.on('change', this.loadLoops);
    hoodie.account.on('signin', this.loadLoops)
    hoodie.account.on('signout', this.loadLoops)
  }

  componentWillUnmount() {
    hoodie.store.off('change', this.loadLoops);
    hoodie.account.off('signin', this.loadLoops)
    hoodie.account.off('signout', this.loadLoops)
  }

Конец

Итак, авторизация готова. Наибольшим вызовом этой части была, вероятно, установка CouchDB. Теперь наше приложение сохранит свою функциональность при разрыве соединения, а при появлении синхронизируется. Однако, если совсем закрыть сайт, открыть его без интернета не получится. Мы исправим это в следующей, финальной части.

» Код этой части доступен тут: https://github.com/imbolc/action-loop под тегом part2.

Автор: Imbolc

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js