DEV Community

Cover image for NgRx Use Cases, Part I: Restricting Access
Armen Vardanyan for This is Angular

Posted on • Edited on

NgRx Use Cases, Part I: Restricting Access

Original cover photo by Sandy Millar on Unsplash.

What is access restricting?

Restricting access in an application is essentially modifying how different users may interact with our platform. Some users may have permissions to see some pages and some might not; authenticated users may be able to visit certain segments of an app, and maybe not able to visit others; or it can be more granular, like disabling sections in pages that are otherwise available, or even performing some actions, but not in their entirety. Today, we are going to examine how this can be done in the most convenient way using NgRx in Angular apps.

What tools do we have?

First of all, we have to keep in mind, that Angular itself already provides some pretty powerful tools hat help us achieve access restriction. Let's first reiterate them:

  1. Guards - special classes that restrict access to pages via routing based on a condition we provide
  2. Interceptors - while they do not come as specifically targeting access restriction, they can be used to modify and even prevent network requests
  3. NgIf directive - yes, this is our main tool when dealing with granular access restriction
  4. NgSwitch directive - main tool when we need to provide different components to the end user based on some role

Aim of this article is not to try and replace these tools with NgRx, but rather make our reactive store interplay with these tools to achieve best UX with as little and concrete code as possible.

What are our use cases?

We are going to show access restriction on three main scenarios:

  1. Authentication with NgRx
  2. Handling permissions
  3. Showing certain features depending on roles/permissions stored in NgRx Store

Let's begin with authorization.

Authorization with NgRx

When dealing with apps that use NgRx, the most important thing to remember is not to duplicate data and keep our interface access to the Store data simple and efficient. For this, we need to understand what sort of scenario we are dealing with. For the purpose of this example, we will implement authorization with following characteristics:

  1. Using a JSON Web Token
  2. Storing the token in cookies
  3. Retrieving the user data using the token for a call to a special API
  4. Checking the auth status of the user
  5. Getting user info (for a profile page or for displaying somewhere)

This is a somewhat specific scenario, but it can be easily extrapolated to other use cases, as in using localStorage instaead of cookies (keep in mind that localStorage is considered insecure), or using an entirely different authorization strategy.

Now let's understand what sort of data we would need to keep in the Store. At first glance, we will probably need to store the following:

  • Current user data
  • a boolean indicating if the user is logged in
  • the auth token itself

Seems alright, doesn't it? Wrong!. We do not need to store anything other than the user data itself, because:

  1. The existence of user data already proves the user is logged in
  2. The token itself contains the user data, just needs to be decoded
  3. The call to API will be made immediately when the app starts

So we will just store the user data in our auth section of the store, and write several selectors that access the logged in state, the token itself, and decoded user data. So our AuthState will look very simple, like this:

export interface AuthState {
  token: string;
  user: User;
}

export const initialState: AuthState = {
  token: "",
  user: null,
};
Enter fullscreen mode Exit fullscreen mode

Now, we need actions to put the token into the state, remove it and so on:

import { createAction, props } from "@ngrx/store";

export const setToken = createAction(
  "[Auth] Set Token",
  props<{ token: string }>()
);

export const setUser = createAction(
  "[Auth] Set user",
  props<{ user: User }>(),
);

export const removeToken = createAction("[Auth] Remove Token");
Enter fullscreen mode Exit fullscreen mode

And then a simple reducer which will handle interactions:

import { createReducer, on } from "@ngrx/store";
import { removeToken, setToken } from "./actions";
import { AuthState, initialState } from "./state";

export const authReducer = createReducer(
  initialState,
  on(setToken, (state, { token }): AuthState => ({ ...state, token })),
  on(removeToken, (state): AuthState => ({ ...state, token: "" })),
  on(setUser, (state, { user }): AuthState => ({ ...state, user }))
);
Enter fullscreen mode Exit fullscreen mode

Now, let's register this in our AppModule:

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";

import { AppComponent } from "./app.component";
import {
  UserDashboardComponent,
} from "./user-dashboard/user-dashboard.component";
import { AppRoutingModule } from "./routing.module";
import { LoginComponent } from "./login/login.component";
import { StoreModule } from "@ngrx/store";
import { authReducer } from "./store/reducer";
import { CommonModule } from "@angular/common";

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    AppRoutingModule,
    CommonModule,
    StoreModule.forRoot({ auth: authReducer }),
  ],
  declarations: [AppComponent, UserDashboardComponent, LoginComponent],
  bootstrap: [AppComponent],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Now we need to write the selectors to pick up the data we want:

