Skip to content

Commit

Permalink
feat: Dashboard permissions (#2131)
Browse files Browse the repository at this point in the history
closes #2122 #1552

Co-authored-by: Sebastian Leidig <sebastian@aam-digital.com>
  • Loading branch information
TheSlimvReal and sleidig authored Dec 19, 2023
1 parent 8621859 commit 4f0daf9
Show file tree
Hide file tree
Showing 32 changed files with 699 additions and 316 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down Expand Up @@ -89,7 +98,9 @@ export class AttendanceWeekDashboardComponent implements OnInit, AfterViewInit {
constructor(
private attendanceService: AttendanceService,
private router: Router,
) {}
) {
super();
}

ngOnInit() {
if (this.periodLabel && !this.label) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<EntityListConfig>) => (this.listConfig = data.config),
(data: DynamicComponentConfig<EntityListConfig>) =>
(this.listConfig = data.config),
);
this.childrenList = await this.childrenService.getChildren();
this.isLoading = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<BmiRow>();
isLoading = true;
entityLabelPlural: string = Child.labelPlural;
@ViewChild("paginator") paginator: MatPaginator;

constructor(private entityMapper: EntityMapperService) {}
constructor(private entityMapper: EntityMapperService) {
super();
}

ngOnInit() {
return this.loadBMIData();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
*/
Expand Down Expand Up @@ -73,7 +89,9 @@ export class NotesDashboardComponent implements OnInit, AfterViewInit {
constructor(
private childrenService: ChildrenService,
private entities: EntityRegistry,
) {}
) {
super();
}

ngOnInit() {
let dayRangeBoundary = this.sinceDays;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -98,7 +98,9 @@ export class NotesManagerComponent implements OnInit {

async ngOnInit() {
this.route.data.subscribe(
async (data: RouteData<EntityListConfig & NotesManagerConfig>) => {
async (
data: DynamicComponentConfig<EntityListConfig & NotesManagerConfig>,
) => {
// TODO replace this use of route and rely on the RoutedViewComponent instead
this.config = data.config;
this.addPrebuiltFilters();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T = any> {
/**
* 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[];
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
37 changes: 37 additions & 0 deletions src/app/core/config/dynamic-routing/route-permissions.service.ts
Original file line number Diff line number Diff line change
@@ -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<MenuItem[]> {
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))
);
}
}
50 changes: 3 additions & 47 deletions src/app/core/config/dynamic-routing/view-config.interface.ts
Original file line number Diff line number Diff line change
@@ -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<T = any> {
export interface ViewConfig<T = any> extends DynamicComponentConfig<T> {
/** 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.
*
Expand All @@ -38,30 +21,3 @@ export interface ViewConfig<T = any> {
* 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<T = any> {
/**
* 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;
}
15 changes: 15 additions & 0 deletions src/app/core/dashboard/dashboard-widget/dashboard-widget.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 4f0daf9

Please sign in to comment.