ネイティブアプリ風ウェブアプリを Ionic で作ってみる(完全入門編)その3

ネイティブアプリ風ウェブアプリを Ionic で作ってみる(完全入門編)その3

また、ソースコードは、こちらで公開しています。

細かい修正をする

さて、先ほどから機能を追加していきましたが、気持ちよくアプリを利用するには細かい修正が必要です。思いつく限り、

  • ログアウトしたい
  • ログインパスワードを忘れた時に復旧したい
  • ログインや ToDo のリクエストなどの loading 中にローダーを表示したい
  • 大きくスワイプした時も削除したい
  • done にした時や削除した時の動作を滑らかにしたい
  • 新規登録できるようにする

ログアウトする

ログアウトする画面をどこに作るかですが、tab が2つしかないのでもう一つ追加してプロフィールページを作成してみましょう。

プロフィールページを作成する

$ ionic generate
 ? What would you like to generate? page
 ? Name/path of page: profile
   ng generate page profile --project=app
   CREATE src/app/profile/profile-routing.module.ts (351 bytes)
   CREATE src/app/profile/profile.module.ts (479 bytes)
   CREATE src/app/profile/profile.page.scss (0 bytes)
   CREATE src/app/profile/profile.page.html (126 bytes)
   CREATE src/app/profile/profile.page.spec.ts (654 bytes)
   CREATE src/app/profile/profile.page.ts (260 bytes)
   UPDATE src/app/app-routing.module.ts (733 bytes)
   [OK] Generated page! 

Tab を追加する

まず、tabs-routing.module.ts を編集します。

const routes: Routes = [
  {
    path: '',
    component: TabsPage,
    children: [
      {
        path: 'todo',
        loadChildren: () => import('../todo/todo.module').then( m => m.TodoPageModule)
      },
      {
        path: 'calendar',
        loadChildren: () => import('../calendar/calendar.module').then( m => m.CalendarPageModule)
      },
      {
        path: 'profile',
        loadChildren: () => import('../profile/profile.module').then( m => m.ProfilePageModule)
      },    
    ]
  }
];

app-routing.module の方に自動的にルートが追加されているのでそちらは削除しておきましょう。タブを追加します。

<ion-tabs>
  <ion-tab-bar slot="bottom">
    <ion-tab-button tab="todo">
      <ion-icon name="list"></ion-icon>
      <ion-label>ToDo</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="calendar">
      <ion-icon name="calendar"></ion-icon>
      <ion-label>Calendar</ion-label>
    </ion-tab-button>

    <ion-tab-button tab="profile">
      <ion-icon name="person-circle"></ion-icon>
      <ion-label>Profile</ion-label>
    </ion-tab-button>
  </ion-tab-bar>
</ion-tabs>

iconは、person-circle にしました。アイコンはたくさんあるのでこちらで探せます。

プロフィールページを作成しましょう。表示する要素は、email くらいしかないので email を表示しましょう。まずは、profile.page.ts の方でデータを定義します。

import { Component } from '@angular/core';
import { map } from 'rxjs/operators';
import { AuthFacade } from '../auth/auth.facade';

@Component({
  selector: 'app-profile',
  templateUrl: './profile.page.html',
  styleUrls: ['./profile.page.scss'],
})
export class ProfilePage {

  constructor(
    private authService: AuthFacade
  ) { }

  email$ = this.authService.currentAuthenticatedSession$.pipe(
    map(user => user?.email)
  )

}

authService の現在のセッションを取得するところから、email を取り出しています。

表示します。

<ion-header>
  <ion-toolbar>
    <ion-title>Profile</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>

  <ion-list>
    <ion-list-header>
      Email
    </ion-list-header>
    <ion-item>
      <ion-label>
        {{ email$ | async }}
      </ion-label>
    </ion-item>
  </ion-list>

</ion-content>

シンプルですね。

これにログアウト機能をつけましょう。まずは、ログアウトボタンを設置して、onLogoutClicked を呼び出します。

<ion-header>
  <ion-toolbar>
    <ion-title>Profile</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>

  <ion-list>
    <ion-list-header>
      Email
    </ion-list-header>
    <ion-item>
      <ion-label>
        {{ email$ | async }}
      </ion-label>
    </ion-item>
  </ion-list>

  <ion-grid>
    <ion-row class="ion-margin-top">
      <ion-col class="ion-text-center">
        <ion-button color="danger" fill="clear" class="ion-margin-top" (click)="onLogoutClicked()">
          ログアウト
        </ion-button>
      </ion-col>
    </ion-row>
  </ion-grid>

</ion-content>

ログアウトを実装しましょう。間違えてクリックした時にログアウトすると面倒なので、アラートを表示してからログアウトしましょう。cognito-auth.service の方には、すでにログアウトが実装されていますが、未検証です。動作の確認も含めてやってみましょう。

import { Component } from '@angular/core';
import { AlertController } from '@ionic/angular';
import { map } from 'rxjs/operators';
import { AuthFacade } from '../auth/auth.facade';

@Component({
  selector: 'app-profile',
  templateUrl: './profile.page.html',
  styleUrls: ['./profile.page.scss'],
})
export class ProfilePage {

  constructor(
    private authService: AuthFacade,
    private alertController: AlertController,
  ) { }

  email$ = this.authService.currentAuthenticatedSession$.pipe(
    map(user => user?.email)
  )

  async onLogoutClicked() {
    const alert = await this.alertController.create({
      header: "ログアウトしますか?",
      buttons: [
        {
          text: 'Cancel',
          role: 'cancel',
        },
        {
          text: 'ログアウト',
          handler: () => {
            console.log('logging out...');
            this.authService.logout();
          }
        }
      ]
    })

    await alert.present();
  }

}

さて実行してみましょう。

