Angular-ElectronJS – Connexion API REST avec jwt


Nous allons poursuivre l'article sur Angular-Electronjs : mise en forme de la page de connexion.

On abordera les élements suivants :

  • Nettoyage du code et factorisation.
  • Messages d’erreurs.
  • Formulaire HTML – Ajout du « reactive forms« .
  • Typescript – Validation du formulaire grâce à une api développé avec nodeexpress-js

Nettoyage du code et factorisation

Faisons un peu de ménage dans notre code.

Modules supplémentaires

En premier lieu, nous allons modifier l’utilisation de fontawesome. Il existe un module fontawesome pour Angular .

Le code classique sera remplacé par celui-ci

<fa-icon icon="['fas', 'spinner']" spin="true"></fa-icon>

On désinstalle tout d’abord @fortawesome/fontawesome-free

npm uninstall @fortawesome/fontawesome-free --save

Puis dans /src/styles.scss, supprimons l’entrée suivante

$fa-font-path : '../node_modules/@fortawesome/fontawesome-free/webfonts';
@import '../node_modules/@fortawesome/fontawesome-free/scss/fontawesome';

Ce remplacement nécessite l’installation de packages supplémentaires. Et on installe les packages suivants :

  • @fortawesome/fontawesome-svg-core
  • @fortawesome/free-solid-svg-icons
  • @fortawesome/angular-fontawesome

Cette installation se fait en une seule fois avec la commande suivante :

npm install @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/angular-fontawesome --save 

Pour pouvoir utiliser ce package dans l’ensemble de l’application, modifions /src/app/app.module.ts et ajoutons fontawesome et d’autres components material tels que: MatSnackBarModule et MatProgressBarModule.

import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { library } from '@fortawesome/fontawesome-svg-core';
import { fas } from '@fortawesome/free-solid-svg-icons';
import {
  MatSidenavModule,
  MatToolbarModule,
  MatIconModule,
  MatCardModule,
  MatInputModule,
  MatFormFieldModule,
  MatButtonModule,
  MatListModule,
  MatExpansionModule,
  MatSnackBarModule,
  MatProgressBarModule
} from '@angular/material';

…puis on ajoute dans NgModule

@NgModule({
    imports: [
        // Material
        MatSidenavModule,
        MatButtonModule,
        MatToolbarModule,
        MatIconModule,
        MatCardModule,
        MatListModule,
        MatInputModule,
        MatFormFieldModule,
        MatProgressBarModule,
        MatSnackBarModule,
        MatExpansionModule,
        FlexLayoutModule,
        ReactiveFormsModule,
        // Fontawesome
        FontAwesomeModule
    ]
});

Messages d’erreurs

L’affichage des messages d’erreurs du formulaire de connexion se fait à 2 endroits:

  • dans le fichier /src/app/components/login/login.component.html
  • et dans typescript /src/app/components/login/login.component.ts

…Dans le fichier login.component.html

Ajoutons un message d’erreur sur la validation du formulaire. Ce message se présentera lorsque le couple identifiant/mot de passe saisi ne correspond pas au couple enregistré en base de données.

Ce message d’erreur sera affiché par le bout de code suivant placé juste après la balise d’ouverture <form>

<div *ngIf="errorForm" fxLayout="row" fxLayoutGap="5px" fxLayoutAlign="center">
        <div><mat-icon aria-hidden="false" color="warn" >error</mat-icon></div>
        <div>{{ 'error.form' | translate }}</div>
      </div>

Voici un exemple :

Hexo

Grâce à la directive *ngIf="errorForm", ce bloc de message s’affiche si la propriété errorForm est définie à true. Nous allons définir cette propriété un peu plus bas.

Les propriétés fx* sont des attributs specifiques à Angular flex layout. Elles permettent de mettre en forme les éléments.

Nous allons également afficher les messages d’erreurs au dessous de chaque champ du formulaire (identifiant et mot de passe).

