DEV Community

Cover image for Create a route Guard to manage permissions
thomas for This is Angular

Posted on • Updated on • Originally published at Medium

Create a route Guard to manage permissions

Welcome to Angular challenges #6 : part 2

The sixth challenge is about managing permissions within an application. Many applications have to handle different types of user permissions such as admins, managers… In this challenge, we will learn how to use a guard in Angular to restrict access to certain pages based on a user's permissions.

If you haven't done the challenge yet, I invite you to try it first by going to Angular Challenges and coming back afterward to compare your solution with mine. (You can also submit a PR that I'll review)


We begin the challenge with the following application. We have a list of buttons to log in as different users, each with their own set of permissions. We also have a button to enter the application. Depending on the user's permissions, we will display the appropriate dashboard.

dashboard

Angular provides a list of built-in guards to protect our routes: canLoad, canActivate , canDeactivate , canActivateChild and canMatch .

If you want to learn more about each guard and how to implement them, I invite you to read this article.

For the purpose of this challenge, we will use the canMatch guard which fits our use case perfectly.

We can write the same route multiple times. If the first route in the file is matched, the router will navigate to this route. Otherwise it will check the following one. Let's see how canMatch works with a very simpler example:

  {
    path: 'enter',
    canMatch: [() => false],
    loadComponent: () => import('./dashboard/writer-reader.component'),
  },
  {
    path: 'enter',
    canMatch: [() => true],
    loadComponent: () => import('./dashboard/client.component'),
  },
  {
    path: 'enter',
    loadComponent: () => import('./dashboard/everyone.component'),
  },
Enter fullscreen mode Exit fullscreen mode

In this example, if the user navigates to enter, the router will try to match the first route. However the canMatch guard returns false., so the router will try the second route which returns true. Finally the router will navigate to the ClientComponent and will not execute of follow up routes.


Now, let's apply our knowledge to our needs. First we need to create an injectable service to implement our guard logic for managing user permissions.
Here is the implementation:

@Injectable({ providedIn: 'root' })
export class HasPermissionGuard implements CanMatch {
  private router = inject(Router);
  private userStore = inject(UserStore);

  canMatch(route: Route): Observable<boolean | UrlTree> {
    const accessRolesList: Role[] = route.data?.['roles'] ?? [];
    const isAdmin: boolean = route.data?.['isAdmin'] ?? false;
    return this.hasPermission$(isAdmin, accessRolesList);
  }

