DEV Community

Cover image for Create a custom Structural Directive to manage permissions
thomas for This is Angular

Posted on • Updated on • Originally published at Medium

Create a custom Structural Directive to manage permissions

Welcome to Angular challenges #6.

The aim of this series of Angular challenges is to increase your skills by practicing on real life exemples. Moreover you can submit your work though a PR which I or other can review; as you will do on real work project or if you want to contribute to Open Source Software.

The sixth challenge is about managing permissions within an application. Many applications have to handle permissions for different types of users, such as admins, managers… In this challenge, we will need to display or hide certain information and restrict certain route navigation based on a user's permissions. This challenge can be divided into two parts:

  • The first part will introduce us to various ways to write a structural directive that modify our HTL to show or hide DOM elements. 
  • The second part will focus on protecting route navigation: guard. (which will be cover in a following article)

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 with different users who have different permissions. We also have a list of sentences that we need to display or hide based on the user we logged in with.

startup screen

User interface is defined as follow:

export type Role = 'MANAGER' | 'WRITER' | 'READER' | 'CLIENT';

export interface User {
  name: string;
  isAdmin: boolean;
  roles: Role[];
}
Enter fullscreen mode Exit fullscreen mode

The naive way to solve this challenge would be to use ngIf on each row:

<div *ngIf="user.isAdmin">visible only for super admin</div>
<div *ngIf="!user.isAdmin && user.roles.includes('MANAGER')">
  visible if manager
</div>
<div *ngIf="/*Get's complicated*/">visible if manager and/or reader</div>
//...
Enter fullscreen mode Exit fullscreen mode

As we can see, we have to create a complicated condition and this solution is not reusable. It can only get messier and less maintainable as the project grows.

Structural Directives

Angular offers a solution called structural directives to modify the structure of the DOM based on specific conditions. Structural directives allow us to add or remove elements from the DOM. In the previous solution, we used the built-in structural directive ngIf, which allows us to add or remove elements based on a condition.

Other commonly used built-in structural directives are NgFor , NgSwitch , NgIf

To make our solution more reusable and maintainable, we can create a custom structural directive called hasRole. This will allow us to simplify our template like this:

<div *hasRoleIsAdmin="true">visible only for super admin</div>
<div *hasRole="'MANAGER'">visible if manager</div>
<div *hasRole="['MANAGER', 'READER']">visible if manager and/or reader</div>
<div *hasRole="['MANAGER', 'WRITER']">visible if manager and/or writer</div>
<div *hasRole="'CLIENT'">visible if client</div>
<div>visible for everyone</div>
Enter fullscreen mode Exit fullscreen mode

👉🏼 Cleaner, Reusable, Maintainable, More readable. 👈🏼


In the following sections, we will see several implementations of the same directive.

Solution #1:

 

@Directive({
  selector: '[hasRole], [hasRoleIsAdmin]',
  standalone: true,
  providers: [provideDestroyService()],
})
export class HasRoleDirective implements OnInit {
  private destroy$ = injectDestroyService();

  private templateRef = inject(TemplateRef<unknown>);
  private viewContainer = inject(ViewContainerRef);

  private store = inject(UserStore);

  @Input('hasRole') role: Role | Role[] | undefined = undefined;

  @Input('hasRoleIsAdmin') isAdmin = false;

  ngOnInit(): void {
    if (this.isAdmin) {
      this.store.isAdmin$
        .pipe(takeUntil(this.destroy$))
        .subscribe((isAdmin) =>
          isAdmin ? this.addTemplate() : this.clearTemplate()
        );
    } else if (this.role) {
      this.store
        .hasAnyRole(this.role)
        .pipe(takeUntil(this.destroy$))
        .subscribe((hasPermission) =>
          hasPermission ? this.addTemplate() : this.clearTemplate()
        );
    } else {
      this.addTemplate();
    }
  }

  private addTemplate() {
    this.viewContainer.clear();
    this.viewContainer.createEmbeddedView(this.templateRef);
  }

