Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular 4 - canActivate observable not invoked

I am trying to implement canActivate in Angular 2/4 using RxJS observables. I have already read another SO question on this. With the following code, my canActivate method works only once, when the app launches, but hello is never printed again when the isLoggedIn observable triggers new values.

canActivate(): Observable<boolean> {
  return this.authService.isLoggedIn().map(isLoggedIn => {
    console.log('hello');
    if (!isLoggedIn) {
      this.router.navigate(['/login']);
    }
    return isLoggedIn;
  }).first();
}

or this version is not working as well:

canActivate(): Observable<boolean> {
  return this.authService.isLoggedIn().map(isLoggedIn => {
    console.log('hello');
    if (isLoggedIn) {
      this.router.navigate(['/']);
    }
    return !isLoggedIn;
  });
}

However, it works fine with this code:

canActivate(): Observable<boolean> {
  return Observable.create(obs => {
    this.authService.isLoggedIn().map(isLoggedIn => {
      console.log('hello');
      if (isLoggedIn) {
        this.router.navigate(['/']);
      }
      return !isLoggedIn;
    }).subscribe(isLoggedIn => obs.next(isLoggedIn));
  });
}

What am I doing wrong in the first piece of code ?

EDIT: here is the isLoggedIn implementation

@LocalStorage(AuthService.JWT_TOKEN_KEY)
private readonly token: string;
private tokenStream: Subject<string>;

public isLoggedIn(): Observable<boolean> {
  if (!this.tokenStream) {
    this.tokenStream = new BehaviorSubject(this.token);
    this.storage.observe(AuthService.JWT_TOKEN_KEY)
      .subscribe(token => this.tokenStream.next(token));
  }
  return this.tokenStream.map(token => {
    return token != null
  });
}

that uses ngx-webstorage. and RxJS BehaviorSubject.

like image 253
user5365075 Avatar asked Jun 22 '17 10:06

user5365075


1 Answers

Challenges of AuthService with RxJs

This was one of the things I struggled with when switching from AngularJs's promises to Angular's Observable pattern. You see promises are pull notifications and observables are push notifications. As such, you have to rethink your AuthService so that it uses a push pattern. I kept thinking in terms of pulling even when I wrote working Observables. I couldn't stop thinking in terms of pulling.

With a promise pattern it was easier. When the AuthService was created it would either create a promise that resolved to "not logged in" or it would create an async promise that would "restore logged state". You could then have a method named isLoggedIn() that would return that promise. That allowed you to easily handle delays between showing user data and when you receive user data.

AuthService as a Push service

Now, we switch to Observables and the verb "is" needs to be changed to "when". Making this small change helps you re-think how things are going to work. So lets rename "isLoggedIn" to "whenLoggedIn()" which will be an Observable that emits data when a user authenticates.

class AuthService {
     private logIns: Subject = new Subject<UserData>();

     public setUser(user: UserData) {
          this.logIns.next(user);
     }

     public whenLoggedIn(): Observable<UserData> {
          return this.logIns;
     }
}

// example
AuthService.whenLoggedIn().subscribe(console.log);
AuthService.setUser(new UserData());

When a user passed to setUser it's emitted to subscribes that a new user has been authenticated.

Problems With Above Approach

The above introduces several problems that need to be fixed.

  • subscribing to whenLoggedIn will listen for new users forever. The pull stream is never completed.
  • There is no concept of "current state". The previous setUser is lost after being pushed to subscribers.
  • It only tells you when a user is authenticated. Not if there is no current user.

We can fix some of this by switching from Subject to BehaviorSubject.

class AuthService {
     private logIns: Subject = new BehaviorSubject<UserData>(null);

     public setUser(user: UserData) {
          this.logIns.next(user);
     }

     public whenLoggedIn(): Observable<UserData> {
          return this.logIns;
     }
}

// example
AuthService.whenLoggedIn().first().subscribe(console.log);
AuthService.setUser(new UserData());

This is much closer to what we want.

Changes

  • BehaviorSubject will always emit the last value for each new subscription.
  • whenLoggedIn().first() was added to subscribe and auto unsubscribe after the first value is received. If we didn't use BehaviorSubject this would block until someone called setUser which might never happen.

Problems With BehaviorSubject

BehaviorSubject doesn't work for AuthService and I'll demonstrate with this sample code here.

class AuthService {
     private logIns: Subject = new BehaviorSubject<UserData>(null);

     public constructor(userSessionToken:string, tokenService: TokenService) {
          if(userSessionToken) {
              tokenService.create(userSessionToken).subscribe((user:UserData) => {
                    this.logIns.next(user);
               });
         }
     }

     public setUser(user: UserData) {
          this.logIns.next(user);
     }

     public whenLoggedIn(): Observable<UserData> {
          return this.logIns;
     }
}

Here's how the problem would appear in your code.

// example
let auth = new AuthService("my_token", tokenService);
auth.whenLoggedIn().first().subscribe(console.log);

The above creates a new AuthService with a token to restore the user session, but when it runs the console just prints "null".

This happens because BehaviorSubject is created with an initial value of null, and the operation to restore the user session is going to happen later after the HTTP call is complete. AuthService will continue to emit null until the session is restored, but that's a problem when you want to use route activators.

ReplaySubject Is Better

We want to remember the current user, but not emit anything until we know if there is a user or not. ReplaySubject is the answer to this problem.

Here's how you would use it.

class AuthService {
     private logIns: Subject<UserData> = new ReplaySubject(1);

     public constructor(userSessionToken:string, tokenService: TokenService) {
          if(userSessionToken) {
              tokenService.create(userSessionToken).subscribe((user:UserData) => {
                    this.logIns.next(user);
               }, ()=> {
                    this.logIns.next(null);
                    console.error('could not restore session');
               });
         } else {
             this.logIns.next(null);
         }
     }

     public setUser(user: UserData) {
          this.logIns.next(user);
     }

     public whenLoggedIn(): Observable<UserData> {
          return this.logIns;
     }
}

// example
let auth = new AuthService("my_token", tokenService);
auth.whenLoggedIn().first().subscribe(console.log);

The above will not wait until the first value is emitted by whenLoggedIn. It will get the first value and unsubscribe.

ReplaySubject works because it remembers the 1 items or emits nothing. It's the nothing part that is important. When we use AuthService in canActivate we want to wait until a user state is known.

CanActivate Example

This now makes it a lot easier to write a user guard that redirects to a login screen or allows the route to change.

class UserGuard implements CanActivate {
      public constructor(private auth: AuthService, private router: Router) {
      }

      public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
           return this.auth.whenLoggedIn()
                      .first()
                      .do((user:UserData) => {
                          if(user === null) {
                              this.router.navigate('/login');
                          }
                      })
                      .map((user:UserData) => !!user);
      }

This will yield an Observable of true or false if there is a user session. It will also block router change until that state is known (i.e. are we fetching data from the server?).

It will also redirect the router to the login screen if there is no user data.

like image 118
Reactgular Avatar answered Sep 25 '22 08:09

Reactgular