Создание приложения, использующего JWT для хранения данных аутентификации

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

Веб-токены JSON (JWT) - один из наиболее распространенных способов хранения данных аутентификации в интерфейсных приложениях. В Node.js есть популярные библиотеки, которые могут генерировать и проверять JWT, проверяя его подлинность, проверяя секретный ключ, хранящийся в серверной части, а также проверяя дату истечения срока действия.

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

В этой части мы создадим приложение, которое использует JWT для хранения данных аутентификации. На внутренней стороне мы будем использовать платформу Express, которая работает на Node.js, и пакет jsonwebtoken для генерации и проверки токена. Для внешнего интерфейса мы будем использовать фреймворк Angular и модуль @auth0/angular-jwt для Angular. В нашем приложении, когда пользователь вводит имя пользователя и пароль, и они находятся в нашей базе данных, JWT будет сгенерирован из нашего секретного ключа, возвращен пользователю и сохранен во внешнем приложении в локальном хранилище. Всякий раз, когда пользователю необходимо получить доступ к аутентифицированным маршрутам на серверной части, ему понадобится токен. В серверном приложении будет функция, называемая промежуточным программным обеспечением, для проверки действительного токена. Действительный токен - это токен, срок действия которого не истек и который проверяется на соответствие нашему секретному ключу. Там также будут страницы регистрации и настройки учетных данных пользователя в дополнение к странице входа.

Теперь, когда у нас есть план, мы можем начать с создания папок внешнего и внутреннего приложений. Сделайте по одному для каждого. Затем мы начинаем писать серверное приложение. Сначала мы устанавливаем несколько пакетов и генерируем скелетный код Express. Мы запускаем npx express-generator, чтобы сгенерировать код. Затем нам нужно установить несколько пакетов. Мы делаем это, запустив npm i @babel/register express-jwt sequelize bcrypt sequelize-cli dotenv jsonwebtoken body-parser cors. @babel/register позволяет нам использовать новейшие функции JavaScript. express-jwt генерирует JWT и проверяет его на секретность. bcrypt выполняет хеширование и обработку наших паролей. sequelize - это наша ORM для выполнения CRUD. cors позволяет нашему приложению Angular взаимодействовать с нашей серверной частью, разрешая междоменное взаимодействие. dotenv позволяет нам хранить переменные среды в .env файле. body-parser необходим Express для анализа запросов JSON.

Затем мы выполняем миграции нашей базы данных. Запустите npx sequelize-cli init, чтобы сгенерировать скелетный код нашей базы данных для сопоставления объектов. Затем запускаем:

npx sequelize-cli model:generate --name User --attributes username:string, password:string, email:string

Делаем еще одну миграцию и ставим:

