From 4f0daf94394bd571c98f87115fc78ddaffef6a7e Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 19 Dec 2023 12:41:52 +0100 Subject: [PATCH] feat: Dashboard permissions (#2131) closes #2122 #1552 Co-authored-by: Sebastian Leidig --- .../attendance-week-dashboard.component.ts | 15 ++- .../children-list/children-list.component.ts | 5 +- .../children-bmi-dashboard.component.ts | 14 +- .../important-notes-dashboard.component.ts | 11 +- .../notes-dashboard.component.ts | 22 +++- .../notes-manager/notes-manager.component.ts | 6 +- .../dynamic-component-config.interface.ts | 37 +++++- .../route-permissions.service.spec.ts | 23 ++++ .../route-permissions.service.ts | 37 ++++++ .../dynamic-routing/view-config.interface.ts | 50 +------- .../dashboard-widget/dashboard-widget.ts | 15 +++ .../dashboard/dashboard.component.spec.ts | 107 +++++++++++++++- .../dashboard/dashboard.component.ts | 68 +++++++++- .../entity-list/entity-list.component.spec.ts | 6 +- .../abstract-permission.guard.ts | 92 +++++++++++++ .../entity-permission.guard.ts | 50 ++------ .../permission-guard/user-role.guard.spec.ts | 121 +++++++++++------- .../permission-guard/user-role.guard.ts | 106 ++------------- src/app/core/ui/navigation/menu-item.ts | 21 +-- .../navigation/navigation.component.spec.ts | 15 +-- .../navigation/navigation.component.ts | 29 ++--- .../birthday-dashboard.component.ts | 16 ++- .../entity-count-dashboard.component.ts | 19 ++- .../progress-dashboard.component.ts | 14 +- .../shortcut-dashboard.component.html | 2 +- .../shortcut-dashboard.component.spec.ts | 47 ++++++- .../shortcut-dashboard.component.ts | 13 +- .../markdown-page.component.spec.ts | 6 +- .../matching-entities.component.spec.ts | 4 +- .../matching-entities.component.ts | 24 ++-- .../todos/todo-list/todo-list.component.ts | 9 +- .../todos-dashboard.component.ts | 11 +- 32 files changed, 699 insertions(+), 316 deletions(-) create mode 100644 src/app/core/config/dynamic-routing/route-permissions.service.spec.ts create mode 100644 src/app/core/config/dynamic-routing/route-permissions.service.ts create mode 100644 src/app/core/dashboard/dashboard-widget/dashboard-widget.ts create mode 100644 src/app/core/permissions/permission-guard/abstract-permission.guard.ts diff --git a/src/app/child-dev-project/attendance/dashboard-widgets/attendance-week-dashboard/attendance-week-dashboard.component.ts b/src/app/child-dev-project/attendance/dashboard-widgets/attendance-week-dashboard/attendance-week-dashboard.component.ts index 2b1db14b1f..2778c7310d 100644 --- a/src/app/child-dev-project/attendance/dashboard-widgets/attendance-week-dashboard/attendance-week-dashboard.component.ts +++ b/src/app/child-dev-project/attendance/dashboard-widgets/attendance-week-dashboard/attendance-week-dashboard.component.ts @@ -22,6 +22,8 @@ import { DisplayEntityComponent } from "../../../../core/basic-datatypes/entity/ import { DashboardWidgetComponent } from "../../../../core/dashboard/dashboard-widget/dashboard-widget.component"; import { AttendanceDayBlockComponent } from "./attendance-day-block/attendance-day-block.component"; import { WidgetContentComponent } from "../../../../core/dashboard/dashboard-widget/widget-content/widget-content.component"; +import { DashboardWidget } from "../../../../core/dashboard/dashboard-widget/dashboard-widget"; +import { EventNote } from "../../model/event-note"; interface AttendanceWeekRow { childId: string; @@ -46,7 +48,14 @@ interface AttendanceWeekRow { ], standalone: true, }) -export class AttendanceWeekDashboardComponent implements OnInit, AfterViewInit { +export class AttendanceWeekDashboardComponent + extends DashboardWidget + implements OnInit, AfterViewInit +{ + static getRequiredEntities() { + return EventNote.ENTITY_TYPE; + } + /** * The offset from the default time period, which is the last complete week. * @@ -89,7 +98,9 @@ export class AttendanceWeekDashboardComponent implements OnInit, AfterViewInit { constructor( private attendanceService: AttendanceService, private router: Router, - ) {} + ) { + super(); + } ngOnInit() { if (this.periodLabel && !this.label) { diff --git a/src/app/child-dev-project/children/children-list/children-list.component.ts b/src/app/child-dev-project/children/children-list/children-list.component.ts index 918d73e1da..d417239f68 100644 --- a/src/app/child-dev-project/children/children-list/children-list.component.ts +++ b/src/app/child-dev-project/children/children-list/children-list.component.ts @@ -3,7 +3,7 @@ import { Child } from "../model/child"; import { ActivatedRoute } from "@angular/router"; import { ChildrenService } from "../children.service"; import { EntityListConfig } from "../../../core/entity-list/EntityListConfig"; -import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; +import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface"; import { EntityListComponent } from "../../../core/entity-list/entity-list/entity-list.component"; import { RouteTarget } from "../../../route-target"; @@ -36,7 +36,8 @@ export class ChildrenListComponent implements OnInit { this.route.data.subscribe( // TODO replace this use of route and rely on the RoutedViewComponent instead // see that flattens the config option, assigning individual properties as inputs however, so we can't easily pass on - (data: RouteData) => (this.listConfig = data.config), + (data: DynamicComponentConfig) => + (this.listConfig = data.config), ); this.childrenList = await this.childrenService.getChildren(); this.isLoading = false; diff --git a/src/app/child-dev-project/children/health-checkup/children-bmi-dashboard/children-bmi-dashboard.component.ts b/src/app/child-dev-project/children/health-checkup/children-bmi-dashboard/children-bmi-dashboard.component.ts index 1f7ad9e510..1b8ba92980 100644 --- a/src/app/child-dev-project/children/health-checkup/children-bmi-dashboard/children-bmi-dashboard.component.ts +++ b/src/app/child-dev-project/children/health-checkup/children-bmi-dashboard/children-bmi-dashboard.component.ts @@ -11,6 +11,7 @@ import { DecimalPipe, NgIf } from "@angular/common"; import { DisplayEntityComponent } from "../../../../core/basic-datatypes/entity/display-entity/display-entity.component"; import { DashboardWidgetComponent } from "../../../../core/dashboard/dashboard-widget/dashboard-widget.component"; import { WidgetContentComponent } from "../../../../core/dashboard/dashboard-widget/widget-content/widget-content.component"; +import { DashboardWidget } from "../../../../core/dashboard/dashboard-widget/dashboard-widget"; interface BmiRow { childId: string; @@ -33,13 +34,22 @@ interface BmiRow { ], standalone: true, }) -export class ChildrenBmiDashboardComponent implements OnInit, AfterViewInit { +export class ChildrenBmiDashboardComponent + extends DashboardWidget + implements OnInit, AfterViewInit +{ + static getRequiredEntities() { + return HealthCheck.ENTITY_TYPE; + } + bmiDataSource = new MatTableDataSource(); isLoading = true; entityLabelPlural: string = Child.labelPlural; @ViewChild("paginator") paginator: MatPaginator; - constructor(private entityMapper: EntityMapperService) {} + constructor(private entityMapper: EntityMapperService) { + super(); + } ngOnInit() { return this.loadBMIData(); diff --git a/src/app/child-dev-project/notes/dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component.ts b/src/app/child-dev-project/notes/dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component.ts index 976d4c76c9..0aaf1d6c1b 100644 --- a/src/app/child-dev-project/notes/dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component.ts +++ b/src/app/child-dev-project/notes/dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component.ts @@ -6,6 +6,7 @@ import { NoteDetailsComponent } from "../../note-details/note-details.component" import { DashboardListWidgetComponent } from "../../../../core/dashboard/dashboard-list-widget/dashboard-list-widget.component"; import { MatTableModule } from "@angular/material/table"; import { DatePipe, NgStyle } from "@angular/common"; +import { DashboardWidget } from "../../../../core/dashboard/dashboard-widget/dashboard-widget"; @DynamicComponent("ImportantNotesDashboard") @DynamicComponent("ImportantNotesComponent") // TODO remove after all existing instances are updated @@ -16,14 +17,20 @@ import { DatePipe, NgStyle } from "@angular/common"; imports: [DashboardListWidgetComponent, MatTableModule, DatePipe, NgStyle], standalone: true, }) -export class ImportantNotesDashboardComponent { +export class ImportantNotesDashboardComponent extends DashboardWidget { + static getRequiredEntities() { + return Note.ENTITY_TYPE; + } + @Input() warningLevels: string[] = []; dataMapper: (data: Note[]) => Note[] = (data) => data .filter((note) => note.warningLevel && this.noteIsRelevant(note)) .sort((a, b) => b.warningLevel._ordinal - a.warningLevel._ordinal); - constructor(private formDialog: FormDialogService) {} + constructor(private formDialog: FormDialogService) { + super(); + } private noteIsRelevant(note: Note): boolean { return this.warningLevels.includes(note.warningLevel.id); diff --git a/src/app/child-dev-project/notes/dashboard-widgets/notes-dashboard/notes-dashboard.component.ts b/src/app/child-dev-project/notes/dashboard-widgets/notes-dashboard/notes-dashboard.component.ts index d45eb9364c..1329575419 100644 --- a/src/app/child-dev-project/notes/dashboard-widgets/notes-dashboard/notes-dashboard.component.ts +++ b/src/app/child-dev-project/notes/dashboard-widgets/notes-dashboard/notes-dashboard.component.ts @@ -17,6 +17,15 @@ import { DecimalPipe, NgIf } from "@angular/common"; import { DisplayEntityComponent } from "../../../../core/basic-datatypes/entity/display-entity/display-entity.component"; import { DashboardWidgetComponent } from "../../../../core/dashboard/dashboard-widget/dashboard-widget.component"; import { WidgetContentComponent } from "../../../../core/dashboard/dashboard-widget/widget-content/widget-content.component"; +import { DashboardWidget } from "../../../../core/dashboard/dashboard-widget/dashboard-widget"; +import { Note } from "../../model/note"; + +interface NotesDashboardConfig { + entity?: string; + sinceDays?: number; + fromBeginningOfWeek?: boolean; + mode?: "with-recent-notes" | "without-recent-notes"; +} /** * Dashboard Widget displaying entities that do not have a recently added Note. @@ -40,7 +49,14 @@ import { WidgetContentComponent } from "../../../../core/dashboard/dashboard-wid ], standalone: true, }) -export class NotesDashboardComponent implements OnInit, AfterViewInit { +export class NotesDashboardComponent + extends DashboardWidget + implements OnInit, AfterViewInit, NotesDashboardConfig +{ + static getRequiredEntities(config: NotesDashboardConfig) { + return config?.entity || Note.ENTITY_TYPE; + } + /** * Entity for which the recent notes should be counted. */ @@ -73,7 +89,9 @@ export class NotesDashboardComponent implements OnInit, AfterViewInit { constructor( private childrenService: ChildrenService, private entities: EntityRegistry, - ) {} + ) { + super(); + } ngOnInit() { let dayRangeBoundary = this.sinceDays; diff --git a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.ts b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.ts index 3e8301e19c..972167ac4f 100644 --- a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.ts +++ b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.ts @@ -12,7 +12,7 @@ import { applyUpdate } from "../../../core/entity/model/entity-update"; import { EntityListConfig } from "../../../core/entity-list/EntityListConfig"; import { EventNote } from "../../attendance/model/event-note"; import { WarningLevel } from "../../warning-level"; -import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; +import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface"; import { merge } from "rxjs"; import moment from "moment"; import { MatSlideToggleModule } from "@angular/material/slide-toggle"; @@ -98,7 +98,9 @@ export class NotesManagerComponent implements OnInit { async ngOnInit() { this.route.data.subscribe( - async (data: RouteData) => { + async ( + data: DynamicComponentConfig, + ) => { // TODO replace this use of route and rely on the RoutedViewComponent instead this.config = data.config; this.addPrebuiltFilters(); diff --git a/src/app/core/config/dynamic-components/dynamic-component-config.interface.ts b/src/app/core/config/dynamic-components/dynamic-component-config.interface.ts index d85e6e3e90..f586c99b0e 100644 --- a/src/app/core/config/dynamic-components/dynamic-component-config.interface.ts +++ b/src/app/core/config/dynamic-components/dynamic-component-config.interface.ts @@ -1,8 +1,35 @@ /** - * Object specifying one dynamic component - * as stored in the config database + * This interface is set on the `data` property of the route. + * It contains static data which are used to build components and manage permissions. + * The generic type defines the interface for the component specific configuration. + * + * The properties given in the `config` object here are automatically assigned to the component as `@Input()` properties. + * e.g. for an DynamicComponentConfig `{ config: { "entityType: "Child", "filtered": true } }` + * your component `MyViewComponent` will receive the values mapped to its properties: + * ```javascript + * class MyViewComponent { + * @Input() entityType: string; + * @Input() filtered: boolean; + * } + * ``` */ -export interface DynamicComponentConfig { - component: string; - config?: any; +export interface DynamicComponentConfig { + /** + * string id/name of the component to be displaying this view. + * The component id has to be registered in the component map. + * + * (optional) if the `ladyLoaded` is true, this is not required (and will be ignored) + * This allows hard-coded lazy-loaded components to be dynamically extended with config or permissions. + */ + component?: string; + + /** optional object providing any kind of config to be interpreted by the component for this view */ + config?: T; + + /** + * Allows to restrict the route to the given list of user roles. + * If set, the route can only be visited by users which have a role which is in the list. + * If not set, all logged-in users can visit the route. + */ + permittedUserRoles?: string[]; } diff --git a/src/app/core/config/dynamic-routing/route-permissions.service.spec.ts b/src/app/core/config/dynamic-routing/route-permissions.service.spec.ts new file mode 100644 index 0000000000..24da97cd15 --- /dev/null +++ b/src/app/core/config/dynamic-routing/route-permissions.service.spec.ts @@ -0,0 +1,23 @@ +import { TestBed } from "@angular/core/testing"; + +import { RoutePermissionsService } from "./route-permissions.service"; +import { UserRoleGuard } from "../../permissions/permission-guard/user-role.guard"; +import { EntityPermissionGuard } from "../../permissions/permission-guard/entity-permission.guard"; + +describe("RoutePermissionsService", () => { + let service: RoutePermissionsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: UserRoleGuard, useValue: {} }, + { provide: EntityPermissionGuard, useValue: {} }, + ], + }); + service = TestBed.inject(RoutePermissionsService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/app/core/config/dynamic-routing/route-permissions.service.ts b/src/app/core/config/dynamic-routing/route-permissions.service.ts new file mode 100644 index 0000000000..96687ccfb7 --- /dev/null +++ b/src/app/core/config/dynamic-routing/route-permissions.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from "@angular/core"; +import { UserRoleGuard } from "../../permissions/permission-guard/user-role.guard"; +import { EntityPermissionGuard } from "../../permissions/permission-guard/entity-permission.guard"; +import { MenuItem } from "../../ui/navigation/menu-item"; + +/** + * Service that checks permissions for routes. + */ +@Injectable({ + providedIn: "root", +}) +export class RoutePermissionsService { + constructor( + private roleGuard: UserRoleGuard, + private permissionGuard: EntityPermissionGuard, + ) {} + + /** + * Filters menu items based on the route and entity permissions on the link. + */ + async filterPermittedRoutes(items: MenuItem[]): Promise { + const accessibleRoutes: MenuItem[] = []; + for (const item of items) { + if (await this.isAccessibleRouteForUser(item.link)) { + accessibleRoutes.push(item); + } + } + return accessibleRoutes; + } + + private async isAccessibleRouteForUser(path: string) { + return ( + (await this.roleGuard.checkRoutePermissions(path)) && + (await this.permissionGuard.checkRoutePermissions(path)) + ); + } +} diff --git a/src/app/core/config/dynamic-routing/view-config.interface.ts b/src/app/core/config/dynamic-routing/view-config.interface.ts index c2d311ba55..51780e1bc1 100644 --- a/src/app/core/config/dynamic-routing/view-config.interface.ts +++ b/src/app/core/config/dynamic-routing/view-config.interface.ts @@ -1,30 +1,13 @@ +import { DynamicComponentConfig } from "../dynamic-components/dynamic-component-config.interface"; + /** * Object specifying a route and config of its view * as stored in the config database */ -export interface ViewConfig { +export interface ViewConfig extends DynamicComponentConfig { /** config object id which equals the route path */ _id: string; - /** - * string id/name of the component to be displaying this view. - * The component id has to be registered in the component map. - * - * (optional) if the `ladyLoaded` is true, this is not required (and will be ignored) - * This allows hard-coded lazy-loaded components to be dynamically extended with config or permissions. - */ - component?: string; - - /** - * Allows to restrict the route to the given list of user roles. - * If set, the route can only be visited by users which have a role which is in the list. - * If not set, all logged-in users can visit the route. - */ - permittedUserRoles?: string[]; - - /** optional object providing any kind of config to be interpreted by the component for this view */ - config?: T; - /** * indicate that the route is lazy loaded. * @@ -38,30 +21,3 @@ export interface ViewConfig { * The prefix which is used to find the ViewConfig's in the config file */ export const PREFIX_VIEW_CONFIG = "view:"; - -/** - * This interface is set on the `data` property of the route. - * It contains static data which are used to build components and manage permissions. - * The generic type defines the interface for the component specific configuration. - * - * The properties given in the `config` object here are automatically assigned to the component as `@Input()` properties. - * e.g. for an RouteData `{ config: { "entityType: "Child", "filtered": true } }` - * your component `MyViewComponent` will receive the values mapped to its properties: - * ```javascript - * class MyViewComponent { - * @Input() entityType: string; - * @Input() filtered: boolean; - * } - * ``` - */ -export interface RouteData { - /** - * If the `UserRoleGuard` is used for the route, this array holds the information which roles can access the route. - */ - permittedUserRoles?: string[]; - - /** - * The component specific configuration. - */ - config?: T; -} diff --git a/src/app/core/dashboard/dashboard-widget/dashboard-widget.ts b/src/app/core/dashboard/dashboard-widget/dashboard-widget.ts new file mode 100644 index 0000000000..abab805ed7 --- /dev/null +++ b/src/app/core/dashboard/dashboard-widget/dashboard-widget.ts @@ -0,0 +1,15 @@ +/** + * Abstract class for dashboard widgets + */ +export abstract class DashboardWidget { + /** + * Implement this if the dashboard depends on the user having access to a certain entity. + * If an array of strings is returned, the dashboard is shown if the user has access to at least one of them. + * + * @param config same of the normal config that will later be passed to the inputs + * @return ENTITY_TYPE which a user needs to have + */ + static getRequiredEntities(config: any): string | string[] { + return; + } +} diff --git a/src/app/core/dashboard/dashboard/dashboard.component.spec.ts b/src/app/core/dashboard/dashboard/dashboard.component.spec.ts index 27b11bd14b..4978dc1a3a 100644 --- a/src/app/core/dashboard/dashboard/dashboard.component.spec.ts +++ b/src/app/core/dashboard/dashboard/dashboard.component.spec.ts @@ -1,14 +1,25 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; import { DashboardComponent } from "./dashboard.component"; +import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic-component-config.interface"; +import { EntityAbility } from "../../permissions/ability/entity-ability"; +import { MockedTestingModule } from "../../../utils/mocked-testing.module"; +import { CurrentUserSubject } from "../../user/user"; describe("DashboardComponent", () => { let component: DashboardComponent; let fixture: ComponentFixture; + let ability: EntityAbility; beforeEach(() => { TestBed.configureTestingModule({ - imports: [DashboardComponent], + imports: [DashboardComponent, MockedTestingModule], }).compileComponents(); + ability = TestBed.inject(EntityAbility); }); beforeEach(() => { @@ -20,4 +31,96 @@ describe("DashboardComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("should only display widgets for which a user has permissions", fakeAsync(() => { + const widgets: DynamicComponentConfig[] = [ + { component: "TodosDashboard" }, + { component: "EntityCountDashboard" }, + { + component: "EntityCountDashboard", + config: { entity: "School", groupBy: "language" }, + }, + { component: "ShortcutDashboard", config: { shortcuts: [] } }, + ]; + + // Some read permissions + ability.update([ + { subject: "Child", action: "manage" }, + { subject: "Todo", action: ["update", "read"] }, + ]); + component.widgets = widgets; + tick(); + console.log("widgets", component.widgets); + expect(component.widgets).toEqual([widgets[0], widgets[1], widgets[3]]); + + // No read permissions + ability.update([{ subject: "all", action: "update" }]); + component.widgets = widgets; + tick(); + expect(component.widgets).toEqual([widgets[3]]); + + // All read permissions + ability.update([{ subject: "all", action: "manage" }]); + component.widgets = widgets; + tick(); + expect(component.widgets).toEqual(widgets); + })); + + it("should show birthday widget if user has access to any provided entity", fakeAsync(() => { + ability.update([{ subject: "Child", action: "read" }]); + component.widgets = [{ component: "BirthdayDashboard" }]; + tick(); + expect(component.widgets).toHaveSize(1); + + component.widgets = [ + { + component: "BirthdayDashboard", + config: { entities: { User: "birthday" } }, + }, + ]; + tick(); + expect(component.widgets).toHaveSize(0); + + component.widgets = [ + { + component: "BirthdayDashboard", + config: { entities: { User: "birthday", Child: "dateOfBirth" } }, + }, + ]; + tick(); + expect(component.widgets).toHaveSize(1); + })); + + it("should show widget if user only have access to some entities", fakeAsync(() => { + ability.update([]); + component.widgets = [{ component: "NotesDashboard" }]; + tick(); + expect(component.widgets).toHaveSize(0); + + ability.update([ + { subject: "Note", action: "manage", conditions: { category: "VISIT" } }, + ]); + component.widgets = [{ component: "NotesDashboard" }]; + tick(); + expect(component.widgets).toHaveSize(1); + })); + + it("should hide widgets if the user is missing the required role", fakeAsync(() => { + ability.update([{ subject: "all", action: "manage" }]); + const user = TestBed.inject(CurrentUserSubject); + const widgets = [ + { component: "TodosDashboard", permittedUserRoles: ["admin_app"] }, + { component: "EntityCountDashboard" }, + ]; + + user.next({ name: "not admin", roles: ["user_app"] }); + component.widgets = widgets; + tick(); + expect(component.widgets).toEqual([widgets[1]]); + + user.next({ name: "admin", roles: ["user_app", "admin_app"] }); + component.widgets = widgets; + tick(); + expect(component.widgets).toEqual(widgets); + })); }); diff --git a/src/app/core/dashboard/dashboard/dashboard.component.ts b/src/app/core/dashboard/dashboard/dashboard.component.ts index 8552547edf..1d4c925094 100644 --- a/src/app/core/dashboard/dashboard/dashboard.component.ts +++ b/src/app/core/dashboard/dashboard/dashboard.component.ts @@ -20,12 +20,16 @@ import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic- import { NgFor } from "@angular/common"; import { DynamicComponentDirective } from "../../config/dynamic-components/dynamic-component.directive"; import { RouteTarget } from "../../../route-target"; +import { EntityAbility } from "../../permissions/ability/entity-ability"; +import { ComponentRegistry } from "../../../dynamic-components"; +import { DashboardWidget } from "../dashboard-widget/dashboard-widget"; +import { CurrentUserSubject } from "../../user/user"; @RouteTarget("Dashboard") @Component({ selector: "app-dashboard", template: ` `, styleUrls: ["./dashboard.component.scss"], @@ -33,7 +37,67 @@ import { RouteTarget } from "../../../route-target"; standalone: true, }) export class DashboardComponent implements DashboardConfig { - @Input() widgets: DynamicComponentConfig[] = []; + @Input() set widgets(widgets: DynamicComponentConfig[]) { + this.filterPermittedWidgets(widgets).then((res) => (this._widgets = res)); + } + get widgets(): DynamicComponentConfig[] { + return this._widgets; + } + _widgets: DynamicComponentConfig[] = []; + + constructor( + private ability: EntityAbility, + private components: ComponentRegistry, + private user: CurrentUserSubject, + ) {} + + private async filterPermittedWidgets( + widgets: DynamicComponentConfig[], + ): Promise { + const permittedWidgets: DynamicComponentConfig[] = []; + for (const widget of widgets) { + if ( + this.hasRequiredRole(widget) && + (await this.hasEntityPermission(widget)) + ) { + permittedWidgets.push(widget); + } + } + return permittedWidgets; + } + + private hasRequiredRole(widget: DynamicComponentConfig) { + if (widget.permittedUserRoles?.length > 0) { + const userRoles = this.user.value.roles; + const requiredRoles = widget.permittedUserRoles; + return requiredRoles.some((role) => userRoles.includes(role)); + } else { + return true; + } + } + + private async hasEntityPermission(widget: DynamicComponentConfig) { + const comp = (await this.components.get( + widget.component, + )()) as unknown as typeof DashboardWidget; + let entity: string | string[]; + if (typeof comp.getRequiredEntities === "function") { + entity = comp.getRequiredEntities(widget.config); + } + return this.userHasAccess(entity); + } + + private userHasAccess(entity: string | string[]): boolean { + if (entity) { + if (Array.isArray(entity)) { + return entity.some((e) => this.ability.can("read", e)); + } else { + return this.ability.can("read", entity); + } + } + // No entity relation -> show widget + return true; + } } export interface DashboardConfig { diff --git a/src/app/core/entity-list/entity-list/entity-list.component.spec.ts b/src/app/core/entity-list/entity-list/entity-list.component.spec.ts index 6dc89d8f14..f82b61e4d5 100644 --- a/src/app/core/entity-list/entity-list/entity-list.component.spec.ts +++ b/src/app/core/entity-list/entity-list/entity-list.component.spec.ts @@ -14,7 +14,7 @@ import { AttendanceService } from "../../../child-dev-project/attendance/attenda import { MockedTestingModule } from "../../../utils/mocked-testing.module"; import { ActivatedRoute, Router } from "@angular/router"; import { Subject } from "rxjs"; -import { RouteData } from "../../config/dynamic-routing/view-config.interface"; +import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic-component-config.interface"; import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; import { HarnessLoader } from "@angular/cdk/testing"; import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed"; @@ -72,7 +72,7 @@ describe("EntityListComponent", () => { }; let mockAttendanceService: jasmine.SpyObj; let mockActivatedRoute: Partial; - let routeData: Subject>; + let routeData: Subject>; beforeEach(waitForAsync(() => { mockAttendanceService = jasmine.createSpyObj([ @@ -81,7 +81,7 @@ describe("EntityListComponent", () => { ]); mockAttendanceService.getActivitiesForChild.and.resolveTo([]); mockAttendanceService.getAllActivityAttendancesForPeriod.and.resolveTo([]); - routeData = new Subject>(); + routeData = new Subject>(); mockActivatedRoute = { component: undefined, queryParams: new Subject(), diff --git a/src/app/core/permissions/permission-guard/abstract-permission.guard.ts b/src/app/core/permissions/permission-guard/abstract-permission.guard.ts new file mode 100644 index 0000000000..a59e038f59 --- /dev/null +++ b/src/app/core/permissions/permission-guard/abstract-permission.guard.ts @@ -0,0 +1,92 @@ +import { + ActivatedRouteSnapshot, + CanActivate, + Route, + Router, +} from "@angular/router"; +import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic-component-config.interface"; + +/** + * Abstract base class with functionality common to all guards that check configurable user permissions or roles. + */ +export abstract class AbstractPermissionGuard implements CanActivate { + constructor(private router: Router) {} + + /** + * Check if current navigation is allowed. This is used by Angular Router. + * @param route + */ + async canActivate(route: ActivatedRouteSnapshot): Promise { + const routeData: DynamicComponentConfig = route.data; + if (await this.canAccessRoute(routeData)) { + return true; + } else { + if (route instanceof ActivatedRouteSnapshot) { + // Route should only change if this is a "real" navigation check (not the check in the NavigationComponent) + this.router.navigate(["/404"]); + } + return false; + } + } + + /** + * Implement specific permission checks here, based on the given route data (from config) + * and any required services provided by Angular dependency injection. + * + * @param routeData The route data object defined either in routing code or loaded from config by the RouterService. + * @protected + */ + protected abstract canAccessRoute( + routeData: DynamicComponentConfig, + ): Promise; + + /** + * Pre-check if access to the given route would be allowed. + * This is used by components and services to evaluate permissions without actual navigation. + * + * @param path + */ + public checkRoutePermissions(path: string) { + let routeData = this.getRouteDataFromRouter(path, this.router.config); + return this.canAccessRoute(routeData?.data); + } + + /** + * Extract the relevant route from Router, to get a merged route that contains the full trail of `permittedRoles` + * @param path + * @param routes + * @private + */ + private getRouteDataFromRouter(path: string, routes: Route[]) { + // removing leading slash + path = path.replace(/^\//, ""); + + function isPathMatch(genericPath: string, path: string) { + const routeRegex = genericPath + .split("/") + // replace params with wildcard regex + .map((part) => (part.startsWith(":") ? "[^/]*" : part)) + .join("/"); + return path.match("^" + routeRegex + "[/.*]*$"); + } + + const pathSections = path.split("/"); + let route = routes.find((r) => isPathMatch(r.path, path)); + if (!route && pathSections.length > 1) { + route = routes.find((r) => isPathMatch(r.path, pathSections[0])); + } + + if (route?.children) { + const childRoute = this.getRouteDataFromRouter( + pathSections.slice(1).join("/"), + route.children, + ); + if (childRoute) { + childRoute.data = { ...route.data, ...childRoute?.data }; + route = childRoute; + } + } + + return route; + } +} diff --git a/src/app/core/permissions/permission-guard/entity-permission.guard.ts b/src/app/core/permissions/permission-guard/entity-permission.guard.ts index c799eb9f3c..a4e73806a2 100644 --- a/src/app/core/permissions/permission-guard/entity-permission.guard.ts +++ b/src/app/core/permissions/permission-guard/entity-permission.guard.ts @@ -1,33 +1,28 @@ import { Injectable } from "@angular/core"; -import { ActivatedRouteSnapshot, CanActivate, Router } from "@angular/router"; -import { RouteData } from "../../config/dynamic-routing/view-config.interface"; +import { CanActivate, Router } from "@angular/router"; import { EntityAbility } from "../ability/entity-ability"; +import { AbstractPermissionGuard } from "./abstract-permission.guard"; +import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic-component-config.interface"; /** * A guard that checks the current users permission to interact with the entity of the route. * Define `requiredPermissionOperation` in the route data / config, to enable a check that will find the relevant entity from config. */ @Injectable() -export class EntityPermissionGuard implements CanActivate { +export class EntityPermissionGuard + extends AbstractPermissionGuard + implements CanActivate +{ constructor( - private router: Router, + router: Router, private ability: EntityAbility, - ) {} - - async canActivate(route: ActivatedRouteSnapshot): Promise { - const routeData: RouteData = route.data; - if (await this.canAccessRoute(routeData)) { - return true; - } else { - if (route instanceof ActivatedRouteSnapshot) { - // Route should only change if this is a "real" navigation check (not the check in the NavigationComponent) - this.router.navigate(["/404"]); - } - return false; - } + ) { + super(router); } - private async canAccessRoute(routeData: RouteData) { + protected async canAccessRoute( + routeData: DynamicComponentConfig, + ): Promise { const operation = routeData?.["requiredPermissionOperation"] ?? "read"; const primaryEntity = routeData?.["entityType"] ?? @@ -47,23 +42,4 @@ export class EntityPermissionGuard implements CanActivate { return this.ability.can(operation, primaryEntity); } - - public checkRoutePermissions(path: string) { - // removing leading slash - path = path.replace(/^\//, ""); - - function isPathMatch(genericPath: string, path: string) { - const routeRegex = genericPath - .split("/") - // replace params with wildcard regex - .map((part) => (part.startsWith(":") ? "[^/]*" : part)) - .join("/"); - return path.match("^" + routeRegex + "$"); - } - - const routeData = this.router.config.find((r) => isPathMatch(r.path, path)) - ?.data; - - return this.canAccessRoute(routeData); - } } diff --git a/src/app/core/permissions/permission-guard/user-role.guard.spec.ts b/src/app/core/permissions/permission-guard/user-role.guard.spec.ts index 98246edda2..76f60fab41 100644 --- a/src/app/core/permissions/permission-guard/user-role.guard.spec.ts +++ b/src/app/core/permissions/permission-guard/user-role.guard.spec.ts @@ -4,8 +4,6 @@ import { UserRoleGuard } from "./user-role.guard"; import { RouterTestingModule } from "@angular/router/testing"; import { ActivatedRouteSnapshot, Route, Router } from "@angular/router"; import { AuthUser } from "../../session/auth/auth-user"; -import { ConfigService } from "../../config/config.service"; -import { PREFIX_VIEW_CONFIG } from "../../config/dynamic-routing/view-config.interface"; import { CurrentUserSubject } from "../../user/user"; describe("UserRoleGuard", () => { @@ -16,18 +14,11 @@ describe("UserRoleGuard", () => { name: "admin", roles: ["admin", "user_app"], }; - let mockConfigService: jasmine.SpyObj; beforeEach(() => { - mockConfigService = jasmine.createSpyObj(["getConfig"]); - TestBed.configureTestingModule({ imports: [RouterTestingModule], - providers: [ - CurrentUserSubject, - UserRoleGuard, - { provide: ConfigService, useValue: mockConfigService }, - ], + providers: [CurrentUserSubject, UserRoleGuard], }); guard = TestBed.inject(UserRoleGuard); userSubject = TestBed.inject(CurrentUserSubject); @@ -37,10 +28,10 @@ describe("UserRoleGuard", () => { expect(guard).toBeTruthy(); }); - it("should return true if current user is allowed", () => { + it("should return true if current user is allowed", async () => { userSubject.next(adminUser); - const result = guard.canActivate({ + const result = await guard.canActivate({ routeConfig: { path: "url" }, data: { permittedUserRoles: ["admin"] }, } as any); @@ -48,12 +39,12 @@ describe("UserRoleGuard", () => { expect(result).toBeTrue(); }); - it("should return false for a user without permissions", () => { + it("should return false for a user without permissions", async () => { userSubject.next(normalUser); const router = TestBed.inject(Router); spyOn(router, "navigate"); - const result = guard.canActivate({ + const result = await guard.canActivate({ routeConfig: { path: "url" }, data: { permittedUserRoles: ["admin"] }, } as any); @@ -62,7 +53,7 @@ describe("UserRoleGuard", () => { expect(router.navigate).not.toHaveBeenCalled(); }); - it("should navigate to 404 for real navigation requests without permissions", () => { + it("should navigate to 404 for real navigation requests without permissions", async () => { userSubject.next(normalUser); const router = TestBed.inject(Router); spyOn(router, "navigate"); @@ -72,46 +63,64 @@ describe("UserRoleGuard", () => { data: { permittedUserRoles: ["admin"] }, }); - guard.canActivate(route); + await guard.canActivate(route); expect(router.navigate).toHaveBeenCalledWith(["/404"]); }); - it("should return true if no config is set", () => { - const result = guard.canActivate({ routeConfig: { path: "url" } } as any); + it("should return true if no config is set", async () => { + const result = await guard.canActivate({ + routeConfig: { path: "url" }, + } as any); expect(result).toBeTrue(); }); - it("should check permissions of a given route (checkRoutePermissions)", () => { - mockConfigService.getConfig.and.callFake((id) => { - switch (id) { - case PREFIX_VIEW_CONFIG + "restricted": - return { permittedUserRoles: ["admin"] } as any; - case PREFIX_VIEW_CONFIG + "pathA": - return {} as any; - case PREFIX_VIEW_CONFIG + "pathA/:id": - // details view restricted - return { permittedUserRoles: ["admin"] } as any; - } + it("should check permissions of a given route (checkRoutePermissions)", async () => { + const router = TestBed.inject(Router); + router.config.push({ + path: "restricted", + data: { permittedUserRoles: ["admin"] }, + }); + router.config.push({ path: "pathA", data: {} }); + // details view restricted + router.config.push({ + path: "pathA/:id", + data: { permittedUserRoles: ["admin"] }, }); userSubject.next(normalUser); - expect(guard.checkRoutePermissions("free")).toBeTrue(); - expect(guard.checkRoutePermissions("/free")).toBeTrue(); - expect(guard.checkRoutePermissions("restricted")).toBeFalse(); - expect(guard.checkRoutePermissions("pathA")).toBeTrue(); - expect(guard.checkRoutePermissions("/pathA")).toBeTrue(); - expect(guard.checkRoutePermissions("pathA/1")).toBeFalse(); + await expectAsync(guard.checkRoutePermissions("free")).toBeResolvedTo(true); + await expectAsync(guard.checkRoutePermissions("/free")).toBeResolvedTo( + true, + ); + await expectAsync(guard.checkRoutePermissions("restricted")).toBeResolvedTo( + false, + ); + await expectAsync(guard.checkRoutePermissions("pathA")).toBeResolvedTo( + true, + ); + await expectAsync(guard.checkRoutePermissions("/pathA")).toBeResolvedTo( + true, + ); + await expectAsync(guard.checkRoutePermissions("pathA/1")).toBeResolvedTo( + false, + ); userSubject.next(adminUser); - expect(guard.checkRoutePermissions("free")).toBeTrue(); - expect(guard.checkRoutePermissions("restricted")).toBeTrue(); - expect(guard.checkRoutePermissions("pathA")).toBeTrue(); - expect(guard.checkRoutePermissions("pathA/1")).toBeTrue(); + await expectAsync(guard.checkRoutePermissions("free")).toBeResolvedTo(true); + await expectAsync(guard.checkRoutePermissions("restricted")).toBeResolvedTo( + true, + ); + await expectAsync(guard.checkRoutePermissions("pathA")).toBeResolvedTo( + true, + ); + await expectAsync(guard.checkRoutePermissions("pathA/1")).toBeResolvedTo( + true, + ); }); - it("should checkRoutePermissions considering nested child routes", () => { + it("should checkRoutePermissions considering nested child routes", async () => { const nestedRoute: Route = { path: "nested", children: [ @@ -130,15 +139,31 @@ describe("UserRoleGuard", () => { router.config.push(onParentRoute); userSubject.next(normalUser); - expect(guard.checkRoutePermissions("nested")).toBeFalse(); - expect(guard.checkRoutePermissions("nested/X")).toBeTrue(); - expect(guard.checkRoutePermissions("on-parent")).toBeFalse(); - expect(guard.checkRoutePermissions("on-parent/X")).toBeFalse(); + await expectAsync(guard.checkRoutePermissions("nested")).toBeResolvedTo( + false, + ); + await expectAsync(guard.checkRoutePermissions("nested/X")).toBeResolvedTo( + true, + ); + await expectAsync(guard.checkRoutePermissions("on-parent")).toBeResolvedTo( + false, + ); + await expectAsync( + guard.checkRoutePermissions("on-parent/X"), + ).toBeResolvedTo(false); userSubject.next(adminUser); - expect(guard.checkRoutePermissions("nested")).toBeTrue(); - expect(guard.checkRoutePermissions("nested/X")).toBeTrue(); - expect(guard.checkRoutePermissions("on-parent")).toBeTrue(); - expect(guard.checkRoutePermissions("on-parent/X")).toBeTrue(); + await expectAsync(guard.checkRoutePermissions("nested")).toBeResolvedTo( + true, + ); + await expectAsync(guard.checkRoutePermissions("nested/X")).toBeResolvedTo( + true, + ); + await expectAsync(guard.checkRoutePermissions("on-parent")).toBeResolvedTo( + true, + ); + await expectAsync( + guard.checkRoutePermissions("on-parent/X"), + ).toBeResolvedTo(true); }); }); diff --git a/src/app/core/permissions/permission-guard/user-role.guard.ts b/src/app/core/permissions/permission-guard/user-role.guard.ts index dcca95d56e..b25b3865b5 100644 --- a/src/app/core/permissions/permission-guard/user-role.guard.ts +++ b/src/app/core/permissions/permission-guard/user-role.guard.ts @@ -1,113 +1,33 @@ import { Injectable } from "@angular/core"; -import { - ActivatedRouteSnapshot, - CanActivate, - Route, - Router, -} from "@angular/router"; -import { - PREFIX_VIEW_CONFIG, - RouteData, - ViewConfig, -} from "../../config/dynamic-routing/view-config.interface"; -import { AuthUser } from "../../session/auth/auth-user"; -import { ConfigService } from "../../config/config.service"; +import { Router } from "@angular/router"; import { CurrentUserSubject } from "../../user/user"; +import { AbstractPermissionGuard } from "./abstract-permission.guard"; +import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic-component-config.interface"; /** * A guard that checks the roles of the current user against the permissions which are saved in the route data. */ @Injectable() -export class UserRoleGuard implements CanActivate { +export class UserRoleGuard extends AbstractPermissionGuard { constructor( + router: Router, private currentUser: CurrentUserSubject, - private router: Router, - private configService: ConfigService, - ) {} + ) { + super(router); + } - canActivate(route: ActivatedRouteSnapshot): boolean { - const routeData: RouteData = route.data; + protected async canAccessRoute( + routeData: DynamicComponentConfig, + ): Promise { + const permittedRoles = routeData?.permittedUserRoles; const user = this.currentUser.value; - if (this.canAccessRoute(routeData?.permittedUserRoles, user)) { - return true; - } else { - if (route instanceof ActivatedRouteSnapshot) { - // Route should only change if this is a "real" navigation check (not the check in the NavigationComponent) - this.router.navigate(["/404"]); - } - return false; - } - } - private canAccessRoute(permittedRoles: string[], user: AuthUser) { if (permittedRoles?.length > 0) { // Check if user has a role which is in the list of permitted roles - return permittedRoles.some((role) => user?.roles.includes(role)); + return permittedRoles.some((role) => user.roles.includes(role)); } else { // No config set => all users are allowed return true; } } - - public checkRoutePermissions(path: string) { - // removing leading slash - path = path.replace(/^\//, ""); - - let viewConfig = this.getRouteConfig(path); - - if (!viewConfig) { - // search for details route ("path/:id" for any id) - const detailsPath = path.replace(/\/[^\/]*$/, "/:id"); - viewConfig = this.getRouteConfig(detailsPath); - } - - return this.canActivate({ - routeConfig: { path: path }, - data: { permittedUserRoles: viewConfig?.permittedUserRoles }, - } as any); - } - - /** - * Find the relevant ViewConfig from config or already registered routes - * @param path - * @private - */ - private getRouteConfig(path: string): ViewConfig { - const viewConfig = this.configService.getConfig( - PREFIX_VIEW_CONFIG + path, - ); - if (viewConfig) { - return viewConfig; - } - - let route = this.getRouteDataFromRouter(path, this.router.config); - return route?.data as ViewConfig; - } - - /** - * Extract the relevant route from Router, to get a merged route that contains the full trail of `permittedRoles` - * @param path - * @param routes - * @private - */ - private getRouteDataFromRouter(path: string, routes: Route[]) { - const pathSections = path.split("/"); - let route = routes.find((r) => r.path === path); - if (!route && pathSections.length > 1) { - route = routes.find((r) => r.path === pathSections[0]); - } - - if (route?.children) { - const childRoute = this.getRouteDataFromRouter( - pathSections.slice(1).join("/"), - route.children, - ); - if (childRoute) { - childRoute.data = { ...route.data, ...childRoute?.data }; - route = childRoute; - } - } - - return route; - } } diff --git a/src/app/core/ui/navigation/menu-item.ts b/src/app/core/ui/navigation/menu-item.ts index 51fb366efe..b8ad56dee8 100644 --- a/src/app/core/ui/navigation/menu-item.ts +++ b/src/app/core/ui/navigation/menu-item.ts @@ -18,16 +18,17 @@ /** * Structure for menu items to be displayed. */ -export class MenuItem { +export interface MenuItem { /** - * Create a menu item. - * @param label The text to be displayed in the menu. - * @param icon The icon to be displayed left of the label. - * @param link The url fragment to which the item will route to (e.g. '/dashboard') + * The text to be displayed in the menu. */ - constructor( - public label: string, - public icon: string, - public link: string, - ) {} + label: string; + /** + * The icon to be displayed left of the label. + */ + icon: string; + /** + * The url fragment to which the item will route to (e.g. '/dashboard') + */ + link: string; } diff --git a/src/app/core/ui/navigation/navigation/navigation.component.spec.ts b/src/app/core/ui/navigation/navigation/navigation.component.spec.ts index 19c82b95a3..38a8463c2f 100644 --- a/src/app/core/ui/navigation/navigation/navigation.component.spec.ts +++ b/src/app/core/ui/navigation/navigation/navigation.component.spec.ts @@ -24,7 +24,6 @@ import { } from "@angular/core/testing"; import { NavigationComponent } from "./navigation.component"; -import { MenuItem } from "../menu-item"; import { ConfigService } from "../../../config/config.service"; import { BehaviorSubject, Subject } from "rxjs"; import { Config } from "../../../config/config"; @@ -50,7 +49,7 @@ describe("NavigationComponent", () => { mockConfigService.getConfig.and.returnValue({ items: [] }); mockConfigService.getAllConfigs.and.returnValue([]); mockRoleGuard = jasmine.createSpyObj(["checkRoutePermissions"]); - mockRoleGuard.checkRoutePermissions.and.returnValue(true); + mockRoleGuard.checkRoutePermissions.and.resolveTo(true); mockEntityGuard = jasmine.createSpyObj(["checkRoutePermissions"]); mockEntityGuard.checkRoutePermissions.and.resolveTo(true); @@ -86,8 +85,8 @@ describe("NavigationComponent", () => { tick(); expect(component.menuItems).toEqual([ - new MenuItem("Dashboard", "home", "/dashboard"), - new MenuItem("Children", "child", "/child"), + { label: "Dashboard", icon: "home", link: "/dashboard" }, + { label: "Children", icon: "child", link: "/child" }, ]); })); @@ -98,7 +97,7 @@ describe("NavigationComponent", () => { { name: "Children", icon: "child", link: "/child" }, ], }; - mockRoleGuard.checkRoutePermissions.and.callFake((route: string) => { + mockRoleGuard.checkRoutePermissions.and.callFake(async (route: string) => { switch (route) { case "/dashboard": return false; @@ -114,11 +113,11 @@ describe("NavigationComponent", () => { tick(); expect(component.menuItems).toEqual([ - new MenuItem("Children", "child", "/child"), + { label: "Children", icon: "child", link: "/child" }, ]); })); - it("should not add menu items if entity permissions are missing", fakeAsync(() => { + it("should add menu items where entity permissions are missing", fakeAsync(() => { const testConfig = { items: [ { name: "Dashboard", icon: "home", link: "/dashboard" }, @@ -141,7 +140,7 @@ describe("NavigationComponent", () => { tick(); expect(component.menuItems).toEqual([ - new MenuItem("Children", "child", "/child"), + { label: "Children", icon: "child", link: "/child" }, ]); })); diff --git a/src/app/core/ui/navigation/navigation/navigation.component.ts b/src/app/core/ui/navigation/navigation/navigation.component.ts index 0519f71c3f..e3965c9530 100644 --- a/src/app/core/ui/navigation/navigation/navigation.component.ts +++ b/src/app/core/ui/navigation/navigation/navigation.component.ts @@ -19,7 +19,6 @@ import { Component } from "@angular/core"; import { MenuItem } from "../menu-item"; import { NavigationMenuConfig } from "../navigation-menu-config.interface"; import { ConfigService } from "../../../config/config.service"; -import { UserRoleGuard } from "../../../permissions/permission-guard/user-role.guard"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { NavigationEnd, Router, RouterLink } from "@angular/router"; import { filter, startWith } from "rxjs/operators"; @@ -27,7 +26,7 @@ import { MatListModule } from "@angular/material/list"; import { NgForOf } from "@angular/common"; import { Angulartics2Module } from "angulartics2"; import { FaDynamicIconComponent } from "../../../common-components/fa-dynamic-icon/fa-dynamic-icon.component"; -import { EntityPermissionGuard } from "../../../permissions/permission-guard/entity-permission.guard"; +import { RoutePermissionsService } from "../../../config/dynamic-routing/route-permissions.service"; /** * Main app menu listing. @@ -57,8 +56,7 @@ export class NavigationComponent { constructor( private configService: ConfigService, private router: Router, - private userRoleGuard: UserRoleGuard, - private entityPermissionGuard: EntityPermissionGuard, + private routePermissionService: RoutePermissionsService, ) { this.configService.configUpdates .pipe(untilDestroyed(this)) @@ -117,20 +115,13 @@ export class NavigationComponent { private async initMenuItemsFromConfig() { const config: NavigationMenuConfig = this.configService.getConfig(this.CONFIG_ID); - const menuItems = []; - for (const item of config.items) { - if (await this.isAccessibleRouteForUser(item.link)) { - menuItems.push(new MenuItem(item.name, item.icon, item.link)); - } - } - this.menuItems = menuItems; - this.activeLink = this.computeActiveLink(location.pathname); - } - - private isAccessibleRouteForUser(path: string) { - return ( - this.userRoleGuard.checkRoutePermissions(path) && - this.entityPermissionGuard.checkRoutePermissions(path) - ); + // TODO align interface {@link https://github.com/Aam-Digital/ndb-core/issues/2066} + const items: MenuItem[] = config.items.map(({ name, icon, link }) => ({ + label: name, + icon, + link, + })); + this.menuItems = + await this.routePermissionService.filterPermittedRoutes(items); } } diff --git a/src/app/features/dashboard-widgets/birthday-dashboard-widget/birthday-dashboard/birthday-dashboard.component.ts b/src/app/features/dashboard-widgets/birthday-dashboard-widget/birthday-dashboard/birthday-dashboard.component.ts index 3da611bbd1..69610e9273 100644 --- a/src/app/features/dashboard-widgets/birthday-dashboard-widget/birthday-dashboard/birthday-dashboard.component.ts +++ b/src/app/features/dashboard-widgets/birthday-dashboard-widget/birthday-dashboard/birthday-dashboard.component.ts @@ -15,6 +15,12 @@ import { DatePipe, NgIf } from "@angular/common"; import { DisplayEntityComponent } from "../../../../core/basic-datatypes/entity/display-entity/display-entity.component"; import { DashboardWidgetComponent } from "../../../../core/dashboard/dashboard-widget/dashboard-widget.component"; import { WidgetContentComponent } from "../../../../core/dashboard/dashboard-widget/widget-content/widget-content.component"; +import { DashboardWidget } from "../../../../core/dashboard/dashboard-widget/dashboard-widget"; + +interface BirthdayDashboardConfig { + entities: EntityPropertyMap; + threshold: number; +} @DynamicComponent("BirthdayDashboard") @Component({ @@ -32,7 +38,14 @@ import { WidgetContentComponent } from "../../../../core/dashboard/dashboard-wid ], standalone: true, }) -export class BirthdayDashboardComponent implements OnInit, AfterViewInit { +export class BirthdayDashboardComponent + extends DashboardWidget + implements BirthdayDashboardConfig, OnInit, AfterViewInit +{ + static getRequiredEntities(config: BirthdayDashboardConfig) { + return config?.entities ? Object.keys(config.entities) : Child.ENTITY_TYPE; + } + @ViewChild(MatPaginator) paginator: MatPaginator; private readonly today: Date; @@ -55,6 +68,7 @@ export class BirthdayDashboardComponent implements OnInit, AfterViewInit { isLoading = true; constructor(private entityMapper: EntityMapperService) { + super(); this.today = new Date(); this.today.setHours(0, 0, 0, 0); } diff --git a/src/app/features/dashboard-widgets/entity-count-dashboard-widget/entity-count-dashboard/entity-count-dashboard.component.ts b/src/app/features/dashboard-widgets/entity-count-dashboard-widget/entity-count-dashboard/entity-count-dashboard.component.ts index 9791e2b286..9c05d36369 100644 --- a/src/app/features/dashboard-widgets/entity-count-dashboard-widget/entity-count-dashboard/entity-count-dashboard.component.ts +++ b/src/app/features/dashboard-widgets/entity-count-dashboard-widget/entity-count-dashboard/entity-count-dashboard.component.ts @@ -15,6 +15,12 @@ import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { Angulartics2Module } from "angulartics2"; import { groupBy } from "../../../../utils/utils"; import { DashboardListWidgetComponent } from "../../../../core/dashboard/dashboard-list-widget/dashboard-list-widget.component"; +import { DashboardWidget } from "../../../../core/dashboard/dashboard-widget/dashboard-widget"; + +interface EntityCountDashboardConfig { + entity?: string; + groupBy?: string; +} @DynamicComponent("ChildrenCountDashboard") @DynamicComponent("EntityCountDashboard") @@ -30,7 +36,14 @@ import { DashboardListWidgetComponent } from "../../../../core/dashboard/dashboa ], standalone: true, }) -export class EntityCountDashboardComponent implements OnInit { +export class EntityCountDashboardComponent + extends DashboardWidget + implements EntityCountDashboardConfig, OnInit +{ + static getRequiredEntities(config: EntityCountDashboardConfig) { + return config?.entity || Child.ENTITY_TYPE; + } + /** * Entity name which should be grouped * @param value @@ -56,7 +69,9 @@ export class EntityCountDashboardComponent implements OnInit { private entityMapper: EntityMapperService, private router: Router, private entities: EntityRegistry, - ) {} + ) { + super(); + } async ngOnInit() { const entities = await this.entityMapper.loadType(this._entity); diff --git a/src/app/features/dashboard-widgets/progress-dashboard-widget/progress-dashboard/progress-dashboard.component.ts b/src/app/features/dashboard-widgets/progress-dashboard-widget/progress-dashboard/progress-dashboard.component.ts index e7a52281a1..71d7268521 100644 --- a/src/app/features/dashboard-widgets/progress-dashboard-widget/progress-dashboard/progress-dashboard.component.ts +++ b/src/app/features/dashboard-widgets/progress-dashboard-widget/progress-dashboard/progress-dashboard.component.ts @@ -16,6 +16,7 @@ import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { DashboardWidgetComponent } from "../../../../core/dashboard/dashboard-widget/dashboard-widget.component"; import { WidgetContentComponent } from "../../../../core/dashboard/dashboard-widget/widget-content/widget-content.component"; import { SyncStateSubject } from "../../../../core/session/session-type"; +import { DashboardWidget } from "../../../../core/dashboard/dashboard-widget/dashboard-widget"; @Component({ selector: "app-progress-dashboard", @@ -33,7 +34,14 @@ import { SyncStateSubject } from "../../../../core/session/session-type"; standalone: true, }) @DynamicComponent("ProgressDashboard") -export class ProgressDashboardComponent implements OnInit { +export class ProgressDashboardComponent + extends DashboardWidget + implements OnInit +{ + static getRequiredEntities() { + return ProgressDashboardConfig.ENTITY_TYPE; + } + @Input() dashboardConfigId = ""; data: ProgressDashboardConfig; @@ -42,7 +50,9 @@ export class ProgressDashboardComponent implements OnInit { private loggingService: LoggingService, private dialog: MatDialog, private syncState: SyncStateSubject, - ) {} + ) { + super(); + } async ngOnInit() { this.data = new ProgressDashboardConfig(this.dashboardConfigId); diff --git a/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.html b/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.html index 0c13ba6d0a..e0e0e6c99f 100644 --- a/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.html +++ b/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.html @@ -6,7 +6,7 @@ Title of dashboard widget that shows a list of certain actions a user can click on " - [entries]="shortcuts" + [entries]="_shortcuts" >
diff --git a/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.spec.ts b/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.spec.ts index f024a68fb6..65c8e77194 100644 --- a/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.spec.ts +++ b/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.spec.ts @@ -1,16 +1,32 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; import { ShortcutDashboardComponent } from "./shortcut-dashboard.component"; import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; import { EntityMapperService } from "../../../../core/entity/entity-mapper/entity-mapper.service"; +import { UserRoleGuard } from "../../../../core/permissions/permission-guard/user-role.guard"; +import { EntityPermissionGuard } from "../../../../core/permissions/permission-guard/entity-permission.guard"; +import { MenuItem } from "../../../../core/ui/navigation/menu-item"; describe("ShortcutDashboardComponent", () => { let component: ShortcutDashboardComponent; let fixture: ComponentFixture; + let mockRoleGuard: jasmine.SpyObj; + let mockPermissionGuard: jasmine.SpyObj; beforeEach(async () => { + mockRoleGuard = jasmine.createSpyObj(["checkRoutePermissions"]); + mockPermissionGuard = jasmine.createSpyObj(["checkRoutePermissions"]); await TestBed.configureTestingModule({ imports: [ShortcutDashboardComponent, FontAwesomeTestingModule], - providers: [{ provide: EntityMapperService, useValue: undefined }], + providers: [ + { provide: EntityMapperService, useValue: undefined }, + { provide: UserRoleGuard, useValue: mockRoleGuard }, + { provide: EntityPermissionGuard, useValue: mockPermissionGuard }, + ], }).compileComponents(); }); @@ -23,4 +39,31 @@ describe("ShortcutDashboardComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("should only show routes to which the user has access", fakeAsync(() => { + mockRoleGuard.checkRoutePermissions.and.callFake(async (route) => { + switch (route) { + case "/child": + return true; + case "/school": + return false; + } + }); + mockPermissionGuard.checkRoutePermissions.and.resolveTo(true); + const childItem: MenuItem = { + label: "Children", + icon: "child", + link: "/child", + }; + const schoolItem: MenuItem = { + label: "School", + icon: "building", + link: "/school", + }; + + component.shortcuts = [childItem, schoolItem]; + tick(); + + expect(component.shortcuts).toEqual([childItem]); + })); }); diff --git a/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.ts b/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.ts index fbb7867367..8cc85cd487 100644 --- a/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.ts +++ b/src/app/features/dashboard-widgets/shortcut-dashboard-widget/shortcut-dashboard/shortcut-dashboard.component.ts @@ -5,6 +5,7 @@ import { DynamicComponent } from "../../../../core/config/dynamic-components/dyn import { FaDynamicIconComponent } from "../../../../core/common-components/fa-dynamic-icon/fa-dynamic-icon.component"; import { RouterLink } from "@angular/router"; import { DashboardListWidgetComponent } from "../../../../core/dashboard/dashboard-list-widget/dashboard-list-widget.component"; +import { RoutePermissionsService } from "../../../../core/config/dynamic-routing/route-permissions.service"; /** * A simple list of shortcuts displayed as a dashboard widget for easy access to important navigation. @@ -24,5 +25,15 @@ import { DashboardListWidgetComponent } from "../../../../core/dashboard/dashboa }) export class ShortcutDashboardComponent { /** displayed entries, each representing one line displayed as a shortcut */ - @Input() shortcuts: MenuItem[] = []; + @Input() set shortcuts(items: MenuItem[]) { + this.routePermissionsService + .filterPermittedRoutes(items) + .then((res) => (this._shortcuts = res)); + } + get shortcuts(): MenuItem[] { + return this._shortcuts; + } + _shortcuts: MenuItem[] = []; + + constructor(private routePermissionsService: RoutePermissionsService) {} } diff --git a/src/app/features/markdown-page/markdown-page/markdown-page.component.spec.ts b/src/app/features/markdown-page/markdown-page/markdown-page.component.spec.ts index d5ea516c24..84a04210c7 100644 --- a/src/app/features/markdown-page/markdown-page/markdown-page.component.spec.ts +++ b/src/app/features/markdown-page/markdown-page/markdown-page.component.spec.ts @@ -4,7 +4,7 @@ import { MarkdownPageComponent } from "./markdown-page.component"; import { ActivatedRoute } from "@angular/router"; import { BehaviorSubject } from "rxjs"; import { MarkdownPageConfig } from "../MarkdownPageConfig"; -import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; +import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface"; import { MarkdownPageModule } from "../markdown-page.module"; import { ComponentRegistry } from "../../../dynamic-components"; @@ -12,7 +12,9 @@ describe("MarkdownPageComponent", () => { let component: MarkdownPageComponent; let fixture: ComponentFixture; - let mockRouteData: BehaviorSubject>; + let mockRouteData: BehaviorSubject< + DynamicComponentConfig + >; beforeEach(waitForAsync(() => { mockRouteData = new BehaviorSubject({ diff --git a/src/app/features/matching-entities/matching-entities/matching-entities.component.spec.ts b/src/app/features/matching-entities/matching-entities/matching-entities.component.spec.ts index fce940b0a5..1ade62f967 100644 --- a/src/app/features/matching-entities/matching-entities/matching-entities.component.spec.ts +++ b/src/app/features/matching-entities/matching-entities/matching-entities.component.spec.ts @@ -21,14 +21,14 @@ import { FormFieldConfig } from "../../../core/common-components/entity-form/ent import { Coordinates } from "../../location/coordinates"; import { MockedTestingModule } from "../../../utils/mocked-testing.module"; import { School } from "../../../child-dev-project/schools/model/school"; -import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; +import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface"; import { Note } from "../../../child-dev-project/notes/model/note"; describe("MatchingEntitiesComponent", () => { let component: MatchingEntitiesComponent; let fixture: ComponentFixture; - let routeData: Subject>; + let routeData: Subject>; let mockConfigService: jasmine.SpyObj; let testConfig: MatchingEntitiesConfig = { diff --git a/src/app/features/matching-entities/matching-entities/matching-entities.component.ts b/src/app/features/matching-entities/matching-entities/matching-entities.component.ts index 9e20f85282..247c614730 100644 --- a/src/app/features/matching-entities/matching-entities/matching-entities.component.ts +++ b/src/app/features/matching-entities/matching-entities/matching-entities.component.ts @@ -19,7 +19,7 @@ import { ColumnConfig, DataFilter, } from "../../../core/common-components/entity-subrecord/entity-subrecord/entity-subrecord-config"; -import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; +import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface"; import { ActivatedRoute } from "@angular/router"; import { FormDialogService } from "../../../core/form-dialog/form-dialog.service"; import { addAlphaToHexColor } from "../../../utils/style-utils"; @@ -127,16 +127,18 @@ export class MatchingEntitiesComponent implements OnInit { ) ?? {}; Object.assign(this, JSON.parse(JSON.stringify(config))); - this.route.data.subscribe((data: RouteData) => { - if ( - !data?.config?.leftSide && - !data?.config?.rightSide && - !data?.config?.columns - ) { - return; - } - Object.assign(this, JSON.parse(JSON.stringify(data.config))); - }); + this.route.data.subscribe( + (data: DynamicComponentConfig) => { + if ( + !data?.config?.leftSide && + !data?.config?.rightSide && + !data?.config?.columns + ) { + return; + } + Object.assign(this, JSON.parse(JSON.stringify(data.config))); + }, + ); } // TODO: fill selection on hover already? diff --git a/src/app/features/todos/todo-list/todo-list.component.ts b/src/app/features/todos/todo-list/todo-list.component.ts index c1d35d9501..557aed8dda 100644 --- a/src/app/features/todos/todo-list/todo-list.component.ts +++ b/src/app/features/todos/todo-list/todo-list.component.ts @@ -5,7 +5,7 @@ import { EntityListConfig, PrebuiltFilterConfig, } from "../../../core/entity-list/EntityListConfig"; -import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; +import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface"; import { FormDialogService } from "../../../core/form-dialog/form-dialog.service"; import { TodoDetailsComponent } from "../todo-details/todo-details.component"; import { LoggingService } from "../../../core/logging/logging.service"; @@ -45,9 +45,10 @@ export class TodoListComponent implements OnInit { ) {} ngOnInit() { - this.route.data.subscribe((data: RouteData) => - // TODO replace this use of route and rely on the RoutedViewComponent instead - this.init(data.config), + this.route.data.subscribe( + (data: DynamicComponentConfig) => + // TODO replace this use of route and rely on the RoutedViewComponent instead + this.init(data.config), ); } diff --git a/src/app/features/todos/todos-dashboard/todos-dashboard.component.ts b/src/app/features/todos/todos-dashboard/todos-dashboard.component.ts index d3dc6064df..ba7e0438a2 100644 --- a/src/app/features/todos/todos-dashboard/todos-dashboard.component.ts +++ b/src/app/features/todos/todos-dashboard/todos-dashboard.component.ts @@ -9,6 +9,7 @@ import { DatePipe, NgStyle } from "@angular/common"; import { MatTableModule } from "@angular/material/table"; import { MatTooltipModule } from "@angular/material/tooltip"; import { CurrentUserSubject } from "../../../core/user/user"; +import { DashboardWidget } from "../../../core/dashboard/dashboard-widget/dashboard-widget"; @DynamicComponent("TodosDashboard") @Component({ @@ -24,7 +25,11 @@ import { CurrentUserSubject } from "../../../core/user/user"; DatePipe, ], }) -export class TodosDashboardComponent { +export class TodosDashboardComponent extends DashboardWidget { + static getRequiredEntities() { + return Todo.ENTITY_TYPE; + } + dataMapper: (data: Todo[]) => Todo[] = (data) => data.filter(this.filterEntries).sort(this.sortEntries); @@ -33,7 +38,9 @@ export class TodosDashboardComponent { constructor( private formDialog: FormDialogService, private currentUser: CurrentUserSubject, - ) {} + ) { + super(); + } filterEntries = (todo: Todo) => { return (