ログアウトできてそうですが、思ったのと違う感があります。ログインページに遷移して欲しいですよね。再読み込みをすれば、ログイン画面に遷移されるのですが、一気に遷移させましょう。

  logout(): void {
    this.cognitoUser = null;
    Auth.signOut().then(() => {
      this.clearLoginState();
      this.router.navigate(['/login']);
    })
  }

遷移できましたね。

auth.slice をリファクタする

さて、再読み込みしてログインしようとすると失敗します。これは、なぜかというと、ログイン自体には成功しているのですが、Auth.currentAuthenticatedSession() が、なぜか、The user is not authenticated のエラーを吐くのです。

上の図は、signin から login そして、ログイン後に認証済みの CognitoUser が返却されているのですが、Auth.currentAuthenticatedSession() は、空のままです。どうも Auth.currentAuthenticatedSession() の使い所は、すでにログインしているかどうかを判定するには使えるがログイン直後に認証されたかを確認するには向いていないようです。

試しに、再読み込みして Profile ページなどに直接遷移すると認証OKとなります。

Auth.currentAuthenticatedSession() に認証状態の管理を丸投げはできないようです。そこで、自前で状態管理をするようにしましょう。

auth.slice のリファクタです。次の方針できましょう。

  • cognito-auth.service をストアに依存させない
  • ログイン・ログアウトは、Effect で処理する
  • facade は、ストアだけに依存し、サービスに依存しない

ではやっていきましょう。

cognito-auth.service をストアに依存させない

まず、auth-provider のインターフェースを考えます。

import { Observable } from "rxjs";
import { User } from "./user.model";

export interface AuthProvider { 
  currentAuthenticatedSession$: Observable<User>;
  isAuthenticated$: Observable<boolean>;
  completeNewPassword(password: string): Observable<User>;
  confirmSignIn(code: string): Observable<User>;
  login(authentication: {}): Observable<User>;
  logout(): Observable<any>;
}

基本的に全部 Observable を返してくれるようにします。サインイン系のものは、User を返却させます。logout は、返却値を利用しないので any としておきます。

これに合わせてサービスを実装してきましょう。

import { Injectable } from '@angular/core';
import { Auth } from 'aws-amplify';
import { Observable, from, of } from 'rxjs';
import { map, catchError, mergeMap } from 'rxjs/operators';
import { AuthProvider } from './auth-provider';
import { CognitoUser } from 'amazon-cognito-identity-js';
import { User } from './user.model';
import { Router } from '@angular/router';

@Injectable({
  providedIn: 'root'
})
export class CognitoAuthService implements AuthProvider {

  constructor(
    private router: Router
  ) { }

  private cognitoUser: CognitoUser | null;

  completeNewPassword(password: string): Observable<User> {
    return from(Auth.completeNewPassword(this.cognitoUser, password)).pipe(
      mergeMap(user => {
        return this.getUserAttributesPromise(user)
      }),
      map(userData => new User(userData.email, userData.sub))
    )
  }

  confirmSignIn(code: string): Observable<User> {
    throw new Error('Method not implemented.');
  }

  currentAuthenticatedSession$: Observable<User | any> =
    from(Auth.currentAuthenticatedUser()).pipe(
      mergeMap((user) => {
        return this.getUserAttributesPromise(user);
      }),
      map((userData) => {
        return new User(userData.email, userData.sub);
      }),
      catchError(err => {
        console.error(err);
        return of({});
      })
    )

  isAuthenticated$: Observable<boolean> = this.currentAuthenticatedSession$.pipe(
    map(user => {
      if (user instanceof User) {
        return true;
      }
    })
  );

  login(authentication: { email: string, password: string }): Observable<User> {
    console.log('signin');
    return from(Auth.signIn({
      username: authentication.email,
      password: authentication.password,
    })).pipe(
      mergeMap((cognitoUser: CognitoUser) => {
        return from(this.getUser(cognitoUser))
      })
    );
  }

  logout(): Observable<{}> {
    this.cognitoUser = null;
    return from(Auth.signOut());
  }

  private async getUser(user: CognitoUser): Promise<User> {
    const userData = await this.getUserAttributesPromise(user);
    return new User(userData.email, userData.sub);
  };

  private getUserAttributesPromise(user: CognitoUser): Promise<any> {
    return new Promise((resolve, reject) => {
      user.getUserAttributes((err, result) => {
        if (err) {
          reject(err);
        }
        const userData = {};
        for (let value of result) {
          userData[value.Name] = value.Value;
        }
        resolve(userData);
      })
    })
  }
}

こんな感じでしょうか。ここまで読んだ読者なら大丈夫だと思いますが、RxJS の使い方に不安がある場合は、その1をもう一度振り返ってみましょう。

ログイン、ログアウトなど各種 Effect を追加する

auth.effects.ts を実装しましょう。まずは、ファイルを作成してください。

必要になる effect を考えてみましょう。

  • login : ログインをリクエストします
  • loginSuccess : ログイン後の画面遷移をします
  • logout : ログアウトさせます
  • logoutSuccess : ログアウト後の画面遷移をします
  • initAuthState : 現在ログイン中のセッションがあれば復元します
  • completeNewPassword : パスワード変更を実行します(もう使われないけど)

これらを実装していきましょう。

まずは、Slice にアクションとリデューサーを登録していきます。

import { createFeatureSelector } from '@ngrx/store';
import { createSlice } from '@reduxjs/toolkit';
import { User } from './user.model';

