Angular Challenges #6, Part 2: Route Guards and Permissions
This sixth challenge focuses on permission management inside an Angular app. Most real-world applications need to distinguish between user roles such as admins, managers, or regular users. In this installment, we explore how route guards can be used to control which pages are accessible based on the logged-in user's permissions.
If you haven't attempted the challenge yet, head over to the Angular Challenges repository, give it a try, and then return here to compare your approach with the one presented below. (You can also submit a PR for review.)
The starting point for this challenge is an app with a set of buttons allowing you to log in as different users, each carrying a distinct set of permissions. There's also a button to navigate into the application. Depending on the active user's permissions, the appropriate dashboard will be displayed.
Angular ships with several built-in route guards: canLoad, canActivate, canDeactivate, canActivateChild, and canMatch.
For a deeper dive into each guard and how to implement it, check out this article.
Everything you need to know about route Guard in Angular
thomas for Playful Programming Angular ・ Jan 18 '23
For this challenge, the canMatch guard is the right fit. It lets you define the same route path multiple times. When a user navigates to a URL, the router checks the first matching route definition. If its canMatch guard returns true, that route is used. If it returns false, the router moves on to the next matching entry. Consider this simple illustration:
{
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'),
},
In the snippet above, navigating to enter first hits the initial route. Since its canMatch guard resolves to false, the router evaluates the second route definition. With its guard returning true, the router proceeds to the ClientComponent and skips any remaining route definitions.
Now let's apply this concept to the challenge. The first step is to build an injectable service that contains the guard logic for permission handling. Here's 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'));
}
})
);
}
}
Inside the guard, we read the roles and isAdmin values from the route's data property.
First, it checks whether a user is currently logged in. If not, the guard redirects to the no-user page. If a user exists, the guard compares the user's roles against those specified on the route to decide whether the router should match this route or proceed to the next one.
Once the guard service is ready, we can define the routes like this:
{
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'),
},
Since Angular v.14.2, guards can be written as plain functions. Additionally, the class-based guard is slated for deprecation in version 15.2.
This leads to a function-based approach. Using the inject function, we can pull in our service through Angular's dependency injection system. (Keep in mind: inject must be called within an injection 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'));
}
})
);
};
Because guards are now functions, we can split the logic into smaller, dedicated 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'))
)
)
);
};
This separation makes the code clearer, easier to maintain, and less error-prone. Need both conditions on a single route? No problem — a guard accepts an array of functions.
We can now update the route definitions accordingly.
{
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'),
},
- Improved Developer Experience
- Reduced chance of errors (No longer relying on the
datafield with typeany) - Less boilerplate
- Cleaner to read
- Simpler to maintain
One more enhancement
Calling inject directly inside a function could throw an error if that function is invoked outside of an injection context. To guard against this, we can make the injected dependencies 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'))
)
)
);
};
Note: This also comes with another benefit: the guard function can now be tested directly without setting up TestBed.
That wraps up this challenge. We hope you enjoyed it and picked up a few new tricks along the way.
If you missed the first part of this challenge — managing permissions with structural directives — follow this link.
Create a custom Structural Directive to manage permissions
thomas for Playful Programming Angular ・ Jan 2 '23
👉 More challenges are available at Angular challenges. Give them a shot — I'll be glad to review your submissions!
Follow me on Twitter or Github for updates on upcoming challenges. Feel free to reach out if you have any questions.

