From c0c0cf60a610c275394c7a4ac2cf6b6a75a8879b Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Thu, 16 Apr 2026 11:05:34 +0700 Subject: [PATCH] feat(challenge 6): implement role-based access control with directives and guards --- .../src/app/button.component.ts | 3 +- .../src/app/dashboard/client.component.ts | 14 ++++ .../src/app/dashboard/manager.component.ts | 4 +- .../src/app/dashboard/reader.component.ts | 14 ++++ .../src/app/dashboard/writer.component.ts | 14 ++++ .../src/app/has-role.directive.ts | 70 +++++++++++++++++++ .../src/app/has-role.guard.ts | 18 +++++ .../src/app/information.component.ts | 21 +++--- .../src/app/login.component.ts | 49 ++++--------- .../6-structural-directive/src/app/routes.ts | 38 ++++++++++ .../src/app/user-role.service.ts | 31 ++++++++ .../src/app/user.model.ts | 16 ++++- .../src/app/user.store.ts | 14 ++-- 13 files changed, 248 insertions(+), 58 deletions(-) create mode 100644 apps/angular/6-structural-directive/src/app/dashboard/client.component.ts create mode 100644 apps/angular/6-structural-directive/src/app/dashboard/reader.component.ts create mode 100644 apps/angular/6-structural-directive/src/app/dashboard/writer.component.ts create mode 100644 apps/angular/6-structural-directive/src/app/has-role.directive.ts create mode 100644 apps/angular/6-structural-directive/src/app/has-role.guard.ts create mode 100644 apps/angular/6-structural-directive/src/app/user-role.service.ts diff --git a/apps/angular/6-structural-directive/src/app/button.component.ts b/apps/angular/6-structural-directive/src/app/button.component.ts index 5d1323605..8bad2cf46 100644 --- a/apps/angular/6-structural-directive/src/app/button.component.ts +++ b/apps/angular/6-structural-directive/src/app/button.component.ts @@ -7,7 +7,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; `, host: { - class: 'border border-blue-700 bg-blue-400 p-2 rounded-sm text-white', + class: + 'rounded-md bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 cursor-pointer', }, changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/apps/angular/6-structural-directive/src/app/dashboard/client.component.ts b/apps/angular/6-structural-directive/src/app/dashboard/client.component.ts new file mode 100644 index 000000000..d78f8d6e1 --- /dev/null +++ b/apps/angular/6-structural-directive/src/app/dashboard/client.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { ButtonComponent } from '../button.component'; + +@Component({ + selector: 'app-client-dashboard', + imports: [RouterLink, ButtonComponent], + template: ` +

dashboard for Client works!

