Reputation: 583
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?
Upvotes: 0
Views: 4149
Reputation: 583
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:
private userStoreSubject = new BehaviorSubject<User | null>(null);
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:
userStoreSubject
null
, return the useruserProfile$
(which means that the profile will be fetched from the server)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));
})
);
}
Upvotes: 0
Reputation: 60518
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.
Upvotes: 3