type AuthState = {
  currentAuthenticatedSession: User | null,
  isAuthenticated: boolean,
  loading: boolean,
  loginSuccess: boolean,
  challenge: null | 'NEW_PASSWORD_REQUIRED',
  redirectTo: string | null,
}

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    currentAuthenticatedSession: null,
    loading: false,
    isAuthenticated: false,
    challenge: null,
    redirectTo: '',
  } as AuthState,
  reducers: {
    initAuthState: (state, _) => {
      state.loading = true
    },
    redirectTo: (state, action) => {
      state.redirectTo = action.payload;
    },
    login: (state, _) => {
      state.loading = true
    },
    loginSuccess: (state, action) => {
      state.loading = false;
      state.currentAuthenticatedSession = action.payload;
    },
    loginFail: (state, _) => {
      state.loading = false;
      state.currentAuthenticatedSession = null;
    },
    logout: (state, _) => {
      state.loading = true;
    },
    logoutSuccess: (state, _) => {
      state.loading = false;
      state.currentAuthenticatedSession = null;
    },
    completeNewPassword: (state, _) => {
      state.loading = true;
    },
    completeNewPasswordSuccess: (state, _) => {
      state.loading = false;
    },
    completeNewPasswordFailure: (state, _) => {
      state.loading = false;
    },
    setChallenge: (state, action) => {
      state.challenge = action.payload;
    }
  }
});

const {
  reducer,
  actions: {
    login,
    logout,
    redirectTo,
    initAuthState,
    loginSuccess,
    loginFail,
    logoutSuccess,
    completeNewPassword,
    completeNewPasswordSuccess,
    completeNewPasswordFailure,
    setChallenge,
  },
  name
} = authSlice;

export default reducer;
export {
  name,
  login,
  logout,
  redirectTo,
  initAuthState,
  loginSuccess,
  loginFail,
  logoutSuccess,
  completeNewPassword,
  completeNewPasswordSuccess,
  completeNewPasswordFailure,
  setChallenge,
};

export const selectFeature = createFeatureSelector<ReturnType<typeof reducer>>(
  name
);

では、Effect を実装します。

import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { mergeMap, map, catchError, tap } from 'rxjs/operators';
import { Actions, ofType, createEffect } from '@ngrx/effects';
import * as authSlice from './auth.slice';
import { CognitoAuthService } from './cognito-auth.service';
import { User } from './user.model';
import { Router } from '@angular/router';

type AuthenticationParams = {
  email: string,
  password: string,
}
@Injectable()
export class AuthEffects {
  constructor(
    private actions$: Actions,
    private authService: CognitoAuthService,
    private router: Router,
  ) { }

  login$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.login),
      map(action => <AuthenticationParams>action.payload),
      mergeMap((authentication: AuthenticationParams) => {
        console.log('login');
        console.log(authentication);
        return this.authService.login(authentication).pipe(
          map((user: User) => {
            console.log(user);
            return authSlice.loginSuccess(user);
          }),
          catchError((err) => {
            return of(authSlice.loginFail({}))
          })
        )
      })
    )
  );

  loginSuccess$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.loginSuccess),
      tap(() => {
        this.router.navigate(['/todo'])
      })
    ),
    { dispatch: false }
  )

  logout$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.logout),
      mergeMap(() => {
        return this.authService.logout().pipe(
          map(() => {
            return authSlice.logoutSuccess({});
          })
        )
      })
    )
  );

  logoutSuccess$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.logoutSuccess),
      tap(() => {
        this.router.navigate(['/login']);
      })
    ),
    { dispatch: false }
  )

  initAuthState$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.initAuthState),
      mergeMap(() => {
        return this.authService.currentAuthenticatedSession$.pipe(
          map(user => {
            return authSlice.loginSuccess(user);
          }),
          catchError(err => {
            console.error(err);
            return of(authSlice.loginFail({}));
          })
        )
      })
    )
  )

  completeNewPassword$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.completeNewPassword),
      map(action => action.payload),
      mergeMap(password => {
        return this.authService.completeNewPassword(password).pipe(
          map(user => {
            return authSlice.completeNewPasswordSuccess(user);
          }),
          catchError(err => {
            console.error(err);
            return of(authSlice.completeNewPasswordFailure({}));
          })
        )
      })
    )
  )
}

いろいろ増えましたね。ですが、一つ一つは、シンプルなことしかしていないので大丈夫です。ログインに成功すると、todo ページに遷移し、logout に成功すると login ページに遷移するようになっています。ログイン状態で、 /calendar に直接アクセスすると、まずログインページが表示され、そのあとtodo ページに遷移するので、/calendar にいって欲しいのに、残念な感じありますね。この修正は、読者に任せましょう。

facade は、ストアだけに依存し、サービスに依存しない

さて、Facade の実装を変更しましょう。

import { Injectable } from '@angular/core';
import { createSelector, Store } from '@ngrx/store';
import { map } from 'rxjs/operators';
import { User } from './user.model';
import fromAuth, * as authSlice from './auth.slice';

@Injectable({
  providedIn: 'root'
})
export class AuthFacade {
  constructor(
    private readonly store: Store<typeof fromAuth>,
  ) { }

  initAuthState() {
    this.store.dispatch(authSlice.initAuthState({}));
  }

  currentAuthenticatedSession$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.currentAuthenticatedSession)
  );

  loginSuccess$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.loginSuccess)
  );

  isAuthenticated$ = this.currentAuthenticatedSession$.pipe(
    map(user => {
      if (user instanceof User) {
        return true;
      } else {
        return false;
      }
    })
  )

  challenge$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.challenge)
  )

  completeNewPassword(password: string) {
    this.store.dispatch(authSlice.completeNewPassword(password));
  }

  login(authentication): void {
    console.log('login clicked');
    this.store.dispatch(authSlice.login(authentication));
  }

  logout(): void {
    this.store.dispatch(authSlice.logout({}));
  }

}

これで facade は、Store だけに依存するようになりました。

忘れないうちに、AppModule に新しい Effect を登録しましょう。これ結構忘れます。

    EffectsModule.forRoot([TodoEffects, AuthEffects]),

AppComponent で、初期のログイン状態を確認するようにしましょう。

