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`)
}
}
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>
@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
}
}
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.
}
}
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,
}
}
}
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>
@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
}
}
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>
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
}
}
}
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>
@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) {}
}
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;
}
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');
}
}
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.
Top comments (0)