'use strict';
module.exports = {
  up: (queryInterface, Sequelize) => {
    return Promise.all([
      queryInterface.addConstraint(
        "Users",
        ["email"],
        {
          type: "unique",
          name: 'emailUnique'
        }),
      queryInterface.addConstraint(
        "Users",
        ["userName"],
        {
          type: "unique",
          name: 'userNameUnique'
        }),
  },
  down: (queryInterface, Sequelize) => {
    return Promise.all([
      queryInterface.removeConstraint(
        "Users",
        'emailUnique'
      ),
      queryInterface.removeConstraint(
        "Users",
        'userNameUnique'
      ),
    ])
  }
};

Это гарантирует, что у нас не будет повторяющихся записей с одним и тем же именем пользователя или адресом электронной почты.

Это создает User модель и таблицу Users после запуска npx sequelize-cli db:migrate.

Напишем код. Поместите в app.js следующее:

require("@babel/register");
require("babel-polyfill");
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const user = require('./controllers/userController');
const app = express();
app.use(cors())
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use((req, res, next) => {
  res.locals.session = req.session;
  next();
});

app.use('/user', user);
app.get('*', (req, res) => {
  res.redirect('/home');
});
app.listen((process.env.PORT || 8080), () => {
  console.log('App running on port 8080!');
});

Нам нужно:

require("@babel/register");
require("babel-polyfill");

использовать новейшие функции JavaScript.

А нам понадобятся:

require('dotenv').config();

чтобы прочитать нашу конфигурацию в .env файле.

Это точка входа. Вскоре мы создадим userController в папке controllers.

app.use(‘/user’, user); направляет любой URL, начинающийся с user, в userController файл.

Затем мы добавляем файл userController.js:

const express = require('express');
const bcrypt = require('bcrypt');
const router = express.Router();
const models = require('../models');
const jwt = require('jsonwebtoken');
import { authCheck } from '../middlewares/authCheck';
router.post('/login', async (req, res) => {
    const secret = process.env.JWT_SECRET;
    const userName = req.body.userName;
    const password = req.body.password;
    if (!userName || !password) {
        return res.send({
            error: 'User name and password required'
        })
    }
    const users = await models.User.findAll({
        where: {
            userName
        }
    })
    const user = users[0];
    if (!user) {
        res.status(401);
        return res.send({
            error: 'Invalid username or password'
        });
    }
  try {
        const compareRes = await bcrypt.compare(password, user.hashedPassword);
        if (compareRes) {
            const token = jwt.sign(
                {
                    data: {
                        userName,
                        userId: user.id
                    }
                },
                secret,
                { expiresIn: 60 * 60 }
            );
            return res.send({ token });
        }
        else {
            res.status(401);
            return res.send({
                error: 'Invalid username or password'
            });
        }
    }
    catch (ex) {
        logger.error(ex);
        res.status(401);
        return res.send({
            error: 'Invalid username or password'
        });
    }
});
router.post('/signup', async (req, res) => {
    const userName = req.body.userName;
    const email = req.body.email;
    const password = req.body.password;
    try {
        const hashedPassword = await bcrypt.hash(password, 10)
        await models.User.create({
            userName,
            email,
            hashedPassword
        })
        return res.send({ message: 'User created' });
    }
    catch (ex) {
        logger.error(ex);
        res.status(400);
        return res.send({ error: ex });
    }
});
router.put('/updateUser', authCheck, async (req, res) => {
    const userName = req.body.userName;
    const email = req.body.email;
    const token = req.headers.authorization;
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const userId = decoded.data.userId;
    try {
        await models.User.update({
            userName,
            email
        }, {
                where: {
                    id: userId
                }
            })
        return res.send({ message: 'User created' });
    }
    catch (ex) {
        logger.error(ex);
        res.status(400);
        return res.send({ error: ex });
    }
});
router.put('/updatePassword', authCheck, async (req, res) => {
    const token = req.headers.authorization;
    const password = req.body.password;
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const userId = decoded.data.userId;
    try {
        const hashedPassword = await bcrypt.hash(password, saltRounds)
        await models.User.update({
            hashedPassword
        }, {
                where: {
                    id: userId
                }
            })
        return res.send({ message: 'User created' });
    }
    catch (ex) {
        logger.error(ex);
        res.status(400);
        return res.send({ error: ex });
    }
});
module.exports = router;

Маршрут login ищет запись пользователя, а затем, если она найдена, проверяет хешированный пароль с помощью compare функции bcrypt. Если оба успешны, создается JWT. Маршрут signup получает полезную нагрузку JSON, состоящую из имени пользователя и пароля, и сохраняет их. Обратите внимание, что перед сохранением пароля выполняется хеширование и обработка. Пароли не должны храниться в виде обычного текста. bcrypt.hash принимает два аргумента. Это первый пароль в виде обычного текста, а второй - количество раундов соли.

updatePassword маршрут - это аутентифицированный маршрут. Он проверяет токен и, если он действителен, продолжает сохранять пароль пользователя, выполняя поиск User с идентификатором пользователя, который соответствует декодированному токену. Далее мы добавим промежуточное ПО authCheck.

Создайте папку middlewares и создайте в ней authCheck.js.

const jwt = require('jsonwebtoken');
const secret = process.env.JWT_SECRET;
export const authCheck = (req, res, next) => {
    if (req.headers.authorization) {
        const token = req.headers.authorization;
        jwt.verify(token, secret, (err, decoded) => {
            if (err) {
                res.send(401);
            }
            else {
                next();
            }
        });
    }
    else {
        res.send(401);
    }
}

Это позволяет нам проверять аутентификацию на аутентифицированных маршрутах без повторения кода. Мы помещаем if между URL-адресом и нашим основным кодом маршрута в каждом аутентифицированном маршруте, импортируя его и ссылаясь на него.

Мы создаем .env файл из корня папки внутреннего приложения со следующим содержимым:

DB_HOST='localhost'
DB_NAME='login-app'
DB_USERNAME='db-username'
DB_PASSWORD='db-password'
JWT_SECRET='secret'

Бэкэнд-приложение готово. Теперь перейдем к клиентскому приложению Angular.

Переключитесь в папку своего внешнего приложения. Чтобы создать приложение Angular, вам понадобится Angular CLI.

Чтобы установить его, запустите npm i -g @angular/cli в командной строке Node.js. Затем запустите ng new frontend, чтобы сгенерировать скелетный код для своего внешнего приложения.

Также установите @angular/material согласно документации по Angular.

После этого замените значение по умолчанию app.module.ts следующим:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import {
  MatButtonModule,
  MatCheckboxModule,
  MatInputModule,
  MatMenuModule,
  MatSidenavModule,
  MatToolbarModule,
  MatTableModule,
  MatDialogModule,
  MAT_DIALOG_DEFAULT_OPTIONS,
  MatDatepickerModule,
  MatSelectModule,
  MatCardModule
} from '@angular/material';
import { MatFormFieldModule } from '@angular/material/form-field';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers';
import { FormsModule } from '@angular/forms';
import { TopBarComponent } from './top-bar/top-bar.component';
import { HomePageComponent } from './home-page/home-page.component';
import { LoginPageComponent } from './login-page/login-page.component';
import { SignUpPageComponent } from './sign-up-page/sign-up-page.component';
import { SettingsPageComponent } from './settings-page/settings-page.component';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { SessionService } from './session.service';
import { HttpReqInterceptor } from './http-req-interceptor';
import { UserService } from './user.service';
import { CapitalizePipe } from './capitalize.pipe';
@NgModule({
  declarations: [
    AppComponent,
    TopBarComponent,
    HomePageComponent,
    LoginPageComponent,
    SignUpPageComponent,
    SettingsPageComponent,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    StoreModule.forRoot(reducers),
    BrowserAnimationsModule,
    MatButtonModule,
    MatCheckboxModule,
    MatFormFieldModule,
    MatInputModule,
    MatMenuModule,
    MatSidenavModule,
    MatToolbarModule,
    MatTableModule,
    FormsModule,
    HttpClientModule,
    MatDialogModule,
    MatDatepickerModule,
    MatMomentDateModule,
    MatSelectModule,
    MatCardModule,
    NgxMaterialTimepickerModule
  ],
  providers: [
    SessionService,
    {
      provide: HTTP_INTERCEPTORS,
      useClass: HttpReqInterceptor,
      multi: true
    },
    UserService,
    {
      provide: MAT_DIALOG_DEFAULT_OPTIONS,
      useValue: { hasBackdrop: false }
    },
  ],
  bootstrap: [AppComponent],
})
export class AppModule { }

Это создает все зависимости и компоненты, которые мы добавим. Чтобы упростить аутентифицированные запросы с помощью нашего токена, мы создаем перехватчик HTTP-запросов, создав http-req-interceptor.ts:

import { Injectable } from '@angular/core';
import {
    HttpEvent,
    HttpInterceptor,
    HttpHandler,
    HttpResponse,
    HttpErrorResponse,
    HttpRequest
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '../environments/environment'
import { map, filter, tap } from 'rxjs/operators';
import { Router } from '@angular/router';
@Injectable()
export class HttpReqInterceptor implements HttpInterceptor {
    constructor(
        public router: Router
    ) { }
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        let modifiedReq = req.clone({});
        if (localStorage.getItem('token')) {
            modifiedReq = modifiedReq.clone({
                setHeaders: {
                    authorization: localStorage.getItem('token')
                }
            });
        }
    return next.handle(modifiedReq).pipe(tap((event: HttpEvent<any>) => {
            if (event instanceof HttpResponse) {}
        });
    }
}

Мы устанавливаем токен во всех запросах, кроме запроса на вход.

В нашем environments/environment.ts у нас есть:

export const environment = {
  production: false,
  apiUrl: 'http://localhost:8080'
};

Это указывает на URL нашей серверной части.

Теперь нам нужно сделать нашу боковую навигацию. Мы хотим добавить @ngrx/store для хранения состояния боковой навигационной панели. Устанавливаем пакет, запустив npm install @ngrx/store --save. Мы добавляем наш редуктор, запустив ng add @ngrx/store, чтобы добавить наши редукторы.

Мы добавляем menu-reducers.ts для централизованной установки состояния в нашем хранилище потоков:

const TOGGLE_MENU = 'TOGGLE_MENU';
function menuReducer(state, action) {
    switch (action.type) {
        case TOGGLE_MENU:
            state = action.payload;
            return state;
        default:
            return state
    }
}
export { menuReducer, TOGGLE_MENU };

Чтобы связать наш редуктор с другими частями приложения, введите в index.ts следующее:

import { menuReducer } from './menu-reducer';
import { tweetsReducer } from './tweets-reducer';
export const reducers = {
  menu: menuReducer,
};

Чтобы получить внешний вид нашего Материального дизайна, добавьте в style.css следующее:

/* You can add global styles to this file, and also import other style files */
@import "~@angular/material/prebuilt-themes/indigo-pink.css";
body {
  font-family: "Roboto", sans-serif;
  margin: 0;
}
form {
  mat-form-field {
    width: 95vw;
    margin: 0 auto;
  }
}
.center {
  text-align: center;
}

Между тегами head в index.html мы добавляем:

<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

Затем мы добавляем службу для пользовательских функций, запустив ng g service user. Будет создано user.service.ts. Затем мы помещаем:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from 'src/environments/environment';
import { Router } from '@angular/router';
import { JwtHelperService } from "@auth0/angular-jwt";
const helper = new JwtHelperService();
@Injectable({
  providedIn: 'root'
})
export class UserService {
  constructor(
    private http: HttpClient,
    private router: Router
  ) { }
  signUp(data) {
    return this.http.post(`${environment.apiUrl}/user/signup`, data);
  }
  updateUser(data) {
    return this.http.put(`${environment.apiUrl}/user/updateUser`, data);
  }
  updatePassword(data) {
    return this.http.put(`${environment.apiUrl}/user/updatePassword`, data);
  }
  login(data) {
    return this.http.post(`${environment.apiUrl}/user/login`, data);
  }
  logOut() {
    localStorage.clear();
    this.router.navigate(['/']);
  }
  isAuthenticated() {
    try {
      const token = localStorage.getItem('token');
      const decodedToken = helper.decodeToken(token);
      const isExpired = helper.isTokenExpired(token);
      return !!decodedToken && !isExpired;
    }
    catch (ex) {
      return false;
    }
  }
}

Каждая функция запрашивает подписку на HTTP-запрос, за исключением функции isAuthenticated, которая используется для проверки действительности токена.

Нам также нужна маршрутизация для нашего приложения, чтобы мы могли видеть страницы при переходе по URL-адресам, перечисленным ниже. В app-routing.module.ts мы помещаем:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomePageComponent } from './home-page/home-page.component';
import { LoginPageComponent } from './login-page/login-page.component';
import { SignUpPageComponent } from './sign-up-page/sign-up-page.component';
import { TweetsPageComponent } from './tweets-page/tweets-page.component';
import { SettingsPageComponent } from './settings-page/settings-page.component';
import { PasswordResetRequestPageComponent } from './password-reset-request-page/password-reset-request-page.component';
import { PasswordResetPageComponent } from './password-reset-page/password-reset-page.component';
import { IsAuthenticatedGuard } from './is-authenticated.guard';
const routes: Routes = [
  { path: 'login', component: LoginPageComponent },
  { path: 'signup', component: SignUpPageComponent },
  { path: 'settings', component: SettingsPageComponent, canActivate: [IsAuthenticatedGuard] },
  { path: '**', component: HomePageComponent }
];
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Теперь мы создаем детали, на которые есть ссылки в файле выше. Нам нужно запретить людям доступ к аутентифицированным маршрутам без токена, поэтому нам нужен охранник в Angular. Мы делаем это, запустив ng g guard isAuthenticated. Это генерирует is-authenticated.guard.ts.

Мы помещаем в is-authenticated.guard.ts следующее:

import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { UserService } from './user.service';
@Injectable({
  providedIn: 'root'
})
export class IsAuthenticatedGuard implements CanActivate {
  constructor(
    private userService: UserService,
    private router: Router
  ) { }
canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    const isAuthenticated = this.userService.isAuthenticated();
    if (!isAuthenticated) {
      localStorage.clear();
      this.router.navigate(['/']);
    }
    return isAuthenticated;
  }
}

Это использует нашу isAuthenticated функцию из UserService для проверки действительного токена. Если он недействителен, мы очищаем его и перенаправляем обратно на главную страницу.

Теперь создаем формы для входа в систему, устанавливая данные пользователя после входа. Мы запускаем ng g component homePage, ng g component loginPage, ng g component topBar, ng g component signUpPage и ng g component settingsPage. Это для форм и компонентов верхней панели.

Домашняя страница - это просто статическая страница. У нас должны быть созданы home-page.component.html и home-page.component.ts после выполнения команд в нашем последнем абзаце.

В home-page.component.html мы помещаем:

<div class="center">
    <h1>Home Page</h1>
</div>

Теперь делаем нашу страницу авторизации. В login-page.component.ts мы помещаем:

<div class="center">
    <h1>Log In</h1>
</div>
<form #loginForm='ngForm' (ngSubmit)='login(loginForm)'>
    <mat-form-field>
        <input matInput placeholder="Username" required #userName='ngModel' name='userName'
            [(ngModel)]='loginData.userName'>
        <mat-error *ngIf="userName.invalid && (userName.dirty || userName.touched)">
            <div *ngIf="userName.errors.required">
                Username is required.
            </div>
        </mat-error>
    </mat-form-field>
    <br>
    <mat-form-field>
        <input matInput placeholder="Password" type='password' required #password='ngModel' name='password'
            [(ngModel)]='loginData.password'>
        <mat-error *ngIf="password.invalid && (password.dirty || password.touched)">
            <div *ngIf="password.errors.required">
                Password is required.
            </div>
        </mat-error>
    </mat-form-field>
    <br>
    <button mat-raised-button type='submit'>Log In</button>
    <a mat-raised-button routerLink='/passwordResetRequest'>Reset Password</a>
</form>

В login-page.component.ts мы помещаем:

import { Component, OnInit } from '@angular/core';
import { UserService } from '../user.service';
import { NgForm } from '@angular/forms';
import { Router } from '@angular/router';
@Component({
  selector: 'app-login-page',
  templateUrl: './login-page.component.html',
  styleUrls: ['./login-page.component.scss']
})
export class LoginPageComponent implements OnInit {
  loginData: any = <any>{};
  constructor(
    private userService: UserService,
    private router: Router
  ) { }
  ngOnInit() {
  }
  login(loginForm: NgForm) {
    if (loginForm.invalid) {
      return;
    }
    this.userService.login(this.loginData)
      .subscribe((res: any) => {
        localStorage.setItem('token', res.token);
        this.router.navigate(['/settings']);
      }, err => {
        alert('Invalid username or password');
      })
  }
}

Следим за тем, чтобы все поля были заполнены. Если это так, данные для входа будут отправлены, а токен будет сохранен в локальном хранилище, если аутентификация прошла успешно. В противном случае будет отображаться предупреждение об ошибке.

Затем на нашей странице регистрации sign-up-page.component.html мы помещаем:

<div class="center">
    <h1>Sign Up</h1>
</div>
<br>
<form #signUpForm='ngForm' (ngSubmit)='signUp(signUpForm)'>
    <mat-form-field>
        <input matInput placeholder="Username" required #userName='ngModel' name='userName'
            [(ngModel)]='signUpData.userName'>
        <mat-error *ngIf="userName.invalid && (userName.dirty || userName.touched)">
            <div *ngIf="userName.errors.required">
                Username is required.
            </div>
        </mat-error>
    </mat-form-field>
    <br>
    <mat-form-field>
        <input pattern="\S+@\S+\.\S+" matInput placeholder="Email" required #email='ngModel' name='email'
            [(ngModel)]='signUpData.email'>
        <mat-error *ngIf="email.invalid && (email.dirty || email.touched)">
            <div *ngIf="email.errors.required">
                Email is required.
            </div>
            <div *ngIf="email.invalid">
                Email is invalid.
            </div>
        </mat-error>
    </mat-form-field>
    <br>
    <mat-form-field>
        <input matInput placeholder="Password" type='password' required #password='ngModel' name='password'
            [(ngModel)]='signUpData.password'>
        <mat-error *ngIf="password.invalid && (password.dirty || password.touched)">
            <div *ngIf="password.errors.required">
                Password is required.
            </div>
        </mat-error>
    </mat-form-field>
    <br>
    <button mat-raised-button type='submit'>Sign Up</button>
</form>

и в sign-up-page.component.ts мы помещаем:

import { Component, OnInit } from '@angular/core';
import { UserService } from '../user.service';
import { NgForm } from '@angular/forms';
import { Router } from '@angular/router';
import _ from 'lodash';
@Component({
  selector: 'app-sign-up-page',
  templateUrl: './sign-up-page.component.html',
  styleUrls: ['./sign-up-page.component.scss']
})
export class SignUpPageComponent implements OnInit {
  signUpData: any = <any>{};
  constructor(
    private userService: UserService,
    private router: Router
  ) { }
ngOnInit() {
  }
  signUp(signUpForm: NgForm) {
    if (signUpForm.invalid) {
      return;
    }
    this.userService.signUp(this.signUpData)
      .subscribe(res => {
        this.login();
      }, err => {
        console.log(err);
        if (
          _.has(err, 'error.error.errors') &&
          Array.isArray(err.error.error.errors) &&
          err.error.error.errors.length > 0
        ) {
          alert(err.error.error.errors[0].message);
        }
      })
  }
  login() {
    this.userService.login(this.signUpData)
      .subscribe((res: any) => {
        localStorage.setItem('token', res.token);
        this.router.navigate(['/tweets']);
      })
  }
}

Эти два фрагмента кода получают данные регистрации и отправляют их в серверную часть, которая сохранит файл, если они все действительны.

Аналогично в settings-page.component.html:

<div class="center">
    <h1>Settings</h1>
</div>
<br>
<div>
    <h2>Update User Info</h2>
</div>
<br>
<form #updateUserForm='ngForm' (ngSubmit)='updateUser(updateUserForm)'>
    <mat-form-field>
        <input matInput placeholder="Username" required #userName='ngModel' name='userName'
            [(ngModel)]='updateUserData.userName'>
        <mat-error *ngIf="userName.invalid && (userName.dirty || userName.touched)">
            <div *ngIf="userName.errors.required">
                Username is required.
            </div>
        </mat-error>
    </mat-form-field>
    <br>
    <mat-form-field>
        <input pattern="\S+@\S+\.\S+" matInput placeholder="Email" required #email='ngModel' name='email'
            [(ngModel)]='updateUserData.email'>
        <mat-error *ngIf="email.invalid && (email.dirty || email.touched)">
            <div *ngIf="email.errors.required">
                Email is required.
            </div>
            <div *ngIf="email.invalid">
                Email is invalid.
            </div>
        </mat-error>
    </mat-form-field>
    <br>
    <button mat-raised-button type='submit'>Update User Info</button>
</form>
<br>
<div>
    <h2>Update Password</h2>
</div>
<br>
<form #updatePasswordForm='ngForm' (ngSubmit)='updatePassword(updatePasswordForm)'>
    <mat-form-field>
        <input matInput placeholder="Password" type='password' required #password='ngModel' name='password'
            [(ngModel)]='updatePasswordData.password'>
        <mat-error *ngIf="password.invalid && (password.dirty || password.touched)">
            <div *ngIf="password.errors.required">
                Password is required.
            </div>
        </mat-error>
    </mat-form-field>
    <br>
    <button mat-raised-button type='submit'>Update Password</button>
</form>
<br>
<div *ngIf='currentTwitterUser.id' class="title">
    <h2>Connected to Twitter Account</h2>
    <div>
        <button mat-raised-button (click)='redirectToTwitter()'>Connect to Different Twitter Account</button>
    </div>
</div>
<div *ngIf='!currentTwitterUser.id' class="title">
    <h2>Not Connected to Twitter Account</h2>
    <div>
        <button mat-raised-button (click)='redirectToTwitter()'>Connect to Twitter Account</button>
    </div>
</div>

В settings-page.component.html мы помещаем:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { SessionService } from '../session.service';
import { NgForm } from '@angular/forms';
import { UserService } from '../user.service';
@Component({
  selector: 'app-settings-page',
  templateUrl: './settings-page.component.html',
  styleUrls: ['./settings-page.component.scss']
})
export class SettingsPageComponent implements OnInit {
  currentTwitterUser: any = <any>{};
  elements: any[] = [];
  displayedColumns: string[] = ['key', 'value'];
  updateUserData: any = <any>{};
  updatePasswordData: any = <any>{};
  constructor(
    private sessionService: SessionService,
    private userService: UserService,
    private router: Router
  ) {

  }
  ngOnInit() {

  }
  updateUser(updateUserForm: NgForm) {
    if (updateUserForm.invalid) {
      return;
    }
    this.userService.updateUser(this.updateUserData)
      .subscribe(res => {
        alert('Updated user info successful.');
      }, err => {
        alert('Updated user info failed.');
      })
  }
  updatePassword(updatePasswordForm: NgForm) {
    if (updatePasswordForm.invalid) {
      return;
    }
    this.userService.updatePassword(this.updatePasswordData)
      .subscribe(res => {
        alert('Updated password successful.');
      }, err => {
        alert('Updated password failed.');
      })
  }
}

Подобно другим страницам, это отправляет полезную нагрузку запроса на изменение данных пользователя и пароля на наш сервер.

Наконец, чтобы сделать нашу верхнюю панель, мы добавляем в top-bar.component.html следующее:

<mat-toolbar>
    <a (click)='toggleMenu()' class="menu-button">
        <i class="material-icons">
            menu
        </i>
    </a>
    Twitter Automator
</mat-toolbar>

И в top-bar.component.ts:

import { Component, OnInit } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { TOGGLE_MENU } from '../reducers/menu-reducer';
@Component({
  selector: 'app-top-bar',
  templateUrl: './top-bar.component.html',
  styleUrls: ['./top-bar.component.scss']
})
export class TopBarComponent implements OnInit {
  menuOpen: boolean;
  constructor(
    private store: Store<any>
  ) {
    store.pipe(select('menu'))
      .subscribe(menuOpen => {
        this.menuOpen = menuOpen;
      })
  }
  ngOnInit() {
  }
  toggleMenu() {
    this.store.dispatch({ type: TOGGLE_MENU, payload: !this.menuOpen });
  }
}

В app.component.ts мы помещаем:

import { Component, HostListener } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { TOGGLE_MENU } from './reducers/menu-reducer';
import { UserService } from './user.service';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  menuOpen: boolean;
  constructor(
    private store: Store<any>,
    private userService: UserService
  ) {
    store.pipe(select('menu'))
      .subscribe(menuOpen => {
        this.menuOpen = menuOpen;
      })
  }
  isAuthenticated() {
    return this.userService.isAuthenticated();
  }
@HostListener('document:click', ['$event'])
  public onClick(event) {
    const isOutside = !event.target.className.includes("menu-button") &&
      !event.target.className.includes("material-icons") &&
      !event.target.className.includes("mat-drawer-inner-container")
    if (isOutside) {
      this.menuOpen = false;
      this.store.dispatch({ type: TOGGLE_MENU, payload: this.menuOpen });
    }
  }
logOut() {
    this.userService.logOut();
  }
}