import { Component, OnInit } from '@angular/core';
import { AuthFacade } from './auth/auth.facade';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent implements OnInit {

  constructor(
    private authService: AuthFacade,
  ) {}

  ngOnInit() {
    this.authService.initAuthState();
  }
}

これで、アクセスした時に、currentAuthenticatedSession が初期化され、ログイン済みだと todo ページに遷移されるようになるはずです。

やってみましょう。

実演

ログイン時

ログインして todo に遷移しています。

ログアウト時

ログアウトして、ログイン画面に遷移しています。

ログインしている状態で、Guard されているページを表示した時

一瞬ログイン画面が表示されて、todo 画面に遷移しています。この場合、calendar に遷移して欲しいところですが、それは読者に任せましょう。

ログインパスワードを忘れた時に復旧したい

パスワードを忘れた手順は、

  • パスワードを忘れたリンクをログインページに設置
  • パスワードを忘れた時のページあるいはフォームを表示
  • サービスクラスでパスワードを忘れた場合の処理を実装
  • Slice / Effect と Facade に処理を追記

まずは、Cognito のパスワードを忘れた場合のフローを確認しましょう。

こちらに記載がありますが、forgotPassword を実行すると Cognito 側に何かしらが発生して、forgotPasswordSubmit でおそらくメールで送信されるコードとユーザーネーム、新しいパスワードを送信すると完了するようです。

forgotPassword を実行後に、code と新しいパスワードを入力する画面にして、メールから取得したコードを入力して forgotPasswordSubmit を実行、完了したらログイン画面に遷移する方針でいきましょう。本来ならば、URLをクリックすれば新しいパスワードを入れるだけで良いようにしたいですが、Cognito 側の設定の詳細に入るのはここでの話題の範囲を超えるのでちょっといけてないフローでいきます。手間はかかりますが、決して難しくはないので、読者の方で修正にチャレンジしてみてください。

パスワードを忘れたリンクを設置

これはとてもシンプルです。

<ion-header [translucent]="true">
  <ion-toolbar>
    <ion-title>
      My ToDo
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">
  <ion-header collapse="condense">
    <ion-toolbar>
      <ion-title size="large">My ToDo</ion-title>
    </ion-toolbar>
  </ion-header>

  <div id="container">
    <strong>Login</strong>
    <p>さあ始めましょう。</p>
    <p *ngIf="newPasswordRequired$ | async">新しいパスワードを設定してください。</p>
    <form [formGroup]="loginForm">
      <ion-list class="ion-margin-vertical">
        <ion-item>
          <ion-input placeholder="Email" formControlName="email"></ion-input>
        </ion-item>
        <ion-item>
          <ion-input placeholder="Password" type="password" formControlName="password"></ion-input>
        </ion-item>
        <ion-item *ngIf="newPasswordRequired$ | async">
          <ion-input placeholder="New Password" type="password" formControlName="newPassword"></ion-input>
        </ion-item>
      </ion-list>
    </form>
    <ion-button color="success" expand="full" class="ion-margin-vertical" 
      (click)="onCompleteNewPassword()" *ngIf="newPasswordRequired$ | async" [disabled]="!loginForm.valid">
      Confirm New Password
    </ion-button>
    <ion-button color="primary" expand="full" class="ion-margin-vertical" (click)="onLoginClicked()"
      [disabled]="!loginForm.valid" *ngIf="!(anyChallenge$ | async)">
      Login
    </ion-button>

    <p class="ion-margin" *ngFor="let errorMessage of errorMessages.email">
      <ion-text color="danger"
        *ngIf="loginForm.get('email').hasError(errorMessage.type) && (loginForm.get('email').dirty || loginForm.get('email').touched)">
        {{ errorMessage.message }}
      </ion-text>
    </p>

    <p class="ion-margin" *ngFor="let errorMessage of errorMessages.password">
      <ion-text color="danger"
        *ngIf="loginForm.get('password').hasError(errorMessage.type) && (loginForm.get('password').dirty || loginForm.get('password').touched)">
        {{ errorMessage.message }}
      </ion-text>
    </p>

    <p class="ion-margin" *ngFor="let errorMessage of errorMessages.newPassword">
      <ion-text color="danger"
        *ngIf="loginForm.get('newPassword').hasError(errorMessage.type) && (loginForm.get('newPassword').dirty || loginForm.get('password').touched)">
        {{ errorMessage.message }}
      </ion-text>
    </p>

    <p class="ion-text-center ion-margin-top">
      <ion-button fill="clear" routerLink="/forgot-password">
        パスワードを忘れた
      </ion-button>
    </p>
  </div>
</ion-content>

一番下に、ボタンを設置しました。

パスワードを忘れた時のページを作成

リンクボタンを設置したので、新たにページを作成して、作りましょう。ionic generate ですね。

$ ionic generate
What would you like to generate? page
 ? Name/path of page: forgot-password
   ng generate page forgot-password --project=app
   CREATE src/app/forgot-password/forgot-password-routing.module.ts (380 bytes)
   CREATE src/app/forgot-password/forgot-password.module.ts (530 bytes)
   CREATE src/app/forgot-password/forgot-password.page.scss (0 bytes)
   CREATE src/app/forgot-password/forgot-password.page.html (134 bytes)
   CREATE src/app/forgot-password/forgot-password.page.spec.ts (704 bytes)
   CREATE src/app/forgot-password/forgot-password.page.ts (291 bytes)
   UPDATE src/app/app-routing.module.ts (764 bytes)
   [OK] Generated page! 

これで、app-routing.module にルートが設定されるのと、forgot-password フォルダに各種ファイルが自動で生成されます。

