Pressing the big red button - Authorisation handling with Angular

tapaibalazs

Tápai Balázs

Posted on September 29, 2020

Pressing the big red button - Authorisation handling with Angular

Although you should never leave authorisation handling to be dealt with only by the Front-End, customers usually require us to hide or disable UI elements based on roles and/or permissions. This makes up for better user experience and can make the developer's life a little monotone.

If you'd like to jump right into the code, you can check out my ng-reusables git repository. I hope you have fun!

Let's just use dependency injection

I have had the chance to work with several enterprise application Front-Ends and when it came to authorisation, usually a role-based approach got implemented. The user's roles were either provided in the JWT, which was then stored in localStorage, or sent back in the login response, and stored in indexedDb. For this blog post, it is not important how the user roles get to the Front-End, but let's state that there is an AuthorisationService, which handles this upon application startup.

@Injectable({ providedIn: "root" })
export class AuthorisationService {
  private USER_ROLES: Set<string> = new Set()

  // ...

  setRoles(roles: string[]): void {
    this.USER_ROLES = new Set(roles)
  }

  hasReadAccess(role: string): boolean {
    return this.USER_ROLES.has(`${role}_READ`)
  }

  hasWriteAccess(role: string): boolean {
    return this.USER_ROLES.has(`${role}_WRITE`)
  }
}
Enter fullscreen mode Exit fullscreen mode

We intentionally store the roles in a Set, because, as opposed to an array, it is more performant to check if the user has a given access right or not.
In this particular case, the application differentiates between read and write access. Read access displays the UI element, write access allows the user to interact with it. Usually, one feature has one role, let's have a feature for pressing the big red button. This feature would have two roles for the user: BIG_RED_BUTTON_READ and BIG_RED_BUTTON_WRITE. Let's create a component for this feature.

<!-- big-red-button.component.html -->
<section *ngIf=authorisationService.hasReadAccess('BIG_RED_BUTTON')
         class="big-red-button-container">
  <button [disabled]="!authorisationService.hasWriteAccess('BIG_RED_BUTTON') || isButtonDisabled()"
          class="big-red-button">
    DO NOT PRESS
  </button>
</section>
Enter fullscreen mode Exit fullscreen mode
@Component({
  selector: `big-red-button`,
  templateUrl: "./big-red-button.component.html",
  styles: [
    `
      /* styles */
    `,
  ],
})
export class BigRedButtonComponent {
  constructor(public authorisationService: AuthorisationService) {}

  isButtonDisabled(): boolean {
    let isButtonDisabled = false
    // Imagine complex boolean logic here.
    return isButtonDisabled
  }
}
Enter fullscreen mode Exit fullscreen mode

Scaling problems