La balise du champ identifiant sera comme ceci

<mat-form-field>
        <input [attr.disabled]="savedForm ? '' : null" [readonly]="savedForm" formControlName="username" matInput placeholder="{{ 'login.email_placeholder' | translate }}">
        <mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
        <mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
        <mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
      </mat-form-field>

Sur ce champ, nous avons ajouté 2 messages d’erreurs qui apparaissent si les validations suivantes ne sont pas respectées :

  • champ obligatoire required
  • champ contenant moins de caractère que ce qui est requis minlength
  • champ contenant plus de caractère que ce qui est requis maxlength

Les messages d’erreurs sur ce champ sont conditionnés par la valeur true des conditions usernameControl.dirty && usernameControl.hasError('required') qui peuvent se résumer ainsi :

  • Afficher ce bloc de texte si le champ identifiant et vide et contient une erreur de type « obligatoire »

Il en est de même pour les 2 autres blocs d’erreurs.

On fait la même chose pour le champ mot de passe. Ce champ possède les mêmes contraintes de validation que le champ identifiant. La balise sera modifiée comme ceci

<mat-form-field >
        <input [type]="hidePassword ? 'password' : 'text'" [attr.disabled]="savedForm ? '' : null" formControlName="password" matInput placeholder="{{ 'login.password_placeholder' | translate }}">
        <a mat-icon-button matSuffix (click)="hidePassword = !hidePassword" [attr.aria-label]="'Hide password'" [attr.aria-pressed]="hidePassword">
          <mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
        </a>
        <mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
        <mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
        <mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
      </mat-form-field>

Dans les 2 cas, nous avons ajouté [attr.disabled]="savedForm ? '' : null" [readonly]="savedForm" qui met le champ en lecture seule et désactive le champs si la propriété savedForm est à true (C’est-à-dire Formulaire en cours de traitement).

Formulaire HTML – Ajout du reactive forms.

Comme dans les champs de formulaire vus plus haut, on ajoute une propriété [disabled] au bouton de validation. Voici comment se présente le code html de ce bouton.

<button mat-raised-button color="primary"  [disabled]="loginForm.invalid || savedForm">
        <fa-icon *ngIf="savedForm" [icon]="['fas', 'spinner']" [spin]="true"></fa-icon>
        {{ 'login.button' | translate }}
</button>

2 propriétés boolean doivent être à true pour changer la propriété du bouton à « disabled » :

  • loginForm.invalid

    Formulaire invalide

  • savedForm
    Formulaire en cours de traitement.

Iil est logique de désactiver le bouton de validation pour éviter le clic intempestif sur ce bouton dans ces 2 cas.

Le code suivant :

<fa-icon *ngIf="savedForm" [icon]="['fas', 'spinner']" [spin]="true"></fa-icon>

est une icone fontawesome animée à gauche du texte du bouton Elle s’affiche uniquement (grâce à la propriété *ngIf) en cas de validation du formulaire.

C’est un moyen de montrer à l’utilisateur que sa demande est en cours de traitement.

Typescript – Validation du formulaire et connexion à une api en nodeexpress-js

Pour rappel, il existe deux méthodes permettant de piloter (créer, valider) un formulaire avec Angular:

  • par un template
  • par le code

Dans la suite de cet article, nous utilisons la méthode pilotée par le code.

Importons ReactiveFormsModule dans le fichier app/app.module.ts

import { ReactiveFormsModule } from '@angular/forms';

puis ajoutons dans NgModule

@NgModule({
  imports: [
    ReactiveFormsModule
  ]
})

Template html login

Modifions notre fichier html /src/app/components/login/login.component.html – ajoutons la balise <form> avec la directive formGroup

<form novalidate
      [formGroup]="loginForm"
      (ngSubmit)="onSubmit()"
      [hidden]="hideForm"
>

La directive (ngSubmit)= »onSubmit() » définit une méthode onSubmit() à appeler lorsque le formulaire sera validé.