<ion-header>
  <ion-toolbar>
    <ion-title>パスワード再設定</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <div id="container">
    <strong>パスワード再設定</strong>
    <form [formGroup]="forgotPasswordForm">
      <ion-list class="ion-margin-vertical">
        <ion-item>
          <ion-input placeholder="Email" formControlName="email"></ion-input>
        </ion-item>
        <ion-item>
          <ion-input placeholder="code" formControlName="code"></ion-input>
        </ion-item>
        <ion-item>
          <ion-input placeholder="New Password" type="newPassword" formControlName="newPassword"></ion-input>
        </ion-item>
      </ion-list>
    </form>
    <ion-button color="success" expand="full" class="ion-margin-vertical" 
      (click)="onForgotPasswordRequest()" [disabled]="!forgotPasswordForm.valid">
      パスワードを再設定する
    </ion-button>

  </div>
</ion-content>

こんな感じですね。

Auth Provider インターフェースを追加

パスワードを忘れた要求と、パスワードを変更する要求の2つが追加になります。

import { Observable } from "rxjs";
import { User } from "./user.model";

export interface AuthProvider { 
  currentAuthenticatedSession$: Observable<User>;
  isAuthenticated$: Observable<boolean>;
  completeNewPassword(password: string): Observable<User>;
  confirmSignIn(code: string): Observable<User>;
  forgotPasswordRequest(username: string): Observable<any>;
  forgotPasswordSubmit(
    username: string, 
    code: string, 
    newPassword: string,
  ): Observable<void>;
  login(authentication: {}): Observable<User>;
  logout(): Observable<any>;
}

cognito-auth.service.ts に実装

実装を追加しましょう。


  forgotPasswordRequest(username: string): Observable<any> {
    return from(Auth.forgotPassword(
      username
    ));
  }

  forgotPasswordSubmit(username: string, code: string, newPassword: string): Observable<void> {
    return from(Auth.forgotPasswordSubmit(
      username,
      code,
      newPassword,
    ));
  }

動くかわからないですが、こんな感じで実装しておきましょう。こう言う時に単体テストができるようにしておけばよかったなって思いますね。

Sliceに reducer を追加

必要そうなものをじゃんじゃん追加しましょう。以下では、xxxSuccess とか xxxxFailure とかのアクション名にして、payload は、からのまま渡すようにしていますが、もっと汎用的に payload を渡して両方処理させても良いと思います。

  • forgotPasswordRequest パスワードリセットが送信される時
  • forgotPasswordRequestSuccess パスワードリセット要求が成功した時(画面を切り替える)
  • forgotPasswordRequestFailure それが失敗した時
  • forgotPasswordSubmit 新しいパスワードを送信するとき
  • forgotPasswordSuccess 新しいパスワードへの変更が成功した時(同時にログイン画面へ遷移)

さて追加していきます。

import { createFeatureSelector } from '@ngrx/store';
import { createSlice } from '@reduxjs/toolkit';
import { User } from './user.model';

type AuthState = {
  currentAuthenticatedSession: User | null,
  isAuthenticated: boolean,
  loading: boolean,
  loginSuccess: boolean,
  challenge: null | 'NEW_PASSWORD_REQUIRED',
  redirectTo: string | null,
  waitCodeAndNewPassword: boolean,
}

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    currentAuthenticatedSession: null,
    loading: false,
    isAuthenticated: false,
    challenge: null,
    redirectTo: '',
    waitCodeAndNewPassword: false,
  } as AuthState,
  reducers: {
    initAuthState: (state, _) => {
      state.loading = true
    },
    redirectTo: (state, action) => {
      state.redirectTo = action.payload;
    },
    login: (state, _) => {
      state.loading = true
    },
    loginSuccess: (state, action) => {
      state.loading = false;
      state.currentAuthenticatedSession = action.payload;
    },
    loginFail: (state, _) => {
      state.loading = false;
      state.currentAuthenticatedSession = null;
    },
    logout: (state, _) => {
      state.loading = true;
    },
    logoutSuccess: (state, _) => {
      state.loading = false;
      state.currentAuthenticatedSession = null;
    },
    completeNewPassword: (state, _) => {
      state.loading = true;
    },
    completeNewPasswordSuccess: (state, _) => {
      state.loading = false;
    },
    completeNewPasswordFailure: (state, _) => {
      state.loading = false;
    },
    setChallenge: (state, action) => {
      state.challenge = action.payload;
    },
    forgotPasswordRequest: (state, action) => {
      state.loading = true;
    },
    forgotPasswordRequestSuccess: (state, action) => {
      state.loading = false;
      state.waitCodeAndNewPassword = true;
    },
    forgotPasswordRequestFailure: (state, _) => {
      state.loading = false;
      state.waitCodeAndNewPassword = false;
    },
    forgotPasswordSubmit: (state, action) => {
      state.loading = true;
    },
    forgotPasswordSuccess: (state, action) => {
      state.loading = false;
      state.waitCodeAndNewPassword = false;
    },
  }
});

const {
  reducer,
  actions: {
    login,
    logout,
    redirectTo,
    initAuthState,
    loginSuccess,
    loginFail,
    logoutSuccess,
    completeNewPassword,
    completeNewPasswordSuccess,
    completeNewPasswordFailure,
    setChallenge,
    forgotPasswordRequest,
    forgotPasswordRequestSuccess,
    forgotPasswordSubmit,
    forgotPasswordSuccess,
  },
  name
} = authSlice;

export default reducer;
export {
  name,
  login,
  logout,
  redirectTo,
  initAuthState,
  loginSuccess,
  loginFail,
  logoutSuccess,
  completeNewPassword,
  completeNewPasswordSuccess,
  completeNewPasswordFailure,
  setChallenge,
  forgotPasswordRequest,
  forgotPasswordRequestSuccess,
  forgotPasswordSubmit,
  forgotPasswordSuccess,
};

export const selectFeature = createFeatureSelector<ReturnType<typeof reducer>>(
  name
);

こんな感じですかね。ちょっとシンプルにしたい気もしますが、このままいきましょう。