This approach works perfectly for such a small component, and let's be fair if our whole application is one big red button we can call it a day.
However, this method gets rather tedious and tiresome for a larger application. This approach is not scalable, because you have to inject the service into each and every one of your components. That means stubbing it in every component unit test, setting it up with mock data, and mocking the user rights as well. This also goes against the DRY (Don't Repeat Yourself) principle. How can we move the necessary logic into our component templates? The answer lies in structural directives.

@Directive({ selector: "[authorisation]" })
export class AuthorisationDirective implements OnDestroy {
  private userSubscription: Subscription
  private role: string

  constructor(
    private userService: UserService,
    private authorisationService: AuthorisationService
  ) {
    this.userSubscription = this.userService.currentUser$.subscribe(
      this.updateView.bind(this)
    )
  }

  @Input()
  set authorisation(role: string) {
    this.role = role
    this.updateView()
  }

  ngOnDestroy(): void {
    this.userSubscription?.unsubscribe()
  }

  updateView(): void {
    // TODO view update logic based on access rights.
  }
}
Enter fullscreen mode Exit fullscreen mode

This is our starting directive, which we are going to expand upon. I inject two services, the UserService handles the user data. When the current user changes, we need to update our views, that is why we subscribe to the user changes. Whenever a change occurs, every active directive instance
will update their view. We implement the OnDestroy lifecycle hook because directives use those as well. We handle the teardown logic inside it.
The authorisation setter gets decorated with the @Input decorator. This way we can use this structural directive on any HTML element in our templates as the following: <div *authorisation="BIG_RED_BUTTON"></div>.

With this setup, we can start implementing the view handling logic. We are going to need two important Angular template handler tools, the ViewContainerRef and the TemplateRef. Let's inject these to our constructor and implement the display/hide logic for read access rights and provide a solution for disabling UI elements when the user does not have write access right.

interface AuthorisationContext {
  $implicit: (b: boolean) => boolean
}

@Directive({ selector: "[authorisation]" })
export class AuthorisationDirective implements OnDestroy {
  // ...
  private viewRef: EmbeddedViewRef<AuthorisationContext> = null

  constructor(
    private userService: UserService,
    private authorisationService: AuthorisationService,
    @Inject(ViewContainerRef) private viewContainer: ViewContainerRef,
    @Inject(TemplateRef) private templateRef: TemplateRef<AuthorisationContext>
  ) {
    //..
  }

  // ..

  private updateView(): void {
    const hasReadRight = this.authService.hasReadAccess(this.role)
    if (hasReadRight) {
      const hasWriteRight = this.authService.hasWriteAccess(this.role)
      this.viewContainer.clear()
      this.viewRef = this.viewContainer.createEmbeddedView(
        this.templateRef,
        this.createContext(hasWriteRight)
      )
    } else {
      this.viewContainer.clear()
      this.viewRef = null
    }
  }

  private createContext(hasWriteRight: boolean): AuthorisationContext {
    return {
      $implicit: (booleanValue: boolean) => !hasWriteRight || booleanValue,
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

First, we declare the AuthorisationContext interface. It has an $implicit property, which comes in handy when we want to use it as a template variable. We also prepare the viewRef member property, which stores our EmbeddedViewRef or null if the user does not have read access.
Then, we call the clear() method on our ViewContainerRef instance. When the user has read access, we call clear() again. This comes in handy when the authorisation setter gets called with a different role for which we need to update the previous view. After that, we create our EmbeddedViewRef using the template reference that we inject into our constructor, and we create our context. Now let's update our component, so it uses our directive.

<!-- big-red-button.component.html -->
<section
  *authorisation="'BIG_RED_BUTTON'; let checkWriteAccess"
  class="big-red-button-container"
>
  <button
    [disabled]="checkWriteAccess(isButtonDisabled())"
    class="big-red-button"
  >
    DO NOT PRESS
  </button>
</section>
Enter fullscreen mode Exit fullscreen mode
@Component({
  selector: `big-red-button`,
  templateUrl: "./big-red-button.component.html",
  styles: [
    `
      /* styles */
    `,
  ],
})
export class BigRedButtonComponent {
  constructor() {}

  isButtonDisabled(): boolean {
    let isButtonDisabled = false
    // IMAGINE COMPLEX BOOLEAN LOGIC HERE
    return isButtonDisabled
  }
}
Enter fullscreen mode Exit fullscreen mode

Our directive deals with the DOM, it manipulates it. This is the reason why we use the asterisk(*) prefix. It means that this directive is a structural directive and as such, Angular internally translates the *authorisation attribute into an <ng-template> element, wrapped around the host element. Finally, our rendered <section> element looks like the following:

<!--bindings={
  "ng-reflect-authorisation": "BIG_RED_BUTTON"
}-->
<section _ngcontent-c0 class="big-red-button-container">
  <!-- ommited -->
</section>
Enter fullscreen mode Exit fullscreen mode

With this solution, we successfully reduced the complexity of our component, and we created a scalable and reusable solution. It is important to mention, that the directive should be declared on the application root level, and it needs to be exported. I suggest putting this into a shared
module. Also, it is important to emphasize, that this is only a Front-End solution, this does not protect your API endpoints from unauthorised access.

What about reactive forms?

An excellent question! While the [disabled]="checkWriteAccess(isButtonDisabled())" works well on buttons, and on template-driven forms, it
can cause problems with reactive form inputs. Namely, binding to the [disabled] attribute can cause 'changed after checked' errors. Angular itself warns about this, and recommends using the .disable() and .enable() methods on form controls. Luckily, we can enhance our directive with the capability to store a FormControl if passed, and disable it when updateView is called.

@Directive({ selector: "[authorisation]" })
export class AuthorisationDirective implements OnDestroy {
  private formControl: AbstractControl = null

  // ...

  @Input()
  set authorisationControl(ctrl: AbstractControl) {
    this.formControl = ctrl
    this.updateView()
  }

  // ...

  private updateView(): void {
    const hasReadRight = this.authService.hasReadAccess(this.role)
    if (hasReadRight) {
      const hasWriteRight = this.authService.hasWriteAccess(this.role)
      this.viewContainer.clear()
      this.viewRef = this.viewContainer.createEmbeddedView(
        this.templateRef,
        this.createContext(hasWriteRight)
      )
      if (!hasWriteRight) {
        this.formControl?.disable()
      }
    } else {
      this.viewContainer.clear()
      this.viewRef = null
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We have added a new @Input() property to our directive. This allows us to pass any control that implements the AbstractControl, such as FormControl, FormGroup and FormArray. We can leverage this using the following directive binding:

<!-- launch-codes.component.html -->
<form
  *authorisation="'LAUNCH_CODE_INPUTS'; control launchCodesForm"
  [formGroup]="launchCodesForm"
>
  <label for="primary-high-ranking-officer">First officer access code:</label>
  <input
    id="primary-high-ranking-officer"
    formControlName="firstOfficerAccessCode"
  />

  <label for="secondary-high-ranking-officer"
    >Second officer access code:</label
  >
  <input
    id="secondary-high-ranking-officer"
    formControlName="secondOfficerAccessCode"
  />
</form>
Enter fullscreen mode Exit fullscreen mode
@Component({
  selector: "launch-codes",
  templateUrl: "./launch-codes.component.html",
})
export class LaunchCodesComponent {
  readonly launchCodesForm: FormGroup = this.fb.group({
    firstOfficerAccessCode: ["", Validators.required],
    secondOfficerAccessCode: ["", Validators.required],
  })

  constructor(private fb: FormBuilder) {}
}
Enter fullscreen mode Exit fullscreen mode

This way when the launchCodesForm is disabled if the user does not have write access.

We need more fancy

So the authorisation logic works, the button gets disabled when the user does not have write right, however, our customer wants something extra.
The goal is to make read-only components differ from full-access components. For the sake of simplicity, in this example we are going to add some opacity to these elements, so they can still be read, but they differ visibly. Let's create the CSS class first.

/* styles.css file */
.app-unauthorised {
  opacity: 0.5 !important;
}
Enter fullscreen mode Exit fullscreen mode

Now, we could easily add [class.app-unauthorised]="checkWriteAccess(false) to our template, but then again, we would need to do this to every element, which has our directive on it. We don't want that, it would not be DRY... Instead, we could use a little DOM manipulation with the help of the ElementRef. Since we want to manipulate the DOM, we inject the Renderer2 as well. Let's update our directive.

@Directive({ selector: "[authorisation]" })
export class AuthorisationDirective implements OnDestroy {
  // ...

  constructor(
    private userService: UserService,
    private authorisationService: AuthorisationService,
    @Inject(ViewContainerRef) private viewContainer: ViewContainerRef,
    @Inject(TemplateRef) private templateRef: TemplateRef<AuthorisationContext>,
    @Inject(ElementRef) private el: ElementRef,
    private renderer: Renderer2,
  ) {
    //..
  }

  // ..

  private updateView(): void {
    const hasReadRight = this.authService.hasReadAccess(this.role)
    if (hasReadRight) {
      const hasWriteRight = this.authService.hasWriteAccess(this.role)
      this.viewContainer.clear()
      this.viewRef = this.viewContainer.createEmbeddedView(
        this.templateRef,
        this.createContext(hasWriteRight)
      )
      if (!hasWriteRight) {
        this.formControl?.disable()
        this.setUnauthorised()
      }
    } else {
      this.viewContainer.clear()
      this.viewRef = null
    }
  }

  // ...

  private setUnauthorised(): void {
    this.renderer.addClass(this.el.nativeElement.previousSibling, 'app-unauthorised');
  }
}
Enter fullscreen mode Exit fullscreen mode

First, we inject the ElementRef into our directive. When the user has only read rights, the app-unauthorised class gets added to our nativeElement's previousSibling. The reason for this is that this kind of directive binding gets converted into an HTML comment in the template as mentioned before. The previous sibling is the element that you apply the structural directive to. Note, that if you use structural directives, like *ngIf, you can see <!----> in production built
Angular applications. This is the reason why we cannot bind more than one structural directive to an element, therefore, if we'd like to use this authorisation directive with an *ngIf structural directive as well, we should wrap the element inside an <ng-container> and apply one of the structural directives onto that container element.

Conclusion

Authorisation handling on the UI can be a tedious job, especially when it is one of the last things to implement in an application. I hope this article has shed some light on how you can use the power of directives in your app to make your job easier.

💖 💪 🙅 🚩
tapaibalazs
Tápai Balázs

Posted on September 29, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related