On modifie ensuite les champs identifiant et mot de passe en ajoutant la directive formControlName.

Ce qui donne

<input [attr.disabled]="savedForm" [readonly]="savedForm" formControlName="username" matInput placeholder="{{ 'login.email_placeholder' | translate }}">

On fait une petite modification pour le champ mot de passe

<input [type]="hidePassword ? 'password' : 'text'" [attr.disabled]="savedForm" formControlName="password" matInput placeholder="{{ 'login.password_placeholder' | translate }}">
<button mat-icon-button matSuffix (click)="hidePassword = !hidePassword" [attr.aria-label]="'Hide password'" [attr.aria-pressed]="hidePassword">
   <mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
</button>

Le bouton à droite du champ mot de passe est une icone cliquable qui affiche en clair le mot de passe saisi. Elle permet de le masquer ensuite en cliquant à nouveau sur la même icone.

Hexo

Le code complet du champ mot de passe doit ressembler à ceci après ajout du message d’erreur vu plus haut.

<mat-form-field >
        <input [type]="hidePassword ? 'password' : 'text'" [attr.disabled]="savedForm" formControlName="password" matInput placeholder="{{ 'login.password_placeholder' | translate }}">
        <button mat-icon-button matSuffix (click)="hidePassword = !hidePassword" [attr.aria-label]="'Hide password'" [attr.aria-pressed]="hidePassword">
          <mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
        </button>
        <mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
        <mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
        <mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
      </mat-form-field>

Ajoutons une barre de progression avant la fin du formulaire. Cette barre de progression s’affiche si le formulaire est en cours de traitement.

<mat-progress-bar color="accent" *ngIf="hideForm || savedForm" mode="indeterminate"></mat-progress-bar>

Hexo

Et on n’oublie pas de fermer le formulaire

</form>

Notre fichier complet /src/app/components/login/login.component.html est le suivant

<form novalidate
      [formGroup]="loginForm"
      (ngSubmit)="onSubmit()"
>
  <div fxLayout="row" fxLayoutAlign="center" class="login-main">

    <mat-card >

    <mat-card-header>
      <mat-card-title>{{ 'login.header' | translate }}</mat-card-title>
    </mat-card-header>

    <mat-card-content fxLayout="column">

      <div *ngIf="errorForm" fxLayout="row" fxLayoutGap="5px" fxLayoutAlign="center">
        <div><mat-icon aria-hidden="false" color="warn" >error</mat-icon></div>
        <div>{{ 'error.form' | translate }}</div>
      </div>

      <mat-form-field>
        <input [attr.disabled]="savedForm" [readonly]="savedForm" formControlName="username" matInput placeholder="{{ 'login.email_placeholder' | translate }}">
        <mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
        <mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
        <mat-error *ngIf="usernameControl.dirty && usernameControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
      </mat-form-field>

      <mat-form-field >
        <input [type]="hidePassword ? 'password' : 'text'" [attr.disabled]="savedForm" formControlName="password" matInput placeholder="{{ 'login.password_placeholder' | translate }}">
        <button mat-icon-button matSuffix (click)="hidePassword = !hidePassword" [attr.aria-label]="'Hide password'" [attr.aria-pressed]="hidePassword">
          <mat-icon>{{hidePassword ? 'visibility_off' : 'visibility'}}</mat-icon>
        </button>
        <mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('required')">{{ 'error.required' | translate }}</mat-error>
        <mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('minlength')">{{ 'error.minlength' | translate }}</mat-error>
        <mat-error *ngIf="passwordControl.dirty && passwordControl.hasError('maxlength')">{{ 'error.maxlength' | translate }}</mat-error>
      </mat-form-field>
    </mat-card-content>

    <mat-card-actions align="end">
      <button mat-raised-button color="primary"  [disabled]="loginForm.invalid || savedForm">
        <fa-icon *ngIf="savedForm" [icon]="['fas', 'spinner']" [spin]="true"></fa-icon>
        {{ 'login.button' | translate }}
      </button>
    </mat-card-actions>

    <mat-progress-bar color="accent" *ngIf="hideForm || savedForm" mode="indeterminate"></mat-progress-bar>

  </mat-card>