Effect を追加

  • パスワードリセット要求
  • パスワード変更要求
  • パスワード変更完了(ログイン画面へ)

の3つで良いですかね。

  forgotPasswordRequest$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.forgotPasswordRequest),
      map(action => action.payload),
      mergeMap(username => {
        return this.authService.forgotPasswordRequest(username).pipe(
          map(() => {
            return authSlice.forgotPasswordRequestSuccess({});
          })
        )
      })
    )
  )

  forgotPasswordSubmit$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.forgotPasswordSubmit),
      map(action => <ForgotPasswordParams>action.payload),
      mergeMap(forgotPasswordParam => {
        return this.authService.forgotPasswordSubmit(
          forgotPasswordParam.username,
          forgotPasswordParam.code,
          forgotPasswordParam.newPassword
        ).pipe(
          map(() => {
            return authSlice.forgotPasswordSuccess({});
          })
        )
      }),
    )
  )

  forgotPasswordSuccess$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.forgotPasswordSuccess),
      tap(() => {
        this.router.navigate(['/login']);
      })
    ),
    { dispatch: false }
  )

これも動くかわかりませんが、このまま行きますw。やはり単体テストを含めてやっておけばよかった。。

Facade を修正

    createSelector(authSlice.selectFeature, (state) => state.waitCodeAndNewPassword)
  )

  forgotPasswordRequest(username: string): void {
    this.store.dispatch(authSlice.forgotPasswordRequest(username))
  }

  forgotPasswordSubmit(username: string, code: string, newPassword: string): void {
    this.store.dispatch(authSlice.forgotPasswordSubmit({
      username,
      code,
      newPassword,
    }));
  }

こんなもんですかね。

forgot-password.page.ts に処理を追加

必要な処理を書いていきます。code の入力時は画面遷移させずにフォームの出し分け処理でいきましょう。フォームのバリデーションも同時に変化させます。

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { filter, tap } from 'rxjs/operators';
import { AuthFacade } from '../auth/auth.facade';

@Component({
  selector: 'app-forgot-password',
  templateUrl: './forgot-password.page.html',
  styleUrls: ['./forgot-password.page.scss'],
})
export class ForgotPasswordPage implements OnInit {

  constructor(
    private fb: FormBuilder,
    private authService: AuthFacade,
  ) { }

  forgotPasswordForm: FormGroup = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    code: '',
    newPassword: '',
  });

  waitCodeAndNewPassword$ = this.authService.waitCodeAndNewPassword$.pipe(
    filter(required => required),
    tap(() => {
      this.forgotPasswordForm.get('code').setValidators([Validators.required])
      this.forgotPasswordForm.get('newPassword').setValidators([Validators.required, Validators.minLength(5)])
    })
  );

  ngOnInit() {
  };

  onForgotPasswordRequest() {
    this.authService.forgotPasswordRequest(
      this.forgotPasswordForm.get('email').value
    );
  }

  onForgotPasswordSubmit() {
    this.authService.forgotPasswordSubmit(
      this.forgotPasswordForm.get('email').value,
      this.forgotPasswordForm.get('code').value,
      this.forgotPasswordForm.get('newPassword').value
    );
  }
}

画面はこれでいきます。エラーメッセージを表示するのは読者に任せましょう。

<ion-header>
  <ion-toolbar>
    <ion-title>パスワード再設定</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <div id="container">
    <strong>パスワード再設定</strong>
    <form [formGroup]="forgotPasswordForm">
      <ion-list class="ion-margin-vertical">
        <ion-item>
          <ion-input placeholder="Email" formControlName="email"></ion-input>
        </ion-item>
        <ion-item *ngIf="waitCodeAndNewPassword$ | async">
          <ion-input placeholder="code" formControlName="code"></ion-input>
        </ion-item>
        <ion-item *ngIf="waitCodeAndNewPassword$ | async">
          <ion-input placeholder="New Password" type="password" formControlName="newPassword"></ion-input>
        </ion-item>
      </ion-list>
    </form>
    <ion-button color="success" expand="full" class="ion-margin-vertical" 
      (click)="onForgotPasswordSubmit()" [disabled]="!forgotPasswordForm.valid" *ngIf="waitCodeAndNewPassword$ | async">
      パスワードを再設定する
    </ion-button>
    <ion-button color="success" expand="full" class="ion-margin-vertical" 
      (click)="onForgotPasswordRequest()" [disabled]="!forgotPasswordForm.valid" *ngIf="!(waitCodeAndNewPassword$ | async)">
      パスワードのリセットを要求
    </ion-button>
  </div>
</ion-content>

さて、動くかどうか。一つ一つはシンプルなので動いて欲しいところ。

いきましたね。パスワードのリセットを要求するとメールアドレスにコードが届きます。それをコピペしてコードに入力新しいパスワードを設定して、新しいパスワードでログインするとちゃんとログインできます。

ユーザーへのフィードバックがなくてメールでコードが届くとかそういう説明が一切ないのでアプリとしては厳しいですが、動作は問題ないって感じですね。

ログインや ToDo のリクエストなどの loading 中にローダーを表示したい

ローダーを表示してみましょう。ローダーもUIに関連する状態を管理していると言えますので、uiSlice で管理するのも良いですが、初めから一元化していなかったので slice は作らないようにします。状態を取得するための uiService を作成しましょう。uiService は多くの Store に依存するものになりそうです。

ui.service.ts を作成する

loading 状況を収集して、状態をもつサービスを作ります。

