0

I have a user service which allows login, logout and maintains data about the currently logged in user:

user$ = this.http.get<User>('/api/user')
            .pipe(
              shareReplay(1),
            );

I am using shareReplay(1) because I do not want the webservice to be called several times.

On one of the components, I have this (for simplicity), but I have several other things I want to do if a user is logged in:

<div *ngIf="isUserLoggedIn$ | async">Logout</div>
isUserLoggedIn$ = this.userService.user$
                    .pipe(
                      map(user => user != null),
                      catchError(error => of(false)),
                    );

However, the isLoggedIn$ does not change after the user logs in or logs out. It does change when I refresh the page.

Here's my logout code:

logout() {
  console.log('logging out');
  this.cookieService.deleteAll('/');

  this.user$ = null;

  console.log('redirecting');
  this.router.navigateByUrl('/login');
}

I understand that the internal observable is not reset if I assign the variable to null.

So, for logout, I took clue from this answer: https://stackoverflow.com/a/56031703 about refreshing a shareReplay(). But, the user$ being used in the templates causes my application to go into a tizzy as soon as I attempt to logout.

In one of my attempts, I tried BehaviorSubject:

user$ = new BehaviorSubject<User>(null);

constructor() {
  this.http.get<User>('/api/user')
    .pipe(take(1), map((user) => this.user$.next(user))
    .subscribe();
}

logout() {
  ...
  this.user$.next(null);
  ...
}

This works a little better except when I refresh the page. The auth-guard (CanActivate) always gets the user$ as null and redirects to the login page.

This seemed like an easy thing to do when I started out, but I am going on falling into a deeper hole with each change. Is there a solution to this?

funkycoder
  • 525
  • 4
  • 17
  • You never assign `$user` in your constructor, that is probably why you are getting an error in the `auth-gaurd`. That should be `this.user$ = this.http.get.....` – Igor Jul 16 '20 at 12:41
  • Also I would make my services stateless *but* if you want to track state like in a `user$` object that made use of `shareReplay` than I would abstract that from the consuming component. Create a method like `getUser` and have it return an observable and make `user$` private. The method can handle the implementation of assigning `user$` and returning it or returning the existing `user$` value. – Igor Jul 16 '20 at 12:44
  • If you want more help please provide a complete implementation of the code in your question, see also [mcve]. – Igor Jul 16 '20 at 12:45
  • @Igor, thanks for the response. `user$` is being initialized in the constructor – funkycoder Jul 16 '20 at 14:25
  • Sticking data in behavior subjects is the standard Angular practice these days. Even if you use some state management library like ngrx you are sticking your data in behaviour subjects in the background. – Adrian Brand Jul 17 '20 at 05:00

2 Answers2

3

For scenarios like this, I use a data stream (user) with an action stream (log user in) and use combineLatest to merge the two:

  private isUserLoggedInSubject = new BehaviorSubject<boolean>(false);
  isUserLoggedIn$ = this.isUserLoggedInSubject.asObservable();

  userData$ = this.http.get<User>(this.userUrl).pipe(
    shareReplay(1),
    catchError(err => throwError("Error occurred"))
  );

  user$ = combineLatest([this.userData$, this.isUserLoggedIn$]).pipe(
    map(([user, isLoggedIn]) => {
      if (isLoggedIn) {
        console.log('logged in user', JSON.stringify(user));
        return user;
      } else {
        console.log('user logged out');
        return null;
      }
    })
  );

  constructor(private http: HttpClient) {}

  login(): void {
    this.isUserLoggedInSubject.next(true);
  }

  logout(): void {
    this.isUserLoggedInSubject.next(false);
  }

I have a working stackblitz here: https://stackblitz.com/edit/angular-user-logout-deborahk

The user$ is the combination of the user data stream and the isUserLoggedIn$ stream that emits a boolean value. That way we can use a map and map the returned value to the user OR to a null if the user has logged out.

Hope something similar works for you.

DeborahK
  • 57,520
  • 12
  • 104
  • 129
  • thank you for your response. I followed your pattern and it absolutely fixed the issue for me. The only thing I had to do was to `pipe` from `user$ in the constructor of the service in order to set the initial state (auth token is in the cookies). Thank you so much for helping me out. – funkycoder Jul 17 '20 at 09:13
  • just curious, how does one think in Rx terms? I know Rx library is huge and it is not possible to know everything. Is there a good tutorial which helps to align the thoughts to RxJS, to better think through solutions? – funkycoder Jul 17 '20 at 10:43
  • This might help: https://www.youtube.com/watch?v=Z76QlSpYcck Or if you have Pluralsight accountt: https://app.pluralsight.com/library/courses/rxjs-angular-reactive-development (If you don't have a Pluralsight account, reach out to me on Twitter @deborahkurata and I'll get you a month pass.) – DeborahK Jul 17 '20 at 18:13
  • thanks. i'll do that. I have one more regression from this approach. The `user$` and `userData$` retain their value. So after logout, it is not possible for a new user to login. How do I refresh these values? – funkycoder Jul 19 '20 at 16:42
  • I updated the stackblitz to add more functionality. Hopefully that will give you a starting point. – DeborahK Jul 19 '20 at 17:46
  • this approach does not do well with `CanActivate` auth-guards in my application, which rely on piping from `user$`. Instead of the userId (in your example) I used the token that I have in cookie to push for reauthenticating the user, but the user observable is not ready in time before the authguards kick in. I used a different approach by having `user$` as an observable from a `BehaviorSubject` but that, too, has the same issue. – funkycoder Jul 20 '20 at 10:56
  • If you could fork the stackblitz and add code demonstrating your CanActivate issue, I'll take a look. – DeborahK Jul 20 '20 at 15:54
  • Here it is: https://stackblitz.com/edit/angular-user-logout-deborahk-hz1agh . This one is quite close to what I have in my app right now. If you enable the guards in `app-routing.module`, you'll see that the pages stop loading because, I think, the user is not loaded by the time guards check for it. I did a `next()` in the constructor of `user.service` to load a default user on start, but that does not help. (this was to simulate an already logged in user whose token I have in the cookie - so refreshing the user page should reload the user) I really appreciate your time in this. Thanks again. – funkycoder Jul 21 '20 at 11:29
  • At this point, would it make sense to make a new question to ask about streams and guards since we are no longer on the topic defines by this question? – DeborahK Jul 21 '20 at 16:28
  • Short answer ... try changing your Subject to a BehaviorSubject so it has an initial value. ` private logInSubject = new BehaviorSubject(1);` – DeborahK Jul 21 '20 at 16:40
  • that worked for a bit, but introduced a new problem which I was struggling with earlier. In the user page as many times I use `$user`, a network call is made. In my actual app, I loop over some data which needs to use some value from the user profile. That app becomes irresponsive immediately because of the continuous network calls. In the stackblitz example, you can see multiple network calls for each line that is displayed. (i know i can put `*ngIf="$user | async as user"`, but loops will kill it for sure). – funkycoder Jul 21 '20 at 19:17
  • At this point, the app is partly declarative/reactive and partly procedural which is causing some problems. You probably need to pick one or the other and go with it. I'll fork your stackblitz, and see what I have time/understanding of your requirements to modify. – DeborahK Jul 21 '20 at 22:12
  • My understanding is that you have three scenarios: (1) user has not yet logged in. The user enters their credentials which then requires an http request. (2) User has previously logged in and you get the userId info from a token. This also requires an http request? (3) The user logged out. Is that right? – DeborahK Jul 21 '20 at 22:20
  • Here is another stackblitz that is a fork of your last one. Hopefully that's close to what you are looking for, but I'm not sure I understood the purpose of each of your guards. Anyway, hope it provides a decent starting point: https://stackblitz.com/edit/angular-user-logout-deborahk-prqiaz – DeborahK Jul 21 '20 at 23:26
  • hi, thanks for that. The stackblitz app takes care of (1) user logged out, and (2) user log in. It does not take care of (3) user already logged in. If I set the `userId = 1`, it should fetch the user info directly and log the user in. Somehow, this was much easier with `Promise`s. I know you have already put in a lot of your time in this. And it will be understandable if you do not wish to continue to help me. But, if you have any further suggestions, I will be happy to hear it. – funkycoder Jul 22 '20 at 05:42
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/218348/discussion-between-funkycoder-and-deborahk). – funkycoder Jul 22 '20 at 09:48
  • Instead of going to chat (which then is difficult for anyone else to find ... I would prefer to define another SO question so that all of this additional info would be readily searchable. If you were able to do all of this with promises more easily ... no reason you couldn't continue to do that. Regarding setting the userId to 1, where would that need to happen? (See a change to the stackblitz I provided earlier.) – DeborahK Jul 25 '20 at 00:00
  • In the `UserService` class, the `userId` is set to 1. This simulates the scenario where the user Auth token is picked from the cookie. It should log the user in, and the Auth guard should redirect the user to the user page. I did start a new SO question with all the findings and links so far: https://stackoverflow.com/questions/63061257/angular-app-user-login-logout-and-session-reload-with-observables – funkycoder Jul 25 '20 at 04:47
0

I was finally able to resolve my problem. And I understood that this is the best example of the A & B problem. For a problem A I thought that the solution is B and started researching into that, however, the solution was actually C.

I hope I understand RxJS in a better way than how it was around 1.5 years ago. I'm putting my answer here so that it helps someone.

To recap, my requirement was simple - If a user lands on my angular app, the AuthGuard should be able to fetch and identify the user using the cookie. If the cookie is expired, then it should redirect the user to the login page.

I think this is a pretty common scenario and RxJS is a great approach to solve this.

Here is how I implemented it now:

An api /api/user sends a request to the server. Server uses the auth token in the cookie to identify the user.

This can lead to two scenarios:

  1. the cookie is still active and the server returns the profile data of the user. I store this in a private member for later use.

private userStoreSubject = new BehaviorSubject<User | null>(null);

  1. the cookie has expired and the server could not fetch the data (server throws a 401 Unauthorized error).

Here's how the user profile is retrieved:

  private userProfile$ = this.http.get<User>('/api/user').pipe(
    switchMap((user) => {
      this.userStoreSubject.next(user);
      return this.userStoreSubject;
    }),
    catchError(() => throwError('user authentication failed')),
  );

Note that if the server returns an error (401, that is), it is caught in catchError() which in turn throws the error again using throwError(). This is helpful in the next step.

Now, since we know how to fetch the user profile from the server and have a BehaviorSubject to save the currently active user, we can use that to create a member to make the user available.

  user$ = this.userStoreSubject.pipe(
    switchMap((user) => {
      if (user) {
        return of(user);
      }

      return this.userProfile$;
    }),
    catchError((error) => {
      console.error('error fetching user', error);
      return of(null);
    }),
  );

Notice the use of switchMap() because we are returning an observable. So, the code above simply boils down to:

  1. Read userStoreSubject
  2. If user is not null, return the user
  3. If user is null, return userProfile$ (which means that the profile will be fetched from the server)
  4. If there is an error (from userProfile$), return null.

This enables us to have an observable to check if the user is logged in:

  isUserLoggedIn$ = this.userStoreSubject.pipe(
    map((user) => !!user),
  );

Note that this reads from userStoreSubject and not user$. This is because I do not want to trigger a server read while trying to see if the user is logged in.

The logout function is simplified too. All I need to do is to make the user store null and delete the cookie. Deleting the cookie is important, otherwise fetching the profile will retrieve the same user again.

  logout() {
    this.cookieService.delete('authentication', '/', window.location.hostname, window.location.protocol === 'https:', 'Strict');
    this.userStoreSubject.next(null);
    this.router.navigate(['login']);
  }

And now, my AuthGuard looks like this:


  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
    return this.userService.user$
      .pipe(
        take(1),
        map((user) => {
          if (!user) {
            return this.getLoginUrl(state.url, user);
          }

          return true;
        }),
        catchError((error) => {
          console.error('error: ', error);
          return of(this.getLoginUrl(state.url, null));
        })
      );
  }

funkycoder
  • 525
  • 4
  • 17