</div>
</form>

… côté Typescript

Modifions le fichier /src/app/components/login/login.components.ts et importons les class utiles permettant de gérer les formulaires

import { FormBuilder, FormControl,  FormGroup, Validators } from '@angular/forms';

Ajoutons les propriétés suivantes


hideForm: boolean;
savedForm: boolean;
errorForm: boolean;
hidePassword: boolean = true;

loginForm: FormGroup;
usernameControl: FormControl;
passwordControl: FormControl;

Importons les class utiles telles que Router, ActivatedRoute et MatSnackBar . Puis dans le constructeur, injectons les class qui nous seront utiles. Ce qui donne ceci


import { Router, ActivatedRoute } from '@angular/router';
import { MatSnackBar } from '@angular/material';
import { TranslateService } from "@ngx-translate/core";

constructor(private fb: FormBuilder,
    public service:LoginService,
    private router: Router,
    private route: ActivatedRoute,
    private _snackBar: MatSnackBar,
    private translate: TranslateService)

Ajoutons une méthode createForm

constructor(private fb: FormBuilder) {
    this.createForm();
  }

Et créons cette méthode

createForm() {
    this.loginForm = this.fb.group({
      username: this.fb.control('', [Validators.required, Validators.minLength(2), Validators.maxLength(25)]),
      password: this.fb.control('', [Validators.required, Validators.minLength(2), Validators.maxLength(25)])
    });

    this.loginForm.valueChanges
      .subscribe(data => this.onValueChanged(data));

    // Reset
    this.hideForm = false;

}

On crée 2 services dans le dossier providers :

  • login
    servira à l’authentification (login, logout)
  • user
    servira à afficher les informations de l’utilisateur
    connecté

La création de ces services se fait grâce aux commandes de Angular suivantes

ng g service providers/login
ng g service providers/user

Les fichiers suivants sont créés

  • /src/app/providers/login.service.spec.ts
  • /src/app/providers/login.service.ts
  • /src/app/providers/user.service.spec.ts
  • /src/app/providers/user.service.ts

Dans le constructeur, ajoutons le code suivant

// if url "/login?logout=1"
let logoutParam = this.route.snapshot.queryParamMap.get('logout');
if (logoutParam == '1') {
    this.logout();
}

// redirect to home if already logged in
if (this.service.currentUserValue) {
    this.router.navigate(['/']);
}

Les lignes 2 à 6 du code précédent détectent la présence de l’URL « ?logout=1 » et lance la déconnexion de l’utilisateur si cet URL est lancé.

On considère que la page home accessible via l’URL « / » est une page réservée aux utilisateurs authentifiés. Le code ci-dessous (ligne 8 – 10) redirige l’utilisateur vers la page « / » si celui ci est authentifié.

On ajoute la méthode logout dans la class LoginComponent.

logout () {
    this.service.logout();
}

Soumission du formulaire

La soumission du formulaire se gère grâce à la méthode onSubmitsuivante.

onSubmit() {

    // True == form en cours de traitement
    this.savedForm = true;

    // supprimer les messages d'erreurs
    this.errorForm = false;

    // Valeurs des champs du formulaire
    let values = this.loginForm.value;

    // Service LoginService
    this.service.login(values)
      .pipe(first())
      .subscribe((result) => {

      // Token existe dans retour API
      let tokenExists = typeof result.token !== 'undefined';

      // Cacher le formulaire et preparer la redirection vers la home
      this.hideForm = true;

      if (tokenExists) {
        this.router.navigate(['/']);
        return true;
      }

      // Afficher error en cas d'echec
      this.setFormError();

    }, (err) => {
      console.log(err);
        // Afficher error en cas d'echec
        this.setFormError();
      });

  }