import { Injectable } from '@angular/core';
import { createSelector, Store } from '@ngrx/store';
import  * as todoSlice from '../todo/todo.slice';
import  * as authSlice from '../auth/auth.slice';
import { merge } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class UiService {
  constructor(
    private readonly store: Store<{}>, 
  ) { }


  fetchLoading$ = this.store.select(
    createSelector(todoSlice.selectFeature, (state) => state.fetchLoading)
  )

  addToDoLoading$ = this.store.select(
    createSelector(todoSlice.selectFeature, (state) => state.addToDoLoading)
  )

  updateToDoLoading$ = this.store.select(
    createSelector(todoSlice.selectFeature, (state) => state.updateToDoLoading)
  )

  deleteToDoLoading$ = this.store.select(
    createSelector(todoSlice.selectFeature, (state) => state.deleteToDoLoading)
  )

  authLoading$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.loading)
  )

  loading$ = merge(
    this.fetchLoading$,
    this.addToDoLoading$,
    this.updateToDoLoading$,
    this.deleteToDoLoading$,
    this.authLoading$,
  )

}

手抜きして、loading$ と言う値に全部の状態をマージさせるようにしました。

app.component.ts にローディングを実装する

app.component に実装しておけば、どの画面からもうまく表示されるんちゃうかと言うことで、app.component に実装してみます。

import { Component, OnDestroy, OnInit } from '@angular/core';
import { AuthFacade } from './auth/auth.facade';
import { LoadingController } from '@ionic/angular';
import { UiService } from './ui/ui.service';
import { takeUntil, map, concatMap } from 'rxjs/operators';
import { of, Subject } from 'rxjs';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent implements OnInit, OnDestroy {

  constructor(
    private authService: AuthFacade,
    private loadingController: LoadingController,
    private uiService: UiService,
  ) {}

  destroy$ = new Subject();

  loading$ = this.uiService.loading$;

  loading: HTMLIonLoadingElement;

  ngOnInit() {
    this.authService.initAuthState();
    this.loading$.pipe(
      takeUntil(this.destroy$),
      concatMap(loading => {
        console.log(loading);
        if (loading) {
          return this.presentLoading();
         } else {
          return this.dismissLoading();
        }
      })
    ).subscribe();
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }

  async presentLoading() {
    this.loading = await this.loadingController.create({
      message: '読み込み中',
    });
    await this.loading.present();
  }

  async dismissLoading() {
    if (!this.loading) return;
    this.loading.dismiss();
    this.loading = null;
  }
}

ionic に LoadingController と言うものがありましたので、これを使います。AlertController と同じように使えます。pipe のところで、concatMap を使っていますが、順番に実行させるようにするためにこれを使っています。

これだけです。やってみましょう。

ちょっと出過ぎな感じがしますが、できましたね。

大きくスワイプした時も削除したい

大きくスワイプすると削除するような UI って割とありますよね。iOS メールとかもそうなっているかと思います。ToDo 画面にこれを実装しましょう。

すでに、ion-item-sliding と言うのを使っていますので、割と簡単にできます。

      <ion-item-options side="end" (ionSwipe)="onDeleteClicked(todo)">          <!-- これを追加だけ!
        <ion-item-option color="danger" expandable (click)="onDeleteClicked(todo)">
          削除
        </ion-item-option>
      </ion-item-options>

やってみます。

思ったんとちゃう感ありますが、できました。アニメーションなどで気持ちよくできるんでしょうか。

done にした時や削除した時の動作を滑らかにしたい

まずは delete 時

todo.page.ts にアニメーションを定義していきます。

  onDelete($event, todo: ToDo): void {
    this.animateDisappear($event.target);
    this.todoService.deleteToDo(todo);
  }

  animateDisappear(element) {
    const hostElement = element.closest('ion-item-sliding');
    this.animationController.create()
    .addElement(hostElement)
    .duration(200)
    .easing('ease-out')
    .fromTo('height', '48px', '0').play()
  }

まとめて一つの関数でやっちゃいましょう。

      <ion-item-options side="end" (ionSwipe)="onDelete($event, todo)">
        <ion-item-option color="danger" expandable (click)="onDelete($event, todo)">
          削除
        </ion-item-option>
      </ion-item-options>

イベントを拾います。

やってみましょう。

広がった赤いオプションが戻っていくのが気持ち悪いですが、ここではこれでよしとしましょう。

Done にした時の順番が入れ替わるアニメーションは、読者に任せることとします。

新規登録できるようにする

アプリケーションを公開すると、新たなユーザーがやってきます。その都度、バックエンドでアカウント発行しても良いのですが、多数の個人ユーザーがいる場合はそうもいきません。そこで画面上で新規登録できるようにしておきましょう。

手順を考える

Cognito はよく考えられた認証システムなので、これをベース考えてみます。

公式ドキュメントを確認すると、signUp と confirmSignUp と言う API があるのでこれを使ったフローを考えます。

  • まず、email と パスワード で新規登録する
  • 確認コードがメールで届くので、それをフォームで入力して送信する
  • ログイン画面で、email と パスワードでログインする

この流れでやってみましょう。

新規登録画面を用意する

ionic generate しましょう。

$ ionic generate
 ? What would you like to generate? page
 ? Name/path of page: signup
   ng generate page signup --project=app
   CREATE src/app/signup/signup-routing.module.ts (347 bytes)
   CREATE src/app/signup/signup.module.ts (472 bytes)
   CREATE src/app/signup/signup.page.scss (0 bytes)
   CREATE src/app/signup/signup.page.html (125 bytes)
   CREATE src/app/signup/signup.page.spec.ts (647 bytes)
   CREATE src/app/signup/signup.page.ts (256 bytes)
   UPDATE src/app/app-routing.module.ts (881 bytes)
   [OK] Generated page! 

続いて、サインアップに必要な機能をサービスクラスに追加します。

  requestSignUp?(username: string, password: string): Observable<any>;
  confirmSignUp?(uesrname: string, code: string): Observable<any>;

まずは、インターフェースを定義して、

  requestSignUp(username: string, password: string): Observable<any> {
    return from(Auth.signUp(username, password));
  }

  confirmSignUp(username: string, code: string): Observable<any> {
    return from(Auth.confirmSignUp(username, code));
  }