  private hasPermission$(isAdmin: boolean, accessRolesList: Role[]) {
    return this.userStore.isUserLoggedIn$.pipe(
      mergeMap((hasUser) => {
        if (hasUser) {
          if (isAdmin) {
            return this.userStore.isAdmin$.pipe(map(Boolean));
          } else if (accessRolesList.length > 0) {
            return this.userStore
              .hasAnyRole(accessRolesList)
              .pipe(map(Boolean));
          }
          return of(false);
        } else {
          return of(this.router.parseUrl('no-user'));
        }
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

We retrieve the roles and isAdmin properties from the route's data attribute.

Then we first check if a user has been logged in. If not, we navigate to the no-user page.

If a user has been logged in, we compare the user's roles with the ones set on the route to determine whether the router should navigate to this route or check the next one.

Once our guard as a service is complete, we can create our routes as follow:

  {
    path: 'enter',
    canMatch: [HasPermissionGuard],
    data: {
      isAdmin: true,
    },
    loadComponent: () => import('./dashboard/admin.component'),
  },
  {
    path: 'enter',
    canMatch: [HasPermissionGuard],
    data: {
      roles: ['MANAGER'],
    },
    loadComponent: () => import('./dashboard/manager.component'),
  },
  {
    path: 'enter',
    canMatch: [HasPermissionGuard],
    data: {
      roles: ['WRITER', 'READER'],
    },
    loadComponent: () => import('./dashboard/writer-reader.component'),
  },
  {
    path: 'enter',
    canMatch: [HasPermissionGuard],
    data: {
      roles: ['CLIENT'],
    },
    loadComponent: () => import('./dashboard/client.component'),
  },
  {
    path: 'enter',
    loadComponent: () => import('./dashboard/everyone.component'),
  },
Enter fullscreen mode Exit fullscreen mode

Since Angular v.14.2, we can use functional programming. Additionally, the guard as a service will be deprecated in version 15.2.

This gives us the following function. To inject our service using the Dependency Injection system of Angular, we can now take advantage of the inject function. (Note: The inject function only works inside a dependency context)

export const hasAdminPermission = (isAdmin: boolean, accessRolesList: Role[]) => {
  const userStore = inject(UserStore);
  const router = inject(Router);
  return userStore.isUserLoggedIn$.pipe(
    mergeMap((hasUser) => {
      if (hasUser) {
        if (isAdmin) {
          return userStore.isAdmin$.pipe(map(Boolean));
        } else if (accessRolesList.length > 0) {
          return userStore.hasAnyRole(accessRolesList).pipe(map(Boolean));
        }
        return of(false);
      } else {
        return of(router.parseUrl('no-user'));
      }
    })
  );
};
Enter fullscreen mode Exit fullscreen mode

Since we are using function, we can split our logic into two separate functions.

export const isAdmin = () => {
  const userStore = inject(UserStore);
  const router = inject(Router);
  return userStore.isUserLoggedIn$.pipe(
    mergeMap((hasUser) =>
      iif(
        () => hasUser,
        userStore.isAdmin$.pipe(map(Boolean)),
        of(router.parseUrl('no-user'))
      )
    )
  );
};

export const hasRole = (accessRolesList: Role[]) => {
  const userStore = inject(UserStore);
  const router = inject(Router);
  return userStore.isUserLoggedIn$.pipe(
    mergeMap((hasUser) =>
      iif(
        () => hasUser,
        userStore.hasAnyRole(accessRolesList).pipe(map(Boolean)),
        of(router.parseUrl('no-user'))
      )
    )
  );
};
Enter fullscreen mode Exit fullscreen mode

This way, the code is easier to understand, easier to maintain and less prone to errors. If we want to use both functions to protect one route, we can, since a guard takes an array as input.
We can now refactor our route definition

  {
    path: 'enter',
    canMatch: [() => isAdmin()],
    loadComponent: () => import('./dashboard/admin.component'),
  },
  {
    path: 'enter',
    canMatch: [() => hasRole(['MANAGER'])],
    loadComponent: () => import('./dashboard/manager.component'),
  },
  {
    path: 'enter',
    canMatch: [() => hasRole(['WRITER', 'READER'])],
    loadComponent: () => import('./dashboard/writer-reader.component'),
  },
  {
    path: 'enter',
    canMatch: [() => hasRole(['CLIENT'])],
    loadComponent: () => import('./dashboard/client.component'),
  },
  {
    path: 'enter',
    loadComponent: () => import('./dashboard/everyone.component'),
  },
Enter fullscreen mode Exit fullscreen mode
  • Better Developer eXperience
  • Less prone to errors (No need for the data property of type any anymore)
  • Less boilerplate code
  • Easier to read
  • More maintainable

One more improvement
Using the inject function inside a function can lead to errors if someone wants to reuse the function outside a dependency context. To avoid this, we can set the two injected dependencies as optional parameters:

export const hasRole = (accessRolesList: Role[], userStore = inject(UserStore), router = inject(Router)) => {
  return userStore.isUserLoggedIn$.pipe(
    mergeMap((hasUser) =>
      iif(
        () => hasUser,
        userStore.hasAnyRole(accessRolesList).pipe(map(Boolean)),
        of(router.parseUrl('no-user'))
      )
    )
  );
};
Enter fullscreen mode Exit fullscreen mode

Note: There is one more advantage by doing so: you can now easily test your function without the need of TestBed 


That's it for this article. I hope you enjoyed this sixth challenge and learned new techniques from it.

If you want to read the first part of this challenge about managing permissions with structural directives, follow this link.

👉 Other challenges are waiting for you at Angular challenges. Come and try them. I'll be happy to review you!

Follow me on Twitter or Github to read more about upcoming Challenges! Don't hesitate to ping me if you have more questions.

Top comments (2)

Collapse
 
knightsarai profile image
knightsarai

Awesome!

Collapse
 
davdev82 profile image
Dhaval Vaghani

Awesome !!! Especially the options inject params