import { createFeatureSelector, createSelector } from "@ngrx/store";
import { AuthState } from "./state";
import { decode } from "some-jwt-library";

export const authFeature = createFeatureSelector<AuthState>("auth");

export const selectToken = createSelector(
  authFeature,
  (state) => state.token,
);
export const selectIsAuth = createSelector(
  authFeature,
  (state) => !!state.token
);
export const selectUserData = createSelector(
  authFeature,
  (state) => state.user
);
Enter fullscreen mode Exit fullscreen mode

And that's it for the initial part, we now can store the data about the user and retrieve it safely. Now let's discuss how we get that data into the store. For this, we will use three effects:

  1. Log in: send the login data to backend, get the token, store in cookies and then the Store itself
  2. Log out: remove the token, redirect to the login page
  3. Retrieve token: when the app; starts, check the cookies for a token and store it in the Store

First we add relevant actions:

export const login = createAction(
  "[Auth] Login",
  props<{ email: string; password: string }>()
);

export const loginError = createAction(
  "[Auth] Login",
  props<{ message: string }>()
);

export const logout = createAction("[Auth] Log Out");
Enter fullscreen mode Exit fullscreen mode

And the effects themselves. Pay special attention to the last one:

@Injectable()
export class AuthEffects {
  // on login, send auth data to backend,
  // get the token and put into the store and cookies
  login$ = createEffect(() => {
    return this.actions$.pipe(
      ofType(login),
      mergeMap(({ email, password }) => {
        return this.authService.login(email, password).pipe(
          tap(({ token }) => this.cookieService.set("token", token)),
          map(({ token }) => setToken({ token })),
          catchError(() => of(loginError({ message: "Login failed" })))
        );
      })
    );
  });

  // on logout, just remove the token
  // and navigate to login page
  // no need to dispatch any actions after that
  logout$ = createEffect(
    () => {
      return this.actions$.pipe(
        ofType(logout),
        tap(() => {
          this.cookieService.remove("token");
          this.router.navigateByUrl("/login");
        })
      );
    },
    { dispatch: false }
  );

  // when app has started, get the user data
  // using the token from cookies
  // and put into the store
  init$ = createEffect(() => {
    return this.actions$.pipe(
      ofType(ROOT_EFFECTS_INIT),
      mergeMap(({ email, password }) => {
        return this.authService.getCurrentUser().pipe(
          map(({ token }) => setUser({ user })),
          catchError(() => of(setUserError({ message: "Error" })))
        );
      })
    );
  });

  constructor(
    private readonly actions$: Actions,
    private readonly authService: AuthService,
    private readonly router: Router,
    private readonly cookieService: CookieService
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

First two effects are fairly straightforward, so let's focus on the last one. Here, we have taken advantage of the built-in ROOT_EFFECTS_INIT action which gets dispatched by NgRx itself when it subscribes to our effects - essentially acting as a notifier for our app start in this scenario. Then we put the user data into our state, essentially rehydrating it.

So now we have our auth in NgRx ready, the final addition will be creating a special guard that will check the existence of our auth token:

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly store: Store,
    private readonly router: Router,
  ) {}

