From b607cff5f9f21ddf824b09decb91164440584f3a Mon Sep 17 00:00:00 2001 From: Dan Percic Date: Fri, 30 Sep 2022 11:08:51 +0300 Subject: [PATCH] wip permissions --- src/index.ts | 1 + src/lib/permissions/index.ts | 9 + .../permissions/models/permission.model.ts | 6 + .../models/permissions-router-data.model.ts | 32 ++ src/lib/permissions/models/role.model.ts | 6 + src/lib/permissions/permissions.directive.ts | 205 +++++++++++ src/lib/permissions/permissions.module.ts | 23 ++ .../services/permissions-guard.service.ts | 318 ++++++++++++++++++ .../services/permissions.service.ts | 125 +++++++ src/lib/permissions/services/roles.service.ts | 137 ++++++++ src/lib/permissions/utils.ts | 38 +++ 11 files changed, 900 insertions(+) create mode 100644 src/lib/permissions/index.ts create mode 100644 src/lib/permissions/models/permission.model.ts create mode 100644 src/lib/permissions/models/permissions-router-data.model.ts create mode 100644 src/lib/permissions/models/role.model.ts create mode 100644 src/lib/permissions/permissions.directive.ts create mode 100644 src/lib/permissions/permissions.module.ts create mode 100644 src/lib/permissions/services/permissions-guard.service.ts create mode 100644 src/lib/permissions/services/permissions.service.ts create mode 100644 src/lib/permissions/services/roles.service.ts create mode 100644 src/lib/permissions/utils.ts diff --git a/src/index.ts b/src/index.ts index 29c942f..7ab9455 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,3 +21,4 @@ export * from './lib/caching'; export * from './lib/users'; export * from './lib/translations'; export * from './lib/standalone'; +export * from './lib/permissions'; diff --git a/src/lib/permissions/index.ts b/src/lib/permissions/index.ts new file mode 100644 index 0000000..a6d3705 --- /dev/null +++ b/src/lib/permissions/index.ts @@ -0,0 +1,9 @@ +export * from './models/permission.model'; +export * from './models/permissions-router-data.model'; +export * from './models/role.model'; +export * from './services/permissions-guard.service'; +export * from './services/permissions.service'; +export * from './services/roles.service'; +export * from './permissions.directive'; +export * from './permissions.module'; +export * from './utils'; diff --git a/src/lib/permissions/models/permission.model.ts b/src/lib/permissions/models/permission.model.ts new file mode 100644 index 0000000..0c4ed8c --- /dev/null +++ b/src/lib/permissions/models/permission.model.ts @@ -0,0 +1,6 @@ +import { ValidationFn } from './permissions-router-data.model'; + +export interface IqserPermission { + name: string; + validationFunction?: ValidationFn; +} diff --git a/src/lib/permissions/models/permissions-router-data.model.ts b/src/lib/permissions/models/permissions-router-data.model.ts new file mode 100644 index 0000000..a937de6 --- /dev/null +++ b/src/lib/permissions/models/permissions-router-data.model.ts @@ -0,0 +1,32 @@ +import { ActivatedRouteSnapshot, NavigationExtras, Route, RouterStateSnapshot } from '@angular/router'; + +export interface IqserPermissionsRouterData { + only?: string | string[] | OnlyFn; + except?: string | string[] | ExceptFn; + redirectTo?: RedirectTo | RedirectToFn; +} + +export interface IqserRedirectToNavigationParameters { + navigationCommands: any[] | NavigationCommandsFn; + navigationExtras?: NavigationExtras | NavigationExtrasFn; +} + +export type OnlyFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => string | string[]; +export type ExceptFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => string | string[]; + +export type RedirectTo = + | string + | { [name: string]: IqserRedirectToNavigationParameters | string | RedirectToFn } + | IqserRedirectToNavigationParameters; + +export type RedirectToFn = ( + rejectedPermissionName?: string, + route?: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, +) => RedirectTo; + +export type NavigationCommandsFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => any[]; +export type NavigationExtrasFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => NavigationExtras; +export type ValidationFn = (name?: string, store?: any) => Promise | boolean | string[]; + +export const DEFAULT_REDIRECT_KEY = 'default'; diff --git a/src/lib/permissions/models/role.model.ts b/src/lib/permissions/models/role.model.ts new file mode 100644 index 0000000..5a5a84d --- /dev/null +++ b/src/lib/permissions/models/role.model.ts @@ -0,0 +1,6 @@ +import { ValidationFn } from './permissions-router-data.model'; + +export interface IqserRole { + name: string; + validationFunction: ValidationFn | string[]; +} diff --git a/src/lib/permissions/permissions.directive.ts b/src/lib/permissions/permissions.directive.ts new file mode 100644 index 0000000..26d6d95 --- /dev/null +++ b/src/lib/permissions/permissions.directive.ts @@ -0,0 +1,205 @@ +import { + ChangeDetectorRef, + Directive, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; + +import { merge, Subscription } from 'rxjs'; +import { skip, take } from 'rxjs/operators'; + +import { isBoolean, notEmptyValue } from './utils'; +import { IqserRolesService } from './services/roles.service'; +import { IqserPermissionsService } from './services/permissions.service'; + +@Directive({ + selector: '[ngxPermissionsOnly],[ngxPermissionsExcept]', +}) +export class NgxPermissionsDirective implements OnInit, OnDestroy, OnChanges { + @Input() ngxPermissionsOnly!: string | string[]; + @Input() ngxPermissionsOnlyThen!: TemplateRef; + @Input() ngxPermissionsOnlyElse!: TemplateRef; + + @Input() ngxPermissionsExcept!: string | string[]; + @Input() ngxPermissionsExceptElse!: TemplateRef; + @Input() ngxPermissionsExceptThen!: TemplateRef; + + @Input() ngxPermissionsThen!: TemplateRef; + @Input() ngxPermissionsElse!: TemplateRef; + + @Output() permissionsAuthorized = new EventEmitter(); + @Output() permissionsUnauthorized = new EventEmitter(); + + private initPermissionSubscription?: Subscription; + // skip first run cause merge will fire twice + private firstMergeUnusedRun = 1; + private currentAuthorizedState?: boolean; + + constructor( + private permissionsService: IqserPermissionsService, + private rolesService: IqserRolesService, + private viewContainer: ViewContainerRef, + private changeDetector: ChangeDetectorRef, + private templateRef: TemplateRef, + ) {} + + ngOnInit(): void { + this.viewContainer.clear(); + this.initPermissionSubscription = this.validateExceptOnlyPermissions(); + } + + ngOnChanges(changes: SimpleChanges): void { + const onlyChanges = changes['ngxPermissionsOnly']; + const exceptChanges = changes['ngxPermissionsExcept']; + if (onlyChanges || exceptChanges) { + // Due to bug when you pass empty array + if (onlyChanges && onlyChanges.firstChange) { + return; + } + if (exceptChanges && exceptChanges.firstChange) { + return; + } + + merge(this.permissionsService.permissions$, this.rolesService.roles$) + .pipe(skip(this.firstMergeUnusedRun), take(1)) + .subscribe(() => { + if (notEmptyValue(this.ngxPermissionsExcept)) { + this.validateExceptAndOnlyPermissions(); + return; + } + + if (notEmptyValue(this.ngxPermissionsOnly)) { + this.validateOnlyPermissions(); + return; + } + + this.handleAuthorisedPermission(this.getAuthorisedTemplates()); + }); + } + } + + ngOnDestroy(): void { + if (this.initPermissionSubscription) { + this.initPermissionSubscription.unsubscribe(); + } + } + + private validateExceptOnlyPermissions(): Subscription { + return merge(this.permissionsService.permissions$, this.rolesService.roles$) + .pipe(skip(this.firstMergeUnusedRun)) + .subscribe(() => { + if (notEmptyValue(this.ngxPermissionsExcept)) { + this.validateExceptAndOnlyPermissions(); + return; + } + + if (notEmptyValue(this.ngxPermissionsOnly)) { + this.validateOnlyPermissions(); + return; + } + this.handleAuthorisedPermission(this.getAuthorisedTemplates()); + }); + } + + private validateExceptAndOnlyPermissions(): void { + Promise.all([ + this.permissionsService.hasPermission(this.ngxPermissionsExcept), + this.rolesService.hasOnlyRoles(this.ngxPermissionsExcept), + ]) + .then(([hasPermission, hasRole]) => { + if (hasPermission || hasRole) { + this.handleUnauthorisedPermission(this.ngxPermissionsExceptElse || this.ngxPermissionsElse); + return; + } + + if (!!this.ngxPermissionsOnly) { + throw false; + } + + this.handleAuthorisedPermission(this.ngxPermissionsExceptThen || this.ngxPermissionsThen || this.templateRef); + }) + .catch(() => { + if (!!this.ngxPermissionsOnly) { + this.validateOnlyPermissions(); + } else { + this.handleAuthorisedPermission(this.ngxPermissionsExceptThen || this.ngxPermissionsThen || this.templateRef); + } + }); + } + + private validateOnlyPermissions(): void { + Promise.all([ + this.permissionsService.hasPermission(this.ngxPermissionsOnly), + this.rolesService.hasOnlyRoles(this.ngxPermissionsOnly), + ]) + .then(([hasPermissions, hasRoles]) => { + if (hasPermissions || hasRoles) { + this.handleAuthorisedPermission(this.ngxPermissionsOnlyThen || this.ngxPermissionsThen || this.templateRef); + } else { + this.handleUnauthorisedPermission(this.ngxPermissionsOnlyElse || this.ngxPermissionsElse); + } + }) + .catch(() => { + this.handleUnauthorisedPermission(this.ngxPermissionsOnlyElse || this.ngxPermissionsElse); + }); + } + + private handleUnauthorisedPermission(template: TemplateRef): void { + if (isBoolean(this.currentAuthorizedState) && !this.currentAuthorizedState) { + return; + } + + this.currentAuthorizedState = false; + this.permissionsUnauthorized.emit(); + + if (!this.elseBlockDefined()) { + return; + } else { + this.showTemplateBlockInView(template); + } + } + + private handleAuthorisedPermission(template: TemplateRef): void { + if (isBoolean(this.currentAuthorizedState) && this.currentAuthorizedState) { + return; + } + + this.currentAuthorizedState = true; + this.permissionsAuthorized.emit(); + + if (!this.thenBlockDefined()) { + return; + } else { + this.showTemplateBlockInView(template); + } + } + + private showTemplateBlockInView(template: TemplateRef): void { + this.viewContainer.clear(); + if (!template) { + return; + } + + this.viewContainer.createEmbeddedView(template); + this.changeDetector.markForCheck(); + } + + private getAuthorisedTemplates(): TemplateRef { + return this.ngxPermissionsOnlyThen || this.ngxPermissionsExceptThen || this.ngxPermissionsThen || this.templateRef; + } + + private elseBlockDefined(): boolean { + return !!this.ngxPermissionsExceptElse || !!this.ngxPermissionsElse; + } + + private thenBlockDefined() { + return !!this.ngxPermissionsExceptThen || !!this.ngxPermissionsThen; + } +} diff --git a/src/lib/permissions/permissions.module.ts b/src/lib/permissions/permissions.module.ts new file mode 100644 index 0000000..15b5155 --- /dev/null +++ b/src/lib/permissions/permissions.module.ts @@ -0,0 +1,23 @@ +import { NgModule, Optional } from '@angular/core'; +import { NgxPermissionsDirective } from './permissions.directive'; +import { IqserPermissionsService } from './services/permissions.service'; +import { IqserPermissionsGuard } from './services/permissions-guard.service'; +import { IqserRolesService } from './services/roles.service'; + +@NgModule({ + declarations: [NgxPermissionsDirective], +}) +export class IqserPermissionsModule { + constructor(@Optional() permissionsService: IqserPermissionsService) { + if (!permissionsService) { + throw new Error('Call forRoot() in AppModule to use IqserPermissionsModule'); + } + } + + static forRoot() { + return { + ngModule: IqserPermissionsModule, + providers: [IqserPermissionsService, IqserPermissionsGuard, IqserRolesService], + }; + } +} diff --git a/src/lib/permissions/services/permissions-guard.service.ts b/src/lib/permissions/services/permissions-guard.service.ts new file mode 100644 index 0000000..952367e --- /dev/null +++ b/src/lib/permissions/services/permissions-guard.service.ts @@ -0,0 +1,318 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivate, + CanActivateChild, + CanLoad, + NavigationExtras, + Route, + Router, + RouterStateSnapshot, +} from '@angular/router'; + +import { firstValueFrom, forkJoin, from, Observable, of } from 'rxjs'; +import { first, mergeMap, tap } from 'rxjs/operators'; + +import { + DEFAULT_REDIRECT_KEY, + ExceptFn, + IqserPermissionsRouterData, + IqserRedirectToNavigationParameters, + NavigationCommandsFn, + NavigationExtrasFn, + OnlyFn, + RedirectTo, + RedirectToFn, +} from '../models/permissions-router-data.model'; +import { IqserPermissionsService } from './permissions.service'; +import { IqserRolesService } from './roles.service'; +import { isFunction, isPlainObject, transformStringToArray } from '../utils'; + +export interface IqserPermissionsData { + only?: string | string[]; + except?: string | string[]; + redirectTo?: RedirectTo | RedirectToFn; +} + +@Injectable() +export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateChild { + constructor( + private readonly _permissionsService: IqserPermissionsService, + private readonly _rolesService: IqserRolesService, + private readonly _router: Router, + ) {} + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Promise | boolean { + return this.#hasPermissions(route, state); + } + + canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | Promise | boolean { + return this.#hasPermissions(childRoute, state); + } + + canLoad(route: Route): boolean | Observable | Promise { + return this.#hasPermissions(route); + } + + passingOnlyPermissionsValidation( + permissions: IqserPermissionsData, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, + ): Promise { + if ( + isFunction(permissions.redirectTo) || + (isPlainObject(permissions.redirectTo) && !this.#isRedirectionWithParameters(permissions.redirectTo)) + ) { + return this.#onlyRedirectCheck(permissions, route, state); + } + return this.#checkOnlyPermissions(permissions, route, state); + } + + #hasPermissions(route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) { + const routeDataPermissions = !!route && route.data ? (route.data['permissions'] as IqserPermissionsRouterData) : {}; + const permissions = this.#transformPermission(routeDataPermissions, route, state); + + if (this.#isParameterAvailable(permissions.except)) { + return this.#passingExceptPermissionsValidation(permissions, route, state); + } + + if (this.#isParameterAvailable(permissions.only)) { + return this.passingOnlyPermissionsValidation(permissions, route, state); + } + + return true; + } + + #transformPermission( + permissions: IqserPermissionsRouterData, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, + ): IqserPermissionsData { + const only = isFunction(permissions.only) ? permissions.only(route, state) : transformStringToArray(permissions.only); + const except = isFunction(permissions.except) + ? permissions.except(route, state) + : transformStringToArray(permissions.except); + const redirectTo = permissions.redirectTo; + + return { + only, + except, + redirectTo, + }; + } + + #isParameterAvailable(permission?: string | string[]) { + return !!permission && permission.length > 0; + } + + #passingExceptPermissionsValidation( + permissions: IqserPermissionsData, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, + ): Promise { + if ( + !!permissions.redirectTo && + (isFunction(permissions.redirectTo) || + (isPlainObject(permissions.redirectTo) && !this.#isRedirectionWithParameters(permissions.redirectTo))) + ) { + let failedPermission = ''; + + const res = from(permissions.except ?? []).pipe( + mergeMap(permissionsExcept => { + return forkJoin([ + this._permissionsService.hasPermission(permissionsExcept), + this._rolesService.hasOnlyRoles(permissionsExcept), + ]).pipe( + tap(hasPermissions => { + const dontHavePermissions = hasPermissions.every(hasPermission => hasPermission === false); + + if (!dontHavePermissions) { + failedPermission = permissionsExcept; + } + }), + ); + }), + first(hasPermissions => hasPermissions.some(hasPermission => hasPermission === true), false), + mergeMap(isAllFalse => { + if (!!failedPermission) { + this.#handleRedirectOfFailedPermission(permissions, failedPermission, route, state); + + return of(false); + } + + if (!isAllFalse && permissions.only) { + return this.#onlyRedirectCheck(permissions, route, state); + } + + return of(!isAllFalse); + }), + ); + + return firstValueFrom(res); + } + + return Promise.all([ + this._permissionsService.hasPermission(permissions.except), + this._rolesService.hasOnlyRoles(permissions.except), + ]).then(([hasPermission, hasRoles]) => { + if (hasPermission || hasRoles) { + if (permissions.redirectTo) { + this.#redirectToAnotherRoute(permissions.redirectTo, route, state); + } + + return false; + } + + if (permissions.only) { + return this.#checkOnlyPermissions(permissions, route, state); + } + return true; + }); + } + + #redirectToAnotherRoute( + permissionRedirectTo: RedirectTo | RedirectToFn, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, + failedPermissionName?: string, + ): void { + const redirectTo = isFunction(permissionRedirectTo) + ? permissionRedirectTo(failedPermissionName, route, state) + : permissionRedirectTo; + + if (this.#isRedirectionWithParameters(redirectTo)) { + redirectTo.navigationCommands = this.#transformNavigationCommands(redirectTo.navigationCommands, route, state); + redirectTo.navigationExtras = this.#transformNavigationExtras(redirectTo.navigationExtras ?? {}, route, state); + this._router.navigate(redirectTo.navigationCommands, redirectTo.navigationExtras); + return; + } + + if (Array.isArray(redirectTo)) { + this._router.navigate(redirectTo); + } else { + this._router.navigate([redirectTo]); + } + } + + #isRedirectionWithParameters(object: any | IqserRedirectToNavigationParameters): object is IqserRedirectToNavigationParameters { + return isPlainObject(object) && (!!object.navigationCommands || !!object.navigationExtras); + } + + #transformNavigationCommands( + navigationCommands: any[] | NavigationCommandsFn, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, + ): any[] { + return isFunction(navigationCommands) ? navigationCommands(route, state) : navigationCommands; + } + + #transformNavigationExtras( + navigationExtras: NavigationExtras | NavigationExtrasFn, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, + ): NavigationExtras { + return isFunction(navigationExtras) ? navigationExtras(route, state) : navigationExtras; + } + + #onlyRedirectCheck( + permissions: IqserPermissionsData, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, + ): Promise { + let failedPermission = ''; + + const res = from(permissions.only ?? []).pipe( + mergeMap(permissionsOnly => { + return forkJoin([ + this._permissionsService.hasPermission(permissionsOnly), + this._rolesService.hasOnlyRoles(permissionsOnly), + ]).pipe( + tap(hasPermissions => { + const failed = hasPermissions.every(hasPermission => hasPermission === false); + + if (failed) { + failedPermission = permissionsOnly; + } + }), + ); + }), + first(hasPermissions => { + if (isFunction(permissions.redirectTo)) { + return hasPermissions.some(hasPermission => hasPermission === true); + } + + return hasPermissions.every(hasPermission => hasPermission === false); + }, false), + mergeMap(pass => { + if (isFunction(permissions.redirectTo)) { + if (pass) { + return of(true); + } else { + this.#handleRedirectOfFailedPermission(permissions, failedPermission, route, state); + return of(false); + } + } else { + if (!!failedPermission) { + this.#handleRedirectOfFailedPermission(permissions, failedPermission, route, state); + } + return of(!pass); + } + }), + ); + + return firstValueFrom(res); + } + + #handleRedirectOfFailedPermission( + permissions: IqserPermissionsData, + failedPermission: string, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, + ) { + if (this.#isFailedPermissionPropertyOfRedirectTo(permissions, failedPermission)) { + if (!isFunction(permissions.redirectTo)) { + // @ts-ignore + this.#redirectToAnotherRoute(permissions.redirectTo[failedPermission], route, state, failedPermission); + } + } else { + if (isFunction(permissions.redirectTo)) { + this.#redirectToAnotherRoute(permissions.redirectTo, route, state, failedPermission); + } else { + if (permissions.redirectTo) { + // @ts-ignore + this.#redirectToAnotherRoute(permissions.redirectTo[DEFAULT_REDIRECT_KEY], route, state, failedPermission); + } + } + } + } + + #isFailedPermissionPropertyOfRedirectTo(permissions: IqserPermissionsData, failedPermission: string): boolean { + // @ts-ignore + return !!permissions.redirectTo && permissions.redirectTo[failedPermission]; + } + + #checkOnlyPermissions( + purePermissions: IqserPermissionsData, + route: ActivatedRouteSnapshot | Route, + state?: RouterStateSnapshot, + ): Promise { + const permissions: IqserPermissionsData = { + ...purePermissions, + }; + + return Promise.all([ + this._permissionsService.hasPermission(permissions.only), + this._rolesService.hasOnlyRoles(permissions.only), + ]).then(([hasPermission, hasRole]) => { + if (hasPermission || hasRole) { + return true; + } + + if (permissions.redirectTo) { + this.#redirectToAnotherRoute(permissions.redirectTo, route, state); + } + + return false; + }); + } +} diff --git a/src/lib/permissions/services/permissions.service.ts b/src/lib/permissions/services/permissions.service.ts new file mode 100644 index 0000000..69d73a6 --- /dev/null +++ b/src/lib/permissions/services/permissions.service.ts @@ -0,0 +1,125 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, from, Observable, of } from 'rxjs'; +import { catchError, first, map, mergeAll, switchMap } from 'rxjs/operators'; + +import { isBoolean, isFunction, transformStringToArray } from '../utils'; +import { ValidationFn } from '../models/permissions-router-data.model'; +import { IqserPermission } from '../models/permission.model'; + +export interface IqserPermissionsObject { + [name: string]: IqserPermission; +} + +@Injectable() +export class IqserPermissionsService { + readonly permissions$: Observable; + readonly #permissions$ = new BehaviorSubject({}); + + constructor() { + this.permissions$ = this.#permissions$.asObservable(); + } + + /** + * Remove all permissions from permissions source + */ + public flushPermissions(): void { + this.#permissions$.next({}); + } + + public hasPermission(permission?: string | string[]): Promise { + if (!permission || (Array.isArray(permission) && permission.length === 0)) { + return Promise.resolve(true); + } + + permission = transformStringToArray(permission); + return this.hasArrayPermission(permission); + } + + public loadPermissions(permissions: string[], validationFunction?: ValidationFn): void { + const newPermissions = permissions.reduce((source, name) => this.reducePermission(source, name, validationFunction), {}); + this.#permissions$.next(newPermissions); + } + + public addPermission(permission: string | string[], validationFunction?: ValidationFn): void { + if (Array.isArray(permission)) { + const permissions = permission.reduce( + (source, name) => this.reducePermission(source, name, validationFunction), + this.#permissions$.value, + ); + + this.#permissions$.next(permissions); + } else { + const permissions = this.reducePermission(this.#permissions$.value, permission, validationFunction); + + this.#permissions$.next(permissions); + } + } + + public removePermission(permissionName: string): void { + const permissions = { + ...this.#permissions$.value, + }; + delete permissions[permissionName]; + this.#permissions$.next(permissions); + } + + public getPermission(name: string): IqserPermission | undefined { + return this.#permissions$.value[name]; + } + + public getPermissions(): IqserPermissionsObject { + return this.#permissions$.value; + } + + private reducePermission(source: IqserPermissionsObject, name: string, validationFunction?: ValidationFn): IqserPermissionsObject { + if (!!validationFunction && isFunction(validationFunction)) { + return { + ...source, + [name]: { name, validationFunction }, + }; + } + return { + ...source, + [name]: { name }, + }; + } + + private hasArrayPermission(permissions: string[]): Promise { + const promises = permissions.map(key => { + if (this.hasPermissionValidationFunction(key)) { + const validationFunction = this.#permissions$.value[key].validationFunction; + if (!validationFunction) { + return of(false); + } + const immutableValue = { ...this.#permissions$.value }; + + return of(null).pipe( + map(() => validationFunction(key, immutableValue)), + switchMap(promise => (isBoolean(promise) ? of(promise) : promise)), + catchError(() => of(false)), + ); + } + + // check for name of the permission if there is no validation function + return of(!!this.#permissions$.value[key]); + }); + + return from(promises) + .pipe( + mergeAll(), + first(data => data !== false, false), + map(data => data !== false), + ) + .toPromise() + .then((data: any) => data); + } + + private hasPermissionValidationFunction(key: string): boolean { + return ( + !!this.#permissions$.value[key] && + !!this.#permissions$.value[key].validationFunction && + isFunction(this.#permissions$.value[key].validationFunction) + ); + } +} diff --git a/src/lib/permissions/services/roles.service.ts b/src/lib/permissions/services/roles.service.ts new file mode 100644 index 0000000..9f9942f --- /dev/null +++ b/src/lib/permissions/services/roles.service.ts @@ -0,0 +1,137 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, firstValueFrom, from, Observable, of } from 'rxjs'; +import { catchError, every, first, map, mergeAll, mergeMap, switchMap } from 'rxjs/operators'; + +import { IqserPermissionsService } from './permissions.service'; +import { ValidationFn } from '../models/permissions-router-data.model'; +import { IqserRole } from '../models/role.model'; +import { isBoolean, isFunction, isPromise, transformStringToArray } from '../utils'; + +export interface IqserRolesObject { + [name: string]: IqserRole; +} + +@Injectable() +export class IqserRolesService { + readonly roles$: Observable; + readonly #roles$ = new BehaviorSubject({}); + + constructor(private readonly _permissionsService: IqserPermissionsService) { + this.roles$ = this.#roles$.asObservable(); + } + + addRole(name: string, validationFunction: ValidationFn | string[]) { + const roles = { + ...this.#roles$.value, + [name]: { name, validationFunction }, + }; + this.#roles$.next(roles); + } + + addRoleWithPermissions(name: string, permissions: string[]) { + this._permissionsService.addPermission(permissions); + this.addRole(name, permissions); + } + + addRoles(rolesObj: { [name: string]: ValidationFn | string[] }) { + Object.keys(rolesObj).forEach((key, index) => { + this.addRole(key, rolesObj[key]); + }); + } + + addRolesWithPermissions(rolesObj: { [name: string]: string[] }) { + Object.keys(rolesObj).forEach((key, index) => { + this.addRoleWithPermissions(key, rolesObj[key]); + }); + } + + flushRoles() { + this.#roles$.next({}); + } + + flushRolesAndPermissions() { + this.flushRoles(); + this._permissionsService.flushPermissions(); + } + + removeRole(roleName: string) { + const roles = { + ...this.#roles$.value, + }; + delete roles[roleName]; + this.#roles$.next(roles); + } + + getRoles(): IqserRolesObject { + return this.#roles$.value; + } + + getRole(name: string): IqserRole | undefined { + return this.#roles$.value[name]; + } + + hasOnlyRoles(names?: string | string[]): Promise { + const isNamesEmpty = !names || (Array.isArray(names) && names.length === 0); + + if (isNamesEmpty) { + return Promise.resolve(true); + } + + names = transformStringToArray(names); + + return Promise.all([this.#hasRoleKey(names), this.#hasRolePermission(this.#roles$.value, names)]).then( + ([hasRoles, hasPermissions]) => { + return !!hasRoles || !!hasPermissions; + }, + ); + } + + #hasRoleKey(roleName: string[]): Promise { + const promises = roleName.map(key => { + const hasValidationFunction = + !!this.#roles$.value[key] && + !!this.#roles$.value[key].validationFunction && + isFunction(this.#roles$.value[key].validationFunction); + + if (hasValidationFunction && !isPromise(this.#roles$.value[key].validationFunction)) { + const validationFunction = this.#roles$.value[key].validationFunction as ValidationFn; + const immutableValue = { ...this.#roles$.value }; + + return of(null).pipe( + map(() => validationFunction(key, immutableValue)), + switchMap(promise => (isBoolean(promise) ? of(promise) : promise)), + catchError(() => of(false)), + ); + } + + return of(false); + }); + + const res = from(promises).pipe( + mergeAll(), + first(data => data !== false, false), + map(data => data !== false), + ); + + return firstValueFrom(res); + } + + #hasRolePermission(roles: IqserRolesObject, roleNames: string[]): Promise { + const res = from(roleNames).pipe( + mergeMap(key => { + if (roles[key] && Array.isArray(roles[key].validationFunction)) { + return from(roles[key].validationFunction).pipe( + mergeMap(permission => this._permissionsService.hasPermission(permission)), + every(hasPermission => hasPermission === true), + ); + } + + return of(false); + }), + first(hasPermission => hasPermission === true, false), + ); + + return firstValueFrom(res); + } +} diff --git a/src/lib/permissions/utils.ts b/src/lib/permissions/utils.ts new file mode 100644 index 0000000..67d5049 --- /dev/null +++ b/src/lib/permissions/utils.ts @@ -0,0 +1,38 @@ +export function isFunction(value: any): value is T { + return typeof value === 'function'; +} + +export function isPlainObject(value: any): boolean { + if (Object.prototype.toString.call(value) !== '[object Object]') { + return false; + } else { + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype === Object.prototype; + } +} + +export function isString(value: any): value is string { + return !!value && typeof value === 'string'; +} + +export function isBoolean(value: any): value is boolean { + return typeof value === 'boolean'; +} + +export function isPromise(promise: any) { + return Object.prototype.toString.call(promise) === '[object Promise]'; +} + +export function notEmptyValue(value: string | string[]): boolean { + if (Array.isArray(value)) { + return value.length > 0; + } + return !!value; +} + +export function transformStringToArray(value?: string | string[]): string[] { + if (isString(value)) { + return [value]; + } + return value ?? []; +}