  private clearTemplate() {
    this.viewContainer.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode
  • TemplateRef and ViewContainerRef need to be injected in order to access our DOM elements and manipulate them. TemplateRef allows us to access the embedded template: <ng-template> while ViewContainerRef accesses the view in which our directive has been called. 

When we write <div *ngIf="...">TOTO</div> , the compiler translates it to <ng-template [ngIf]="..."><div>TOTO</div><ng-template> 

  • We define two inputs hasRole and hasRoleIsAdmin for all our use cases.

To concatenate inputs inside our directive, we need to prefix our input with our directive selector, this way we will be able to write : <div*hasRole="'...';isAdmin:true"></div>

  • All of the logic is created inside our ngOnInit hook and we display or hide the embedded template depending on the result of our condition. 
  • To avoid memory leaks, we inject a DestroyService and use the takeUntil operator to unsubscribe to all of our subscriptions when component is destroyed. To connect our service with the component, we include it in the component provider array with provideDestroyService()

Issue: In our ngOnInit hook, we have repeated the same logic twice. We can do improve this by avoiding the repetition.

Solution #2: BehaviorSubject

@Directive({
  selector: '[hasRole], [hasRoleIsAdmin]',
  standalone: true,
  providers: [provideDestroyService()],
})
export class HasRoleDirective implements OnInit {
  private destroy$ = injectDestroyService();

  private templateRef = inject(TemplateRef<unknown>);
  private viewContainer = inject(ViewContainerRef);

  private store = inject(UserStore);

  private show = new BehaviorSubject<Observable<boolean | undefined>>(
    of(undefined)
  );

  @Input('hasRole') set role(role: Role | Role[] | undefined) {
    if (role) {
      this.show.next(this.store.hasAnyRole(role));
    }
  }

  @Input('hasRoleIsAdmin') set isAdmin(isAdmin: boolean) {
    if (isAdmin) {
      this.show.next(this.store.isAdmin$);
    }
  }

  ngOnInit(): void {
    this.show
      .pipe(
        mergeMap((s) => s),
        takeUntil(this.destroy$)
      )
      .subscribe((showTemplate) =>
        showTemplate ? this.addTemplate() : this.clearTemplate()
      );
  }

  private addTemplate() {
    this.viewContainer.clear();
    this.viewContainer.createEmbeddedView(this.templateRef);
  }

  private clearTemplate() {
    this.viewContainer.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode
  • The logic remains the same, but to combine both subscriptions into one, we use a BehaviourSubject to merge our input into a single stream. Now, we only need to subscribe to one observable and we avoid repeat ing ourselves. 
  • To feed our BehaviourSubject we use setters on our inputs to work on the incoming values. 

Setters are more readable than using ngOnChange. However you must use ngOnChange if your changes involve two or more inputs.

Issue: We still need to manually subscribe to our stream. Manual subscription can be error-prone.
 

Solution #3: Ngrx/Component-store

@Directive({
  selector: '[hasRole], [hasRoleIsAdmin]',
  standalone: true,
  providers: [ComponentStore],
})
export class HasRoleDirective {
  private templateRef = inject(TemplateRef<unknown>);
  private viewContainer = inject(ViewContainerRef);

  private componentStore = inject(ComponentStore);
  private store = inject(UserStore);

  @Input('hasRole') set role(role: Role | Role[] | undefined) {
    if (role) {
      this.showTemplate(this.store.hasAnyRole(role));
    }
  }

  @Input('hasRoleIsAdmin') set isAdmin(isAdmin: boolean) {
    if (isAdmin) {
      this.showTemplate(this.store.isAdmin$);
    }
  }

  private readonly showTemplate = this.componentStore.effect<
    boolean | undefined
  >(
    pipe(
      tap((showTemplate) =>
        showTemplate ? this.addTemplate() : this.clearTemplate()
      )
    )
  );

  private addTemplate() {
    this.viewContainer.clear();
    this.viewContainer.createEmbeddedView(this.templateRef);
  }

  private clearTemplate() {
    this.viewContainer.clear();
  }
}
Enter fullscreen mode Exit fullscreen mode

Using the Ngrx/component-store effect allows us to further reduce the complexity. The nice design of the CS effect is that it can take either a strict value or an Observable as input and handle it in the same way.

But we can further simplify our directive. Since Angular v15, we can take advantage of the hostDirective and reuse code from other directives.

Solution #4: hostDirectives

@Directive({
  selector: '[hasRole], [hasRoleIsAdmin]',
  standalone: true,
  hostDirectives: [NgIf], // 👈🏼 the beauty of Angular v15 is located here
  providers: [ComponentStore],
})
export class HasRoleDirective {
  private store = inject(UserStore);
  private componentStore = inject(ComponentStore);
  private ngIf = inject(NgIf, { host: true });

  @Input('hasRole') set role(role: Role | Role[] | undefined) {
    if (role) {
      this.showTemplate(this.store.hasAnyRole(role));
    }
  }

  @Input('hasRoleIsAdmin') set isAdmin(isAdmin: boolean) {
    if (isAdmin) {
      this.showTemplate(this.store.isAdmin$);
    }
  }

  private readonly showTemplate = this.componentStore.effect<boolean | undefined>(
    pipe(
      tap((showTemplate) => this.ngIf.ngIf = showTemplate) // 🥰
  ));
}
Enter fullscreen mode Exit fullscreen mode
  • We can now add directives to our host element. In this example, we tied the implementation of ngIf with our custom directive by declaring ngIf inside hostDirectives . We inject ngIf inside our directive to access the internal class properties of ngIf

Don't forget to add the host meta-property on the inject function to make sure you get the ngIf directive declared on your host element and not one located higher on your component tree.

  • In our CS effect showTemplate, we set the ngIf class property of the ngIf directive and the built-in NgIf directive will handle all of the DOM logic for us. This is convenient, handy, and DRY which is less error-prone. 

Last but not least, don't forget about a really nice library called RxAngular. (If you don't know it, you should take a look)

Solution #5: RxAngular

@Directive({
  selector: '[hasRole], [hasRoleIsAdmin]',
  standalone: true,
  hostDirectives: [NgIf],
  providers: [RxEffects],
})
export class HasRoleDirective {
  private store = inject(UserStore);
  private rxEffect = inject(RxEffects);
  private ngIf = inject(NgIf, { host: true });

  private show = new Subject<Observable<boolean | undefined>>();
  private show$ = this.show.asObservable().pipe(mergeMap((b) => b));

  @Input('hasRole') set role(role: Role | Role[] | undefined) {
    if (role) {
      this.show.next(this.store.hasAnyRole(role));
    }
  }

  @Input('hasRoleIsAdmin') set isAdmin(isAdmin: boolean) {
    if (isAdmin) {
      this.show.next(this.store.isAdmin$);
    }
  }

  constructor() {
    this.rxEffect.register(this.show$, this.showTemplate);
  }

  private showTemplate = (showTemplate: boolean | undefined) =>
    (this.ngIf.ngIf = showTemplate);
}
Enter fullscreen mode Exit fullscreen mode

You can find the final code in the form of a Pull Request here. (If you want to get it up and running, you can clone the project, check out the solution branch git checkout solution-permissions and run nx serve permissions)


I hope you enjoyed this sixth challenge and learned from it.

If you want to read the second part about protecting your routes with guards, follow this link.

If you found this article useful, please consider supporting my work by giving it some likes ❤️❤️ to help it reach a wider audience. Don't forget to share it with your teammates who might also find it useful. Your support would be greatly appreciated.

👉 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 (5)

Collapse
 
davdev82 profile image
Dhaval Vaghani • Edited

First of all, really good article. One thing I want to check is the use of host = true when injecting the NgIf from the host element. As I understand, host will look up for the token till it reaches the host component that declares the template where the directive is used. For such a use case, wouldn't self = true be more appropriate ?

Collapse
 
achtlos profile image
thomas

Self and host will work exactly the same since the directive is applied with the hostDirective attribute. We know for sure that a ngIf is on the host tag. But you can use the self flag to ne more precise, indeed. 👍

Collapse
 
andrejs_sinkevics profile image
Andrejs Sinkevics • Edited

Thank you!
I have question about manage permissions. What we would have doing, when we have context permission.
Example: Permissions for current page or projects, when we have different permissions each page or project.

P.S. Sorry for my English

Collapse
 
achtlos profile image
thomas

I'm really sorry but I didn't understand the issue. Your permissions are different depending on the context of the page ?

You can still use a structural directive if it's to show or hide element on the page. But you will need to write your own. I can help you if you can create a reproductible example on stackblitz. And if it's to block user to enter some pages, you can use guard (I will write another article on that subject)

If It's something else, you need to explain it a bit more. You can DM me on twitter if you want.

Collapse
 
rgunczer profile image
Robert Gunczer

since when an if statement become "complicated condition" ?
both *ngIf and the new @if works pretty well.
stick with "naive"