setFormError() {
    // Afficher le formulaire
    this.hideForm = false;

    // Le formulaire n'est plus en cours de traitement
    this.savedForm = false;

    // Afficher les messages d'erreurs
    this.errorForm = true;

    // Afficher notif dans snackbar
    let msgSnack = this.translate.instant('error.form');
    this._snackBar.open(msgSnack, null, {
      duration: 5000,
    });
  }

La ligne 49 à 53 affiche un message d’erreur sous forme de notification en bas de page comme sur cette capture.

Hexo

L’essentiel du traitement se fait dans le service LoginService.

Fichier /src/app/providers/login.service.ts

On ajoute 2 méthodes login et logout.

La methode login

Cette méthode se connecte à l’URL ${AppConfig.apiUrl}/user/login en méthode POST et passe les informations login=&password= dans la variable data. Un token est retournée par l’URL en cas de succes. Ce token sera enregistrée dans le localStorage.

la variable ${AppConfig.apiUrl} est définie dans le fichier /src/environments/environment.ts comme suit apiUrl: 'http://localhost:3008/api'

Le contenu de ce fichier est le suivant :

export const AppConfig = {
  production: false,
  environment: 'LOCAL',
  apiUrl: 'http://localhost:3008/api'
};

la variable apiUrl appelle une Url qui est une application nodejs. Une fois la connexion réussie, le token retournée par l’url sera enregistrée dans localstorage via localStorage.setItem

login (data): Observable<any> {
    // console.log(data);
    return this.http.post<any>(`${AppConfig.apiUrl}/user/login`, data, httpOptions)
      .pipe(map(user => {
        // store user details and jwt token in local storage to keep user logged in between page refreshes
        localStorage.setItem('user', JSON.stringify(user));
        this.currentUserSubject.next(user);
        return user;
      }));
  }

La méthode logout

Cette méthode quand à elle supprime la variable locale dans localStorage ensuite détruit la variable currentUserSubject

logout() {
    // remove user from local storage to log user out
    localStorage.removeItem('user');
    this.currentUserSubject.next(null);
  }

Code complet /src/app/providers/login.service.ts

import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { User } from '../models/user';
import { map, tap  } from "rxjs/operators";

import { AppConfig } from '../../environments/environment';

const httpOptions = {
  headers: new HttpHeaders({
    'Content-Type':  'application/json'
  })
};

@Injectable({
  providedIn: 'root'
})
export class LoginService {

  private currentUserSubject: BehaviorSubject<User>;
  public currentUser: Observable<User>;

  constructor(private http: HttpClient) {
    this.currentUserSubject = new BehaviorSubject<User>(JSON.parse(localStorage.getItem('user')));
    this.currentUser = this.currentUserSubject.asObservable();
  }

  public get currentUserValue(): User {
    return this.currentUserSubject.value;
  }

  login (data): Observable<any> {
    // console.log(data);
    return this.http.post<any>(`${AppConfig.apiUrl}/user/login`, data, httpOptions)
      .pipe(map(user => {
        console.log(user);
        // store user details and jwt token in local storage to keep user logged in between page refreshes
        localStorage.setItem('user', JSON.stringify(user));
        this.currentUserSubject.next(user);
        return user;
      }));
  }

  logout() {
    // remove user from local storage to log user out
    localStorage.removeItem('user');
    this.currentUserSubject.next(null);
  }
}

Créons le fichier /src/app/models/user.ts

export class User {
  id: number;
  username: string;
  password: string;
  token?: string;
}

Voici à quoi devrait ressembler notre formulaire de connexion à l’affichage

Hexo

En cas d’erreur après validation

Hexo

En cas de succès

Hexo

Sources: https://github.com/rabehasy/angular-electron/tree/step2