  canActivate(
    next: ActivatedRouteSnapshot,
    state: RouterStateSnapshot,
  ) {
    return this.store.select(selectIsAuth).pipe(
      map((isAuth) => {
        return isAuth ? true : this.router.parseUrl("/login");
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Essentially, all the logic behind autgorization has been delegated to the NgRx Store, and the guard just asks the Store whether to proceed or not.

This is mostly it. Now, let's discuss permission based access restrictions.

Handling permissions with NgRx

Different applications have different approaches to permissions, different ways to store them and validate their existence. The cool thing about NgRx is that we do not care what is the shape of the permissions - we can always write selectors that provide a similar interface for checking permissions and granting access regardless of what the permissions look like.

For the sake of simplicity, for this example we will imagine that the permissions in our app are stored in the JWT token together with the user data in an array of strings - if the name of the permission in present then the user has that permission, otherwise not. So, our user object might look something like this:

interface UserData {
  firstName: string;
  lastName: string;
  permissions: string[];
}
Enter fullscreen mode Exit fullscreen mode

Now, let's add the relevant selectors:

export const selectPermissions = createSelector(
  selectUserData,
  (userData) => userData?.permissions ?? []
);
Enter fullscreen mode Exit fullscreen mode

Now this will return the array that we have, but in certain cases we need to check the existence of a particular permission. For this, we can write a selector with an argument:

export const selectHasPermission = (permission: string) =>
  createSelector(selectPermissions, (permissions) =>
    permissions.includes(permission)
  );
Enter fullscreen mode Exit fullscreen mode

Then in our components we can use this selector to check whether the user has the permission to perform a certain action:

export class UserDashboardComponent implements OnInit {
  canCreateOrder$ = this.store.select(
    selectHasPermission("CreateOrder"),
  );
  constructor(private readonly store: Store) {}
}
Enter fullscreen mode Exit fullscreen mode

And then in the template:

<button *ngIf="canCreateOrder$ | async" (click)="createOrder()">
  Create Order
</button>
Enter fullscreen mode Exit fullscreen mode

Starting from Angular 14, using the inject function, we can create a customizable functional guard for permission checkings:

export function hasPermissionGuard(permission: string) {
  return function () {
    const store = inject(Store);
    return store.select(selectHasPermission(permission));
  };
}
Enter fullscreen mode Exit fullscreen mode

And then we can use it in our routes:

const routes: Routes = [
  {
    path: "orders",
    component: OrdersComponent,
    canActivate: [hasPermissionGuard("ViewOrders")],
  },
  {
    path: "orders/create",
    component: CreateOrderComponent,
    canActivate: [hasPermissionGuard("CreateOrder")],
  },
];
Enter fullscreen mode Exit fullscreen mode

Note: we can customize the guard to accept multiple permissions and check whether the user has all of them, and also add another argument to handle redirects if needed.

Bonus point: finally, we visit the section where we provide granular access to certain parts of the UI:

Handling granular access to UI elements

For this, can create a directive that will hide the element if the user does not have the permission. This directive will:

  1. Be a structural directive - *appHasPermission="permission"
  2. Take a string as an input - the name of the permission
  3. Use the Store to access permissions
  4. Use the selectHasPermission selector to check whether the user has the permission
  5. Hide the element if the user does not have the permission
  6. Show the element if the user has the permission
  7. Unsubscribe from the store when the element is destroyed

Here we go:

@Directive({
  selector: "[appHasPermission]",
})
export class HasPermissionDirective implements OnInit, OnDestroy {
  @Input("appHasPermission") permission: string;
  destroy$ = new Subject<void>();

  constructor(
    private readonly templateRef: TemplateRef<any>,
    private readonly viewContainer: ViewContainerRef,
    private readonly store: Store
  ) {}

  ngOnInit() {
    this.store
      .select(selectHasPermission(this.permission))
      .pipe(takeUntil(this.destroy$))
      .subscribe((hasPermission) => {
        if (hasPermission) {
          this.viewContainer.createEmbeddedView(this.templateRef);
        } else {
          this.viewContainer.clear();
        }
      });
  }

  ngOnDestroy() {
    this.destroy$.next();
  }
}
Enter fullscreen mode Exit fullscreen mode

And then in the template:

<a *appHasPermission="'CreateOrder'" routerLink="/orders/create">
  Create Order
</a>
Enter fullscreen mode Exit fullscreen mode

Conclusion

Access restriction has a very improtant role in multiple applications, especially enterprise apps. NgRx is a great tool for handling authorization and permissions, and it can be used in a variety of ways. In this article, we have covered the most common use cases, but there are many more, so never hesitate to explore new ways of doing things with NgRx.

Top comments (6)

Collapse
 
rick-wick profile image
Rick Wick

Thank you for the insightful article; I found the concept very interesting. I have a couple of questions regarding this approach:

  1. Does this method violate SOLID principles?
    My understanding is that state management is intended for handling the application's state, not for directly managing access permissions. For example, I believe it would be better to create a centralized service dedicated to access control. This service would determine whether to obtain permissions from the store, a BehaviorSubject (which also allows synchronous data retrieval), or other sources.

  2. Will integrating permission checks directly into the state manager complicate component testing?

Collapse
 
deinding profile image
Floyd Haremsa

Great article and examples. One thing though: I do think that you should store a boolean indicating if the user is logged in, because even if the token is present (e.g. rehydrated from cookies or localStorage), it might already be expired which prevents the user from conducting authenticated actions, thus not logged in.

Collapse
 
armandotrue profile image
Armen Vardanyan

@deinding it differs, you can write or import a function that validated the token expiration and use it on the selector, or maybe not store the token and only rely on HttpOnly cookies.

Anyway, thanks for appreciation :)

Collapse
 
levan_dolidze_0ff0261f512 profile image
levan dolidze

really cool usecases as always

Collapse
 
tonegrail profile image
Tone

Can you make an example with a functional route guard using an ngrx store?

Collapse
 
josephkuala profile image
josephkuala

Great.
Where can i found the full source code for this tuto ? Please