0

I am developing a small project using Angular, Nodejs, Express, MySQL. In the project there is 2 type of user, customer user and contractor user.

I am having difficulty in using CanActivate to guard the route for the 'Contractor Profile Page' and 'User Profile Page'.

The contractor should not access customer user profile and customer user should not be access contractor profile

In MySQL database, I stored a value 'isContractor' which is used to identify if a user is a contractor or not. And I am using JWT to persist my login, each time refresh I will request all data from the server once again including the isContractor using the JWT (If the JWT expired I will use a refresh token to get new JWT, therefore the auto logging might took some time to process).

Here is the problem. When I refreshed on my 'Contractor or Customer User profile page' the boolean value I get is not correctly reflecting the user type of the logged in user(the CanActivate took the default value I set). Therefore the guard is also not working correctly as it is intended to be.

Contractor Guard:

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthService } from '../auth.service';

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

  constructor(private authService: AuthService,
    private router: Router) {
    //
  }

  public canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    return this.authService.isContractorUser().pipe(
      map(isContractor => {

        console.log('contractor is csutomer? : ' + isContractor);
        if (!isContractor) {
          this.router.navigate(['page-forbidden']);
          return false;
        } else {
          return true;
        }
      })
    );
  }
}

Customer Guard:

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthService } from '../auth.service';

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

  constructor(private authService: AuthService, private router: Router) {
    //
  }

  public canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    return this.authService.isContractorUser().pipe(
      map(isContractor => {
        console.log('Customer is contractor : ' + isContractor);
        if (isContractor) {

          this.router.navigate(['page-forbidden']);
          return false;
        } else {
          return true;
        }
      })
    );

  }
}

Login Guard(To guard the login page for logged in user):

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthService } from '../auth.service';

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

  constructor(private authService: AuthService, private router: Router) {
    //
  }

  public canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    return this.authService.isAuthenticated().pipe(
      map(isAuthenticated => {
        if (isAuthenticated) {
          this.router.navigate(['page-forbidden']);
          return false;
        } else {
          return true;
        }
      })
    );
  }
}

Function to return the Behavioral subject of 'isContractor' and 'isLoggedIn':

import { Injectable } from '@angular/core';
import { UserService } from './user.service';
import { SnackbarService } from './snackbar.service';
import { Router } from '@angular/router';
import { BehaviorSubject, Observable } from 'rxjs';
import { HttpHeaders } from '@angular/common/http';


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

  constructor(private userService: UserService,
    private snackbarService: SnackbarService,
    private router: Router) { }

  private _loggedIn = new BehaviorSubject<boolean>(false);
  private loggedIn = this._loggedIn.asObservable();
  private _isContractor = new BehaviorSubject<boolean>(false);
  private isContractor = this._isContractor.asObservable();

  public isAuthenticated(): Observable<boolean> {
    return this.loggedIn;
  }

  public isContractorUser(): Observable<boolean> {
    return this.isContractor;
  }
}

Function to persist logging with JWT (This function is placed in ngOnInit of the app.component.ts, so it will be called every time after the hard refresh or revisit the page.):

public autoLogin(): void {
    if (localStorage.getItem("accessToken")) {

      //Persist the login with JWT, Server verify if it is expired. If it is, try create new JWT with the refresh JWT (The logic is in error section).
      this.userService.loginWithJwt(localStorage.getItem('accessToken')).subscribe(
        response => {
          if (response.status == 200) {
            this.userData = response.body.data[0];
            localStorage.setItem('IsLoggedIn', '1');
            this._loggedIn.next(true);

            if (this.userData['isContractor']) {
              this._isContractor.next(true);
            } else {
              this._isContractor.next(false);
            }
          }
        })
}

As you can see the value for 'isContractor' and 'isLoggedIn' behavioral subject is updated with the API route call 'loginWithJWT' which is an Observable. Thus there is some asynchronous problem that the value in the Customer Guard or Contractor guard will always use the default value.

Demo video on youtube for the problem: https://youtu.be/HrjkfMd_YlA

I had tried

1.promise (by using session storage to determine if the value is retrieved from the database before returning a new boolean value, but for some reason, the Guard will just take the default value even with this approach.)

2.BehaviouralSubject (Observable)

I am stuck at how to make the CanActivate not taking the boolean value of isContractor earlier than the value retrieved from the database. It seems like the value is always lagging behind. I believe this is some asynchronous problem that I am not sure how to solve...

  • Just discovered similar question here. https://stackoverflow.com/questions/69194766/delay-canactivate-call-when-page-refreshes-in-order-to-wait-for-the-data-to-load – HengHeng123 May 21 '23 at 17:58
  • Add your code to see what is going on. – René May 21 '23 at 20:25
  • @René I updated the question with Image, I hope it makes it clearer and I hope I am not wrong with my understanding with the JWT auto login logic. I am here humbly and open for any suggestions for improvement. – HengHeng123 May 22 '23 at 06:07
  • Please [don't post code as images](https://meta.stackoverflow.com/questions/285551/why-should-i-not-upload-images-of-code-data-errors). – Octavian Mărculescu May 22 '23 at 06:30
  • 1
    @OctavianMărculescu Done editing. – HengHeng123 May 22 '23 at 06:39

1 Answers1

1

You can turn _isContractor to a plain Subject instead of a BehaviorSubject. Because the fact that your observable source is a BehaviorSubject (which by definition is created with a default value) the rest of your asynchronous logic just uses that value instead of actually waiting for it to be found out (request to server in your case).

When you use a plain Subject, there will be no default value involved, and all of your logic that depends on this, will wait until your subject emits; that will happen after you authenticate, when you call _isContractor.next.

There is also another thing that you could do: use your isLoggedIn observable to actually check isContractor after the user is logged in. Something like this:

export class CustomerGuard {

  constructor(private authService: AuthService, private router: Router) {
    //
  }

  public canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {

    return this.authService.isAuthenticated().pipe(
      filter(isAuthenticated => isAuthenticated),
      switchMap(() => this.authService.isContractorUser())
      map(isContractor) => {
        console.log('Customer is contractor : ' + isContractor);
        if (isContractor) {

          this.router.navigate(['page-forbidden']);
          return false;
        } else {
          return true;
        }
      })
    );
  }
}

Octavian Mărculescu
  • 4,312
  • 1
  • 16
  • 29
  • Thanks for the suggestion, this answer solve the problem for asyn guard, but another problem pop up. The routerlink is not working... I can only access a link by manually enter it in the URL. But I will try solve it. – HengHeng123 May 22 '23 at 10:22
  • I have a solution for this, but its going to affect the user experience, I am redirecting the page using 'window.location.href', thus each time the user clicked on the menu option for the profile, they won't have the smooth feeling same as the angular router link, instead it is like a hard refresh. But I believe there is better solution for this, but just because I am inexperience in Angular routing. – HengHeng123 May 22 '23 at 10:51
  • 1
    Oh wait I actually found a way, it is called 'ReplaySubject' that emit all the old values. So in my case I case make emit only the last value to represent the latest state, and now my routerlink can still work. – HengHeng123 May 22 '23 at 11:19