実装を追加します。

Slice にリデューサーを定義して、export して

    requestSignUp: (state, action) => {
      state.loading = true;
    },
    requestSignUpSuccess: (state, action) => {
      state.loading = false;
      state.waitForConfirmSignUp = true;
    },
    confirmSignUp: (state, action) => {
      state.loading = true;
    },
    confirmSignUpSuccess: (state, action) => {
      state.loading = false;
      state.waitForConfirmSignUp = false;
    }

続いて、副作用を起こす Effect を定義します。

  requestSignUp$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.requestSignUp),
      map(action => <RequestSignUpParams>action.payload),
      mergeMap(requestSignUpParams => {
        return this.authService.requestSignUp(
          requestSignUpParams.username,
          requestSignUpParams.password,
        ).pipe(
          map(() => {
            return authSlice.requestSignUpSuccess({});
          })
        )
      })
    )
  )

  confirmSignUp$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.confirmSignUp),
      map(action => <ConfirmSignUpParams>action.payload),
      mergeMap(confirmSignupParams => {
        console.log('confirm signup effects')
        console.log(confirmSignupParams)
        return this.authService.confirmSignUp(
          confirmSignupParams.username,
          confirmSignupParams.code,
        ).pipe(
          map(() => {
            return authSlice.confirmSignUpSuccess({});
          }),
          catchError(err => {
            console.error(err);
            return of(authSlice.confirmSignUpSuccess({}));
          })
        )
      })

    )
  )

  confirmSignUpSucess$ = createEffect(
    () => this.actions$.pipe(
      ofType(authSlice.confirmSignUpSuccess),
      concatMap(some => {
        console.log(some)
        console.log('confirm signup success')
        return from(this.router.navigate(['/login']));
      })
    ),
    { dispatch: false}
  )

コンポーネントから呼び出せるように、facade にも実装します。

  requestSignUp(username: string, password: string): void {
    this.store.dispatch(authSlice.requestSignUp({
      username,
      password,
    }));
  }

  confirmSignUp(username: string, code: string): void {
    console.log('confirm sign up facade');
    this.store.dispatch(authSlice.confirmSignUp({
      username,
      code,
    }))
  }

  goToConfirmPhase() {
    this.store.dispatch(authSlice.requestSignUpSuccess({}))
  }

Facade で、画面の状態を変化させるだけの goToConfirmPhase と言うものを実装しています。これは、コード入力画面にただ単に遷移させるだけのものです。

これらの機能を使って画面を作ります。

import { Variable } from '@angular/compiler/src/render3/r3_ast';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { filter, tap } from 'rxjs/operators';
import { AuthFacade } from '../auth/auth.facade';

@Component({
  selector: 'app-signup',
  templateUrl: './signup.page.html',
  styleUrls: ['./signup.page.scss'],
})
export class SignupPage implements OnInit {

  constructor(
    private fb: FormBuilder,
    private authService: AuthFacade,
  ) { }

  signUpForm: FormGroup = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(5)]],
    code: [''],
  });

  waitForConfirmSignUp$ = this.authService.waitForConfirmSignUp$.pipe(
    filter(required => required),
    tap(() => {
      this.signUpForm.get('password').clearValidators();
      this.signUpForm.get('password').setErrors(null);
      this.signUpForm.get('code').setValidators([Validators.required]);
      this.signUpForm.setErrors(null);
    })
  );

  ngOnInit() {
  }

  onConfirmSignUp() {
    console.log('confirm signup')
    this.authService.confirmSignUp(
      this.signUpForm.get('email').value,
      this.signUpForm.get('code').value,
    )
  }
  
  onRequestSignUp() {
    this.authService.requestSignUp(
      this.signUpForm.get('email').value,
      this.signUpForm.get('password').value,
    )
  }

  goToConfirmPhase() {
    this.authService.goToConfirmPhase();
  }

}

サインアップページのデータを作っています。サービスも呼び出す準備をしています。

<ion-header>
  <ion-toolbar>
    <ion-title>新規登録</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <div id="container">

    <strong>新規登録</strong>

    <form [formGroup]="signUpForm">
      <ion-list class="ion-margin-vertical">
        <ion-item>
          <ion-input placeholder="Email" formControlName="email"></ion-input>
        </ion-item>
        <ion-item *ngIf="waitForConfirmSignUp$ | async">
          <ion-input placeholder="code" formControlName="code"></ion-input>
        </ion-item>
        <ion-item *ngIf="!(waitForConfirmSignUp$ | async)" >
          <ion-input placeholder="Password" type="password" formControlName="password"></ion-input>
        </ion-item>
      </ion-list>
    </form>

    <ion-button color="success" expand="full" class="ion-margin-vertical" (click)="onConfirmSignUp()"
      [disabled]="!signUpForm.valid" *ngIf="waitForConfirmSignUp$ | async">
      コードを確認する
    </ion-button>

    <ion-button color="success" expand="full" class="ion-margin-vertical" (click)="onRequestSignUp()"
      [disabled]="!signUpForm.valid" *ngIf="!(waitForConfirmSignUp$ | async)">
      新規登録
    </ion-button>

    <ion-button color="success" fill="clear" class="ion-margin-vertical" (click)="goToConfirmPhase()" *ngIf="!(waitForConfirmSignUp$ | async)">
      コード入力
    </ion-button>
  </div>
</ion-content>

画面の出しわけを含め、フォームを構成しています。scss は、ログインのものを流用してください。

では試してみましょう。

あくまでスパルタンですね。説明が全くないので、困っちゃいます。ただ、ローディングは追加した分も同じように動いていますね。

総じてうまくいってますね。何もかも一発でうまくいって怪しいですねw

当たり前ですが、凡ミスは修正しながらやっています。

その4へ続く