+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ClientDashboardComponent {} diff --git a/apps/angular/6-structural-directive/src/app/dashboard/manager.component.ts b/apps/angular/6-structural-directive/src/app/dashboard/manager.component.ts index 60ea7695b..4bb0977be 100644 --- a/apps/angular/6-structural-directive/src/app/dashboard/manager.component.ts +++ b/apps/angular/6-structural-directive/src/app/dashboard/manager.component.ts @@ -1,8 +1,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { ButtonComponent } from '../button.component'; @Component({ selector: 'app-dashboard', - imports: [], + imports: [RouterLink, ButtonComponent], template: `

dashboard for Manager works!

diff --git a/apps/angular/6-structural-directive/src/app/dashboard/reader.component.ts b/apps/angular/6-structural-directive/src/app/dashboard/reader.component.ts new file mode 100644 index 000000000..90a054b6f --- /dev/null +++ b/apps/angular/6-structural-directive/src/app/dashboard/reader.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { ButtonComponent } from '../button.component'; + +@Component({ + selector: 'app-reader-dashboard', + imports: [RouterLink, ButtonComponent], + template: ` +

dashboard for Reader works!

+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReaderDashboardComponent {} diff --git a/apps/angular/6-structural-directive/src/app/dashboard/writer.component.ts b/apps/angular/6-structural-directive/src/app/dashboard/writer.component.ts new file mode 100644 index 000000000..ef47efa92 --- /dev/null +++ b/apps/angular/6-structural-directive/src/app/dashboard/writer.component.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { ButtonComponent } from '../button.component'; + +@Component({ + selector: 'app-writer-dashboard', + imports: [RouterLink, ButtonComponent], + template: ` +

dashboard for Writer works!

+ + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WriterDashboardComponent {} diff --git a/apps/angular/6-structural-directive/src/app/has-role.directive.ts b/apps/angular/6-structural-directive/src/app/has-role.directive.ts new file mode 100644 index 000000000..9cfc8c504 --- /dev/null +++ b/apps/angular/6-structural-directive/src/app/has-role.directive.ts @@ -0,0 +1,70 @@ +import { + Directive, + effect, + inject, + input, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; +import { Role } from './user.model'; +import { UserStore } from './user.store'; + +@Directive({ selector: '[hasRole]' }) +export class HasRoleDirective { + private readonly templateRef = inject(TemplateRef); + private readonly vcRef = inject(ViewContainerRef); + private readonly userStore = inject(UserStore); + + hasRole = input([], { + alias: 'hasRole', + transform: (v: Role | Role[]) => { + return typeof v === 'string' ? [v] : v; + }, + }); + + effect = effect(() => { + const user = this.userStore.getUser(); + const userRoles = user()?.roles ?? []; + const requiredRoles = this.hasRole() ?? []; + + this.updateView(userRoles, requiredRoles); + }); + + private updateView(userRoles: Role[], requiredRoles: Role[]) { + this.vcRef.clear(); + + if (requiredRoles.length === 0) { + this.vcRef.createEmbeddedView(this.templateRef); + return; + } + + const canAccess = requiredRoles.some((role) => + userRoles.includes(role as any), + ); + + if (canAccess) { + this.vcRef.createEmbeddedView(this.templateRef); + } + } +} + +@Directive({ selector: '[isSuperAdmin]' }) +export class IsSuperAdminDirective { + private readonly templateRef = inject(TemplateRef); + private readonly vcRef = inject(ViewContainerRef); + private readonly userStore = inject(UserStore); + + effect = effect(() => { + const user = this.userStore.getUser(); + + this.updateView(user()?.isAdmin); + }); + + private updateView(isAdminUser: boolean | undefined) { + this.vcRef.clear(); + + if (isAdminUser) { + this.vcRef.createEmbeddedView(this.templateRef); + } + } +} diff --git a/apps/angular/6-structural-directive/src/app/has-role.guard.ts b/apps/angular/6-structural-directive/src/app/has-role.guard.ts new file mode 100644 index 000000000..abdd749f7 --- /dev/null +++ b/apps/angular/6-structural-directive/src/app/has-role.guard.ts @@ -0,0 +1,18 @@ +import { inject } from '@angular/core'; +import { CanMatchFn } from '@angular/router'; +import { UserRoleService } from './user-role.service'; +import { Role } from './user.model'; + +export const isAdminGuard: CanMatchFn = () => { + const userRoleService = inject(UserRoleService); + + return userRoleService.isSuperAdminUser(); +}; + +export const hasRoleGuard = (roles: Role[]): CanMatchFn => { + return () => { + const userRoleService = inject(UserRoleService); + + return userRoleService.hasAnyRole(roles); + }; +}; diff --git a/apps/angular/6-structural-directive/src/app/information.component.ts b/apps/angular/6-structural-directive/src/app/information.component.ts index ecf937efc..e1ac22b6b 100644 --- a/apps/angular/6-structural-directive/src/app/information.component.ts +++ b/apps/angular/6-structural-directive/src/app/information.component.ts @@ -1,22 +1,19 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { UserStore } from './user.store'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { HasRoleDirective, IsSuperAdminDirective } from './has-role.directive'; @Component({ selector: 'app-information', template: `

Information Panel

-
visible only for super admin
-
visible if manager
-
visible if manager and/or reader
-
visible if manager and/or writer
-
visible if client
+
visible only for super admin
+
visible if manager
+
visible if manager and/or reader
+
visible if manager and/or writer
+
visible if client
visible for everyone
`, changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HasRoleDirective, IsSuperAdminDirective], }) -export class InformationComponent { - private readonly userStore = inject(UserStore); - - user$ = this.userStore.user$; -} +export class InformationComponent {} diff --git a/apps/angular/6-structural-directive/src/app/login.component.ts b/apps/angular/6-structural-directive/src/app/login.component.ts index f38e5e5ca..996ef7535 100644 --- a/apps/angular/6-structural-directive/src/app/login.component.ts +++ b/apps/angular/6-structural-directive/src/app/login.component.ts @@ -2,15 +2,7 @@ import { Component, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import { ButtonComponent } from './button.component'; import { InformationComponent } from './information.component'; -import { - admin, - client, - everyone, - manager, - reader, - readerAndWriter, - writer, -} from './user.model'; +import { USERS_MAP, UserType } from './user.model'; import { UserStore } from './user.store'; @Component({ @@ -19,13 +11,15 @@ import { UserStore } from './user.store'; template: `
Log as : - - - - - - - + + + + + + +
@@ -38,25 +32,8 @@ import { UserStore } from './user.store'; export class LoginComponent { private readonly userStore = inject(UserStore); - admin() { - this.userStore.add(admin); - } - manager() { - this.userStore.add(manager); - } - reader() { - this.userStore.add(reader); - } - writer() { - this.userStore.add(writer); - } - readerWriter() { - this.userStore.add(readerAndWriter); - } - client() { - this.userStore.add(client); - } - everyone() { - this.userStore.add(everyone); + loginAs(userType: UserType) { + const user = USERS_MAP[userType]; + this.userStore.setUser(user); } } diff --git a/apps/angular/6-structural-directive/src/app/routes.ts b/apps/angular/6-structural-directive/src/app/routes.ts index 4db203f3b..f20aa1ba4 100644 --- a/apps/angular/6-structural-directive/src/app/routes.ts +++ b/apps/angular/6-structural-directive/src/app/routes.ts @@ -1,3 +1,5 @@ +import { hasRoleGuard, isAdminGuard } from './has-role.guard'; + export const APP_ROUTES = [ { path: '', @@ -6,9 +8,45 @@ export const APP_ROUTES = [ }, { path: 'enter', + canMatch: [isAdminGuard], loadComponent: () => import('./dashboard/admin.component').then( (m) => m.AdminDashboardComponent, ), }, + { + path: 'enter', + canMatch: [hasRoleGuard(['MANAGER'])], + data: { role: 'MANAGER' }, + loadComponent: () => + import('./dashboard/manager.component').then( + (m) => m.ManagerDashboardComponent, + ), + }, + { + path: 'enter', + canMatch: [hasRoleGuard(['READER'])], + data: { role: 'READER' }, + loadComponent: () => + import('./dashboard/reader.component').then( + (m) => m.ReaderDashboardComponent, + ), + }, + { + path: 'enter', + canMatch: [hasRoleGuard(['WRITER'])], + data: { role: 'WRITER' }, + loadComponent: () => + import('./dashboard/writer.component').then( + (m) => m.WriterDashboardComponent, + ), + }, + { + path: 'enter', + canMatch: [hasRoleGuard(['CLIENT'])], + loadComponent: () => + import('./dashboard/client.component').then( + (m) => m.ClientDashboardComponent, + ), + }, ]; diff --git a/apps/angular/6-structural-directive/src/app/user-role.service.ts b/apps/angular/6-structural-directive/src/app/user-role.service.ts new file mode 100644 index 000000000..32ea50fcf --- /dev/null +++ b/apps/angular/6-structural-directive/src/app/user-role.service.ts @@ -0,0 +1,31 @@ +import { inject, Injectable } from '@angular/core'; +import { Role } from './user.model'; +import { UserStore } from './user.store'; + +@Injectable({ providedIn: 'root' }) +export class UserRoleService { + private readonly userStore = inject(UserStore); + + hasAnyRole(roles: Role[]): boolean { + const user = this.userStore.getUser(); + const userRoles = user()?.roles ?? []; + + return roles.some((role) => userRoles.includes(role)); + } + + hasAllRoles(roles: Role[]): boolean { + const user = this.userStore.getUser(); + const userRoles = user()?.roles ?? []; + + return roles.every((role) => userRoles.includes(role)); + } + + hasRole(role: Role): boolean { + return this.hasAnyRole([role]); + } + + isSuperAdminUser(): boolean { + const user = this.userStore.getUser(); + return user()?.isAdmin ?? false; + } +} diff --git a/apps/angular/6-structural-directive/src/app/user.model.ts b/apps/angular/6-structural-directive/src/app/user.model.ts index 353a5e214..07d7eb508 100644 --- a/apps/angular/6-structural-directive/src/app/user.model.ts +++ b/apps/angular/6-structural-directive/src/app/user.model.ts @@ -31,7 +31,7 @@ export const reader: User = { }; export const readerAndWriter: User = { - name: 'reader', + name: 'reader and writer', isAdmin: false, roles: ['READER', 'WRITER'], }; @@ -43,7 +43,19 @@ export const client: User = { }; export const everyone: User = { - name: 'client', + name: 'everyone', isAdmin: false, roles: [], }; + +export const USERS_MAP: Record = { + admin, + manager, + reader, + writer, + readerAndWriter, + client, + everyone, +}; + +export type UserType = keyof typeof USERS_MAP; diff --git a/apps/angular/6-structural-directive/src/app/user.store.ts b/apps/angular/6-structural-directive/src/app/user.store.ts index 1b00288b7..7ba6ac1b5 100644 --- a/apps/angular/6-structural-directive/src/app/user.store.ts +++ b/apps/angular/6-structural-directive/src/app/user.store.ts @@ -1,15 +1,17 @@ -import { Injectable } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; +import { Injectable, signal } from '@angular/core'; import { User } from './user.model'; @Injectable({ providedIn: 'root', }) export class UserStore { - private user = new BehaviorSubject(undefined); - user$ = this.user.asObservable(); + private _user = signal(undefined); - add(user: User) { - this.user.next(user); + getUser() { + return this._user.asReadonly(); + } + + setUser(user: User): void { + this._user.set(user); } }