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.
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.
Everything you need to know about route Guard in Angular
thomas for This is Angular ・ Jan 18 '23
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'),
},
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'));
}
})
);
}
}
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'),
},
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'));
}
})
);
};
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'))
)
)
);
};
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'),
},
- Better Developer eXperience
- Less prone to errors (No need for the
data
property of typeany
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'))
)
)
);
};
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.
Create a custom Structural Directive to manage permissions
thomas for This is Angular ・ Jan 2 '23
👉 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)
Awesome!
Awesome !!! Especially the options inject params