а в app.component.html у нас есть:

<mat-sidenav-container class="example-container">
    <mat-sidenav mode="side" [opened]='menuOpen'>
        <ul>
            <li>
                <b>
                    Twitter Automator
                </b>
            </li>
            <li>
                <a routerLink='/login' *ngIf='!isAuthenticated()'>Log In</a>
            </li>
            <li>
                <a routerLink='/signup' *ngIf='!isAuthenticated()'>Sign Up</a>
            </li>
            <li>
                <a href='#' (click)='logOut()' *ngIf='isAuthenticated()'>Log Out</a>
            </li>
            <li>
                <a routerLink='/tweets' *ngIf='isAuthenticated()'>Tweets</a>
            </li>
            <li>
                <a routerLink='/settings' *ngIf='isAuthenticated()'>Settings</a>
            </li>
        </ul>
</mat-sidenav>
    <mat-sidenav-content>
        <app-top-bar></app-top-bar>
        <div id='content'>
            <router-outlet></router-outlet>
        </div>
    </mat-sidenav-content>
</mat-sidenav-container>

Это позволяет нам переключать наше боковое меню навигации. Обратите внимание, что у нас есть:

@HostListener('document:click', ['$event'])
  public onClick(event) {
    const isOutside = !event.target.className.includes("menu-button") &&
      !event.target.className.includes("material-icons") &&
      !event.target.className.includes("mat-drawer-inner-container")
    if (isOutside) {
      this.menuOpen = false;
      this.store.dispatch({ type: TOGGLE_MENU, payload: this.menuOpen });
    }
  }

для обнаружения щелчков боковой навигационной панели. Если мы щелкнем снаружи, то есть не щелкнем ни один элемент с этими классами, мы закроем меню. this.store.dispatch распространяет закрытое состояние на все компоненты.

В итоге имеем:

Следуйте за мной в Twitter: https://twitter.com/AuMayeung