update services

This commit is contained in:
Dan Percic 2022-10-14 18:12:46 +03:00
parent 9275701f93
commit 7d4e0a851a
14 changed files with 672 additions and 803 deletions

1
src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
import 'jest-extended';

View File

@ -1,6 +1,4 @@
export * from './models/permission.model';
export * from './models/permissions-router-data.model';
export * from './models/role.model';
export * from './types';
export * from './services/permissions-guard.service';
export * from './services/permissions.service';
export * from './services/roles.service';

View File

@ -1,10 +0,0 @@
import { ValidationFn } from './permissions-router-data.model';
export interface IqserPermission {
name: string;
validationFn?: ValidationFn<IqserPermission>;
}
export interface IqserPermissionsObject {
[name: string]: IqserPermission;
}

View File

@ -1,6 +0,0 @@
import { ValidationFn } from './permissions-router-data.model';
export interface IqserRole {
name: string;
validationFn: ValidationFn<IqserRole> | string[];
}

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,9 @@ import {
import { merge, Subject, Subscription, switchMap } from 'rxjs';
import { tap } from 'rxjs/operators';
import { notEmpty } from './utils';
import { IqserRolesService } from './services/roles.service';
import { IqserPermissionsService } from './services/permissions.service';
import { List } from '../utils';
type NgTemplate = TemplateRef<unknown>;
@ -38,11 +38,11 @@ export class IqserPermissionsDirective implements OnDestroy, OnInit {
@Output() readonly permissionsAuthorized = new EventEmitter();
@Output() readonly permissionsUnauthorized = new EventEmitter();
#permissions?: string | string[];
#permissions?: string | List;
#thenTemplateRef: TemplateRef<unknown>;
#elseTemplateRef?: TemplateRef<unknown>;
#thenViewRef?: EmbeddedViewRef<unknown>;
#elseViewRef?: EmbeddedViewRef<unknown>;
#thenViewRef: EmbeddedViewRef<unknown> | boolean = false;
#elseViewRef: EmbeddedViewRef<unknown> | boolean = false;
readonly #updateView = new Subject<void>();
readonly #subscription = new Subscription();
@ -58,7 +58,7 @@ export class IqserPermissionsDirective implements OnDestroy, OnInit {
}
@Input()
set allow(value: string | string[]) {
set allow(value: string | List) {
this.#permissions = value;
this.#updateView.next();
}
@ -67,7 +67,7 @@ export class IqserPermissionsDirective implements OnDestroy, OnInit {
set allowThen(template: NgTemplate) {
assertTemplate('allowThen', template);
this.#thenTemplateRef = template;
this.#thenViewRef = undefined;
this.#thenViewRef = false;
this.#updateView.next();
}
@ -75,7 +75,7 @@ export class IqserPermissionsDirective implements OnDestroy, OnInit {
set allowElse(template: NgTemplate) {
assertTemplate('allowElse', template);
this.#elseTemplateRef = template;
this.#elseViewRef = undefined;
this.#elseViewRef = false;
this.#updateView.next();
}
@ -91,21 +91,17 @@ export class IqserPermissionsDirective implements OnDestroy, OnInit {
}
#waitForRolesAndPermissions() {
return merge(this._permissionsService.permissions$, this._rolesService.roles$).pipe(
tap(() => (notEmpty(this.#permissions) ? this.#validate() : this.#showThenBlock())),
);
return merge(this._permissionsService.permissions$, this._rolesService.roles$).pipe(tap(() => this.#validate()));
}
#validate(): void {
Promise.all([this._permissionsService.has(this.#permissions), this._rolesService.has(this.#permissions)])
.then(([hasPermissions, hasRoles]) => {
if (hasPermissions || hasRoles) {
return this.#showThenBlock();
}
if (!this.#permissions) {
return this.#showThenBlock();
}
return this.#showElseBlock();
})
.catch(() => this.#showElseBlock());
const promises = [this._permissionsService.has(this.#permissions), this._rolesService.has(this.#permissions)];
const result = Promise.all(promises).then(([hasPermission, hasRole]) => hasPermission || hasRole);
result.then(isAllowed => (isAllowed ? this.#showThenBlock() : this.#showElseBlock())).catch(() => this.#showElseBlock());
}
#showElseBlock() {
@ -114,8 +110,8 @@ export class IqserPermissionsDirective implements OnDestroy, OnInit {
}
this.permissionsUnauthorized.emit();
this.#thenViewRef = undefined;
this.#elseViewRef = this.#showTemplate(this.#elseTemplateRef);
this.#thenViewRef = false;
this.#elseViewRef = this.#showTemplate(this.#elseTemplateRef) ?? true;
}
#showThenBlock() {
@ -124,18 +120,13 @@ export class IqserPermissionsDirective implements OnDestroy, OnInit {
}
this.permissionsAuthorized.emit();
this.#elseViewRef = undefined;
this.#thenViewRef = this.#showTemplate(this.#thenTemplateRef);
this.#elseViewRef = false;
this.#thenViewRef = this.#showTemplate(this.#thenTemplateRef) ?? true;
}
#showTemplate(template?: NgTemplate) {
this._viewContainer.clear();
if (!template) {
return;
}
return this._viewContainer.createEmbeddedView(template);
return template ? this._viewContainer.createEmbeddedView(template) : undefined;
}
}

View File

@ -82,7 +82,7 @@ describe('Permissions guard', () => {
expect(result).toEqual(true);
});
it('should return false when only doesnt match', async () => {
it('should return false when allow doesnt match', async () => {
testRoute = {
data: {
permissions: {
@ -440,11 +440,11 @@ describe('Role guard with redirectTo as function', () => {
roleService = TestBed.inject(IqserRolesService);
permissionsService.add('canReadAgenda');
permissionsService.add('AWESOME');
roleService.add('ADMIN', ['AWESOME', 'canReadAgenda']);
roleService.add({ ADMIN: ['AWESOME', 'canReadAgenda'] });
});
it('should dynamically pass if one satisfies', async () => {
roleService.add('RUN', ['BLABLA', 'BLABLA2']);
roleService.add({ RUN: ['BLABLA', 'BLABLA2'] });
testRoute = {
data: {

View File

@ -15,18 +15,20 @@ import { first, mergeMap, tap } from 'rxjs/operators';
import {
DEFAULT_REDIRECT_KEY,
IqserPermissionsRouterData,
IqserActivatedRouteSnapshot,
IqserRoute,
NavigationCommandsFn,
NavigationExtrasFn,
RedirectTo,
RedirectToFn,
} from '../models/permissions-router-data.model';
} from '../types';
import { IqserPermissionsService } from './permissions.service';
import { IqserRolesService } from './roles.service';
import { isFunction, isRedirectWithParameters, isString, transformPermission } from '../utils';
import { isArray, isFunction, isRedirectWithParameters, isString, transformPermission } from '../utils';
import { List } from '../../utils';
export interface IqserPermissionsData {
allow: string | string[];
allow: string | List;
redirectTo?: RedirectTo | RedirectToFn;
}
@ -38,15 +40,15 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC
private readonly _router: Router,
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
canActivate(route: IqserActivatedRouteSnapshot, state: RouterStateSnapshot) {
return this.#checkPermissions(route, state);
}
canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
canActivateChild(childRoute: IqserActivatedRouteSnapshot, state: RouterStateSnapshot) {
return this.#checkPermissions(childRoute, state);
}
canLoad(route: Route) {
canLoad(route: IqserRoute) {
return this.#checkPermissions(route);
}
@ -58,15 +60,15 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC
return this.#validatePermissions(permissions, route, state);
}
#checkPermissions(route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) {
const routePermissions = route?.data ? (route.data['permissions'] as IqserPermissionsRouterData) : undefined;
#checkPermissions(route: IqserActivatedRouteSnapshot | IqserRoute, state?: RouterStateSnapshot) {
const routePermissions = route.data?.permissions;
if (!routePermissions) {
return Promise.resolve(true);
}
const permissions = transformPermission(routePermissions, route, state);
if (permissions?.allow?.length > 0) {
if (permissions.allow?.length > 0) {
return this.#validate(permissions, route, state);
}
@ -126,7 +128,6 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC
if (failed) {
failedPermission = permission;
console.log(`Permission ${permission} is not allowed`);
}
}),
);
@ -172,7 +173,7 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC
return this.#redirectToAnotherRoute(failedPermissionRedirectTo, route, failedPermission, state);
}
if (isFunction<RedirectToFn>(permissions.redirectTo) || isString(permissions.redirectTo) || Array.isArray(permissions.redirectTo)) {
if (isFunction<RedirectToFn>(permissions.redirectTo) || isString(permissions.redirectTo) || isArray(permissions.redirectTo)) {
return this.#redirectToAnotherRoute(permissions.redirectTo, route, failedPermission, state);
}
@ -195,7 +196,7 @@ export class IqserPermissionsGuard implements CanActivate, CanLoad, CanActivateC
!isFunction<RedirectToFn>(redirectTo) &&
!isString(redirectTo) &&
!isRedirectWithParameters(redirectTo) &&
!Array.isArray(redirectTo)
!isArray(redirectTo)
) {
return redirectTo[failedPermission];
}

View File

@ -8,6 +8,10 @@ const GUEST = 'GUEST' as const;
describe('Permissions Service', () => {
let permissionsService: IqserPermissionsService;
function getPermissionsLength() {
return Object.keys(permissionsService.get()).length;
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [IqserPermissionsModule.forRoot()],
@ -35,32 +39,29 @@ describe('Permissions Service', () => {
});
it('should remove all permissions from permissions object', () => {
expect(Object.keys(permissionsService.get()).length).toEqual(0);
expect(getPermissionsLength()).toEqual(0);
permissionsService.add(ADMIN);
permissionsService.add(GUEST);
expect(Object.keys(permissionsService.get()).length).toEqual(2);
expect(getPermissionsLength()).toEqual(2);
permissionsService.clear();
expect(Object.keys(permissionsService.get()).length).toEqual(0);
expect(getPermissionsLength()).toEqual(0);
});
it('should add multiple permissions', () => {
expect(Object.keys(permissionsService.get()).length).toEqual(0);
expect(getPermissionsLength()).toEqual(0);
permissionsService.add([ADMIN, GUEST]);
expect(Object.keys(permissionsService.get()).length).toEqual(2);
expect(permissionsService.get()).toEqual({
ADMIN: { name: 'ADMIN' },
GUEST: { name: 'GUEST' },
});
expect(getPermissionsLength()).toEqual(2);
expect(Object.keys(permissionsService.get())).toEqual([ADMIN, GUEST]);
});
it('should return true when permission name is present in permissions object', async () => {
expect(Object.keys(permissionsService.get()).length).toEqual(0);
expect(getPermissionsLength()).toEqual(0);
permissionsService.add([ADMIN, GUEST]);
expect(Object.keys(permissionsService.get()).length).toEqual(2);
expect(getPermissionsLength()).toEqual(2);
let result = await permissionsService.has('ADMIN');
expect(result).toEqual(true);
@ -72,74 +73,75 @@ describe('Permissions Service', () => {
expect(result).toEqual(true);
result = await permissionsService.has(['ADMIN', 'IRIISISTABLE']);
expect(result).toEqual(true);
expect(result).toEqual(false);
});
it('should return true when permission function return true', async () => {
expect(Object.keys(permissionsService.get()).length).toEqual(0);
expect(getPermissionsLength()).toEqual(0);
permissionsService.add(ADMIN, () => true);
expect(Object.keys(permissionsService.get()).length).toEqual(1);
permissionsService.add({ ADMIN: () => true });
expect(getPermissionsLength()).toEqual(1);
let result = await permissionsService.has('ADMIN');
expect(result).toEqual(true);
permissionsService.add(GUEST, () => false);
expect(Object.keys(permissionsService.get()).length).toEqual(2);
permissionsService.add({ GUEST: () => false });
expect(getPermissionsLength()).toEqual(2);
result = await permissionsService.has('GUEST');
expect(result).toEqual(false);
permissionsService.add('TEST1', () => Promise.resolve(true));
expect(Object.keys(permissionsService.get()).length).toEqual(3);
permissionsService.add({ TEST1: () => Promise.resolve(true) });
expect(getPermissionsLength()).toEqual(3);
result = await permissionsService.has('TEST1');
expect(result).toEqual(true);
permissionsService.add('TEST2', () => Promise.resolve(false));
expect(Object.keys(permissionsService.get()).length).toEqual(4);
permissionsService.add({ TEST2: () => Promise.resolve(false) });
expect(getPermissionsLength()).toEqual(4);
result = await permissionsService.has('TEST2');
expect(result).toEqual(false);
});
// TODO: permissions array with function should not be allowed
it('should return true when permissions array function return true', async () => {
expect(Object.keys(permissionsService.get()).length).toEqual(0);
expect(getPermissionsLength()).toEqual(0);
permissionsService.add([ADMIN], () => true);
expect(Object.keys(permissionsService.get()).length).toEqual(1);
permissionsService.add({ ADMIN: () => true });
expect(getPermissionsLength()).toEqual(1);
let result = await permissionsService.has('ADMIN');
expect(result).toEqual(true);
permissionsService.add([GUEST], () => false);
expect(Object.keys(permissionsService.get()).length).toEqual(2);
permissionsService.add({ GUEST: () => false });
expect(getPermissionsLength()).toEqual(2);
result = await permissionsService.has('GUEST');
expect(result).toEqual(false);
permissionsService.add(['TEST1'], () => Promise.resolve(true));
expect(Object.keys(permissionsService.get()).length).toEqual(3);
permissionsService.add({ TEST1: () => Promise.resolve(true) });
expect(getPermissionsLength()).toEqual(3);
result = await permissionsService.has('TEST1');
expect(result).toEqual(true);
permissionsService.add(['TEST9'], () => Promise.resolve(false));
expect(Object.keys(permissionsService.get()).length).toEqual(4);
permissionsService.add({ TEST9: () => Promise.resolve(false) });
expect(getPermissionsLength()).toEqual(4);
result = await permissionsService.has(['TEST9']);
expect(result).toEqual(false);
});
it('should call validationFn with permission name and store', async () => {
permissionsService.add('TEST11', (name, store) => {
expect(name).toEqual('TEST11');
expect(store['TEST11']).toBeTruthy();
return Promise.resolve(true);
permissionsService.add({
TEST11: (name, store) => {
expect(name).toEqual('TEST11');
expect(store['TEST11']).toBeTruthy();
return Promise.resolve(true);
},
});
expect(Object.keys(permissionsService.get()).length).toEqual(1);
expect(getPermissionsLength()).toEqual(1);
const result = await permissionsService.has(['TEST11']);
expect(result).toEqual(true);

View File

@ -1,103 +1,79 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, firstValueFrom, from, Observable, of } from 'rxjs';
import { catchError, first, map, mergeAll, switchMap } from 'rxjs/operators';
import { BehaviorSubject, Observable } from 'rxjs';
import { isFunction, toArray } from '../utils';
import { ValidationFn } from '../models/permissions-router-data.model';
import { IqserPermission, IqserPermissionsObject } from '../models/permission.model';
import { isArray, isString, toArray } from '../utils';
import { IqserPermissions, PermissionValidationFn } from '../types';
import { List } from '../../utils';
@Injectable()
export class IqserPermissionsService {
readonly permissions$: Observable<IqserPermissionsObject>;
readonly #permissions$ = new BehaviorSubject<IqserPermissionsObject>({});
readonly permissions$: Observable<IqserPermissions>;
readonly #permissions$ = new BehaviorSubject<IqserPermissions>({});
constructor() {
this.permissions$ = this.#permissions$.asObservable();
}
/**
* Remove all permissions from permissions source
*/
clear(): void {
this.#permissions$.next({});
}
has(permission?: string | string[]): Promise<boolean> {
if (!permission || (Array.isArray(permission) && permission.length === 0)) {
return Promise.resolve(true);
}
permission = toArray(permission);
return this.#hasArray(permission);
has(permission: string): Promise<boolean>;
has(permissions: List): Promise<boolean>;
has(permissions: string | List): Promise<boolean>;
has(value: string | List): Promise<boolean> {
const isEmpty = !value || value.length === 0;
return isEmpty ? Promise.resolve(true) : this.#has(toArray(value));
}
load(permissions: string[], validationFn?: ValidationFn<IqserPermission>): void {
const newPermissions = permissions.reduce((source, name) => this.#reduce(source, name, validationFn), {});
this.#permissions$.next(newPermissions);
load(permissions: IqserPermissions | List) {
return this.#reduce(permissions);
}
add(permission: string | string[], validationFn?: ValidationFn<IqserPermission>) {
const permissions = toArray(permission).reduce(
(source, name) => this.#reduce(source, name, validationFn),
this.#permissions$.value,
);
return this.#permissions$.next(permissions);
add(permissions: string | List | IqserPermissions) {
const _permissions = isString(permissions) ? { [permissions]: () => true } : permissions;
return this.#reduce(_permissions, this.#permissions$.value);
}
remove(name: string): void {
remove(permission: string) {
const permissions = { ...this.#permissions$.value };
delete permissions[name];
delete permissions[permission];
this.#permissions$.next(permissions);
}
get(): IqserPermissionsObject;
get(name: string): IqserPermission | undefined;
get(name?: string): IqserPermission | IqserPermissionsObject | undefined {
return name ? this.#permissions$.value[name] : this.#permissions$.value;
get(): IqserPermissions;
get(permission: string): PermissionValidationFn | undefined;
get(permission?: string): IqserPermissions | PermissionValidationFn | undefined {
return permission ? this.#permissions$.value[permission] : this.#permissions$.value;
}
#reduce(source: IqserPermissionsObject, name: string, validationFn?: ValidationFn<IqserPermission>): IqserPermissionsObject {
if (!!validationFn && isFunction(validationFn)) {
return { ...source, [name]: { name, validationFn } };
#reduce(permissions: IqserPermissions | List, initialValue = {} as IqserPermissions) {
if (isArray(permissions)) {
return this.#permissions$.next(this.#reduceList(permissions, initialValue));
}
return { ...source, [name]: { name } };
return this.#permissions$.next(this.#reduceObject(permissions, initialValue));
}
#hasArray(permissions: string[]): Promise<boolean> {
const promises = permissions.map(key => {
if (this.#hasValidationFn(key)) {
const validationFunction = this.#permissions$.value[key].validationFn;
if (!validationFunction) {
return of(false);
}
const immutableValue = { ...this.#permissions$.value };
#reduceObject(permissions: IqserPermissions, initialValue: IqserPermissions = {}) {
return Object.entries(permissions).reduce((acc, [permission, validationFn]) => {
return { ...acc, [permission]: validationFn };
}, initialValue);
}
return of(null).pipe(
switchMap(async () => validationFunction(key, immutableValue)),
catchError(() => of(false)),
);
}
#reduceList(permissions: List, initialValue: IqserPermissions = {}): IqserPermissions {
return permissions.reduce((acc, permission) => {
return { ...acc, [permission]: () => true };
}, initialValue);
}
return of(!!this.#permissions$.value[key]);
#has(permissions: List): Promise<boolean> {
const promises = permissions.map(permission => {
const validationFn = this.#permissions$.value[permission];
return validationFn?.(permission, this.#permissions$.value) ?? false;
});
const res = from(promises).pipe(
mergeAll(),
first(data => data !== false, false),
map(data => data !== false),
);
return firstValueFrom(res);
}
#hasValidationFn(key: string): boolean {
return (
!!this.#permissions$.value[key] &&
!!this.#permissions$.value[key].validationFn &&
isFunction(this.#permissions$.value[key].validationFn)
);
return Promise.all(promises).then(results => results.every(result => result));
}
}

View File

@ -1,17 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { ValidationFn } from '../models/permissions-router-data.model';
import { RoleValidationFn } from '../types';
import { IqserRolesService } from './roles.service';
import { IqserPermissionsService } from './permissions.service';
import { IqserPermissionsModule } from '../permissions.module';
import { IqserRole } from '../models/role.model';
const ADMIN = 'ADMIN' as const;
const GUEST = 'GUEST' as const;
describe('Roles Service', () => {
let rolesService: IqserRolesService;
let permissionsService: IqserPermissionsService;
function getRolesLength() {
return Object.keys(rolesService.get()).length;
}
function getPermissionsLength() {
return Object.keys(permissionsService.get()).length;
}
beforeEach(() => {
TestBed.configureTestingModule({
imports: [IqserPermissionsModule.forRoot()],
@ -28,16 +34,16 @@ describe('Roles Service', () => {
it('should add role to role object', () => {
expect(rolesService.get(ADMIN)).toBeFalsy();
rolesService.add(ADMIN, ['edit', 'remove']);
rolesService.add({ ADMIN: ['edit', 'remove'] });
expect(rolesService.get(ADMIN)).toBeTruthy();
expect(rolesService.get()).toEqual({ ADMIN: { name: ADMIN, validationFn: ['edit', 'remove'] } });
expect(rolesService.get()).toEqual({ ADMIN: ['edit', 'remove'] });
});
it('should remove role from role object', () => {
expect(rolesService.get(ADMIN)).toBeFalsy();
rolesService.add(ADMIN, ['edit', 'remove']);
rolesService.add({ ADMIN: ['edit', 'remove'] });
expect(rolesService.get(ADMIN)).toBeTruthy();
rolesService.remove(ADMIN);
@ -45,39 +51,41 @@ describe('Roles Service', () => {
});
it('should remove all roles from object', () => {
expect(Object.keys(rolesService.get()).length).toEqual(0);
expect(getRolesLength()).toEqual(0);
rolesService.add(ADMIN, ['edit', 'remove']);
rolesService.add(GUEST, ['edit', 'remove']);
expect(Object.keys(rolesService.get()).length).toEqual(2);
rolesService.add({
ADMIN: ['edit', 'remove'],
GUEST: ['edit', 'remove'],
});
expect(getRolesLength()).toEqual(2);
rolesService.clear();
expect(Object.keys(rolesService.get()).length).toEqual(0);
expect(getRolesLength()).toEqual(0);
});
it('should add multiple roles', () => {
expect(Object.keys(rolesService.get()).length).toEqual(0);
expect(getRolesLength()).toEqual(0);
rolesService.add({
ADMIN: ['Nice'],
GUEST: ['Awesome'],
});
expect(Object.keys(rolesService.get()).length).toEqual(2);
expect(getRolesLength()).toEqual(2);
expect(rolesService.get()).toEqual({
ADMIN: { name: ADMIN, validationFn: ['Nice'] },
GUEST: { name: GUEST, validationFn: ['Awesome'] },
ADMIN: ['Nice'],
GUEST: ['Awesome'],
});
});
it('return true when role name is present in Roles object', async () => {
expect(Object.keys(rolesService.get()).length).toEqual(0);
it('return true when role name is present in roles object', async () => {
expect(getRolesLength()).toEqual(0);
rolesService.add({
ADMIN: ['Nice'],
GUEST: ['Awesome'],
});
expect(Object.keys(rolesService.get()).length).toEqual(2);
expect(getRolesLength()).toEqual(2);
let result = await rolesService.has(ADMIN);
expect(result).toEqual(true);
@ -86,23 +94,24 @@ describe('Roles Service', () => {
expect(result).toEqual(false);
result = await rolesService.has([ADMIN, 'IRIISISTABLE']);
expect(result).toEqual(true);
expect(result).toEqual(false);
});
it('return true when role permission name is present in Roles object', async () => {
expect(Object.keys(rolesService.get()).length).toEqual(0);
expect(getRolesLength()).toEqual(0);
rolesService.add({
ADMIN: ['Nice'],
GUEST: ['Awesome'],
});
expect(Object.keys(rolesService.get()).length).toEqual(2);
expect(getRolesLength()).toEqual(2);
let result = await rolesService.has(ADMIN);
expect(result).toEqual(true);
result = await rolesService.has([ADMIN, 'IRRISISTABLE']);
expect(result).toEqual(true);
expect(result).toEqual(false);
result = await rolesService.has('SHOULDNOTHAVEROLE');
expect(result).toEqual(false);
@ -112,17 +121,11 @@ describe('Roles Service', () => {
});
it('should return role when requested with has role', () => {
rolesService.add('role', () => true);
const role = rolesService.get('role');
rolesService.add({ role: () => true });
const validationFn = rolesService.get('role') as RoleValidationFn;
if (!role) {
return expect(role).toBeTruthy();
}
expect(role.name).toBe('role');
const validationFn = role.validationFn as ValidationFn<IqserRole>;
expect(validationFn(role.name, rolesService.get())).toEqual(true);
expect(validationFn).toBeTruthy();
expect(validationFn('role', rolesService.get())).toEqual(true);
});
it('should return true when checking with empty permission(not specified)', async () => {
@ -136,31 +139,32 @@ describe('Roles Service', () => {
});
it('should return false when role is not specified in the list', async () => {
rolesService.add('test', ['One']);
rolesService.add({ test: ['One'] });
const result = await rolesService.has('nice');
expect(result).toBe(false);
});
it('should return true when passing empty array', async () => {
rolesService.add('test', ['One']);
rolesService.add({ test: ['One'] });
const result = await rolesService.has([]);
expect(result).toBe(true);
});
it('should add permissions to roles automatically', async () => {
rolesService.add('test', ['one', 'two']);
rolesService.add({ test: ['one', 'two'] });
const result = await rolesService.has('test');
expect(result).toBe(true);
});
it('should remove roles and permissions add the same time', async () => {
rolesService.add('test', ['one', 'two']);
rolesService.add({ test: ['one', 'two'] });
let result = await rolesService.has('test');
expect(result).toBe(true);
result = await permissionsService.has('one');
expect(result).toBe(true);
@ -169,41 +173,42 @@ describe('Roles Service', () => {
result = await rolesService.has('test');
expect(result).toBe(false);
result = await permissionsService.has('one');
expect(result).toBe(false);
});
it('should remove all permissions and roles', () => {
expect(Object.keys(rolesService.get()).length).toEqual(0);
expect(getRolesLength()).toEqual(0);
rolesService.add(ADMIN, ['edit', 'remove']);
rolesService.add(GUEST, ['edit1', 'remove2']);
rolesService.add({ ADMIN: ['edit', 'remove'] });
rolesService.add({ GUEST: ['edit1', 'remove2'] });
expect(Object.keys(rolesService.get()).length).toEqual(2);
expect(Object.keys(permissionsService.get()).length).toEqual(4);
expect(getRolesLength()).toEqual(2);
expect(getPermissionsLength()).toEqual(4);
rolesService.clear();
permissionsService.clear();
expect(Object.keys(rolesService.get()).length).toEqual(0);
expect(Object.keys(permissionsService.get()).length).toEqual(0);
expect(getRolesLength()).toEqual(0);
expect(getPermissionsLength()).toEqual(0);
});
it('should add multiple roles with permissions', () => {
expect(Object.keys(rolesService.get()).length).toEqual(0);
expect(getRolesLength()).toEqual(0);
rolesService.add({
ADMIN: ['Nice'],
GUEST: ['Awesome', 'Another awesome'],
});
expect(Object.keys(rolesService.get()).length).toEqual(2);
expect(getRolesLength()).toEqual(2);
expect(rolesService.get()).toEqual({
ADMIN: { name: ADMIN, validationFn: ['Nice'] },
GUEST: { name: GUEST, validationFn: ['Awesome', 'Another awesome'] },
ADMIN: ['Nice'],
GUEST: ['Awesome', 'Another awesome'],
});
expect(Object.keys(rolesService.get()).length).toEqual(2);
expect(Object.keys(permissionsService.get()).length).toEqual(3);
expect(getRolesLength()).toEqual(2);
expect(getPermissionsLength()).toEqual(3);
});
});

View File

@ -1,38 +1,27 @@
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 { BehaviorSubject, Observable } from 'rxjs';
import { IqserRoles, RoleValidationFn } from '../types';
import { isArray, isBoolean, isString, toArray } from '../utils';
import { List } from '../../utils';
import { IqserPermissionsService } from './permissions.service';
import { ValidationFn } from '../models/permissions-router-data.model';
import { IqserRole } from '../models/role.model';
import { isFunction, isString, toArray } from '../utils';
export interface IqserRolesObject {
[name: string]: IqserRole;
}
@Injectable()
export class IqserRolesService {
readonly roles$: Observable<IqserRolesObject>;
readonly #roles$ = new BehaviorSubject<IqserRolesObject>({});
readonly roles$: Observable<IqserRoles>;
readonly #roles$ = new BehaviorSubject<IqserRoles>({});
constructor(private readonly _permissionsService: IqserPermissionsService) {
this.roles$ = this.#roles$.asObservable();
}
add(role: string, validationFn: ValidationFn<IqserRole> | string[]): void;
add(rolesObj: Record<string, ValidationFn<IqserRole> | string[]>): void;
add(role: string | Record<string, ValidationFn<IqserRole> | string[]>, validationFn?: ValidationFn<IqserRole> | string[]) {
if (isString(role) && validationFn) {
return this.#add(role, validationFn);
}
add(roles: string | List | IqserRoles) {
const _roles = isString(roles) ? { [roles]: () => true } : roles;
this.#reduce(_roles, this.#roles$.value);
}
if (typeof role === 'object') {
return Object.keys(role).forEach(key => this.#add(key, role[key]));
}
throw new Error('Invalid add role arguments');
load(roles: List | IqserRoles) {
this.#reduce(roles);
}
clear() {
@ -45,85 +34,55 @@ export class IqserRolesService {
this.#roles$.next(roles);
}
get(): IqserRolesObject;
get(role: string): IqserRole | undefined;
get(role?: string): IqserRolesObject | IqserRole | undefined {
get(): IqserRoles;
get(role: string): RoleValidationFn | List | undefined;
get(role?: string): IqserRoles | RoleValidationFn | List | undefined {
return role ? this.#roles$.value[role] : this.#roles$.value;
}
has(names?: string | string[]): Promise<boolean> {
const isNamesEmpty = !names || (Array.isArray(names) && names.length === 0);
has(roles: string | List): Promise<boolean> {
const isEmpty = !roles || roles.length === 0;
if (isNamesEmpty) {
if (isEmpty) {
return Promise.resolve(true);
}
names = toArray(names);
return Promise.all([this.#hasRoleKey(names), this.#hasPermission(names)]).then(([hasRoles, hasPermissions]) => {
return !!hasRoles || !!hasPermissions;
});
const validations = toArray(roles).map(role => this.#runValidation(role));
return Promise.all(validations)
.then(results => this.#checkPermissionsIfNeeded(results))
.then(results => results.every(result => result === true));
}
#add(role: string, validationFn: ValidationFn<IqserRole> | string[]) {
const roles: IqserRolesObject = {
...this.#roles$.value,
[role]: { name: role, validationFn },
};
#checkPermissionsIfNeeded(results: List<string | boolean | List>) {
return results.map(result => (isBoolean(result) ? result : this._permissionsService.has(result)));
}
if (Array.isArray(validationFn)) {
this._permissionsService.add(validationFn);
#runValidation(role: string) {
const validationFn = this.#roles$.value[role];
if (isArray(validationFn)) {
return this._permissionsService.has(validationFn);
}
return this.#roles$.next(roles);
return validationFn?.(role, this.#roles$.value) ?? false;
}
#hasRoleKey(roleName: string[]): Promise<boolean> {
const promises = roleName.map(key => {
const role = this.#roles$.value[key];
const hasValidationFn = !!role && !!role.validationFn;
#reduce(roles: List | IqserRoles, initialValue: IqserRoles = {}) {
const result = isArray(roles) ? this.#reduceList(roles, initialValue) : this.#reduceObject(roles, initialValue);
return this.#roles$.next(result);
}
if (hasValidationFn && isFunction<ValidationFn<IqserRole>>(role.validationFn)) {
const validationFn = role.validationFn;
const immutableValue = { ...this.#roles$.value };
return of(null).pipe(
switchMap(async () => validationFn(key, immutableValue)),
catchError(() => of(false)),
);
#reduceObject(roles: IqserRoles, initialValue: IqserRoles = {}) {
return Object.entries(roles).reduce((acc, [role, validationFn]) => {
if (isArray(validationFn)) {
this._permissionsService.add(validationFn);
}
return of(false);
});
const res = from(promises).pipe(
mergeAll(),
first(data => data !== false, false),
map(data => data !== false),
);
return firstValueFrom(res);
return { ...acc, [role]: validationFn };
}, initialValue);
}
#hasPermission(roleNames: string[]): Promise<boolean | undefined> {
const res = from(roleNames).pipe(
mergeMap(key => {
const role = this.#roles$.value[key];
if (role && !isFunction<ValidationFn<IqserRole>>(role.validationFn)) {
return from(role.validationFn).pipe(
mergeMap(permission => this._permissionsService.has(permission)),
every(hasPermission => hasPermission === true),
);
}
return of(false);
}),
first(hasPermission => hasPermission === true, false),
);
return firstValueFrom(res);
#reduceList(roles: List, initialValue: IqserRoles = {}) {
return roles.reduce((acc, role) => ({ ...acc, [role]: () => true }), initialValue);
}
}

View File

@ -1,7 +1,11 @@
import { ActivatedRouteSnapshot, NavigationExtras, Route, RouterStateSnapshot } from '@angular/router';
import { List } from '../utils';
export type IqserPermissions = Record<string, PermissionValidationFn>;
export type IqserRoles = Record<string, RoleValidationFn | List>;
export interface IqserPermissionsRouterData {
allow: string | string[] | AllowFn;
allow: string | List | AllowFn;
redirectTo?: RedirectTo | RedirectToFn;
}
@ -10,29 +14,29 @@ export interface IqserRedirectToNavigationParameters {
navigationExtras?: NavigationExtras | NavigationExtrasFn;
}
export type AllowFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => string | string[];
export type AllowFn = (route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) => string | List;
export type RedirectTo =
| string
| string[]
| List
| IqserRedirectToNavigationParameters
| { [name: string]: IqserRedirectToNavigationParameters | string | RedirectToFn };
| Record<string, IqserRedirectToNavigationParameters | string | RedirectToFn>;
export type RedirectToFn = (failedPermission: 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<T> = (name: string, store: Record<string, T>) => Promise<void | string | boolean> | boolean | string[];
export type RoleValidationFn = (name: string, store: IqserRoles) => Promise<string | List | boolean> | boolean | List;
export type PermissionValidationFn = (name: string, store: IqserPermissions) => Promise<boolean> | boolean;
export type IqserActivatedRouteSnapshot = ActivatedRouteSnapshot & {
data: {
permissions: IqserPermissionsRouterData;
permissions?: IqserPermissionsRouterData;
};
};
export type IqserRoute = Route & {
data: {
permissions: IqserPermissionsRouterData;
permissions?: IqserPermissionsRouterData;
};
};

View File

@ -1,6 +1,7 @@
import { AllowFn, IqserPermissionsRouterData, IqserRedirectToNavigationParameters } from './models/permissions-router-data.model';
import { AllowFn, IqserPermissionsRouterData, IqserRedirectToNavigationParameters } from './types';
import { ActivatedRouteSnapshot, Route, RouterStateSnapshot } from '@angular/router';
import { IqserPermissionsData } from './services/permissions-guard.service';
import { List } from '../utils';
export function isFunction<T>(value: unknown): value is T {
return typeof value === 'function';
@ -16,18 +17,20 @@ export function isPlainObject(value: unknown): boolean {
}
export function isString(value: unknown): value is string {
return !!value && typeof value === 'string';
return typeof value === 'string';
}
export function notEmpty(value?: string | string[]): boolean {
return Array.isArray(value) ? value.length > 0 : !!value;
export function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
export function toArray(value?: string | string[]): string[] {
if (isString(value)) {
return [value];
}
return value ?? [];
export function isArray<T>(value: unknown): value is List<T>;
export function isArray<T>(value: unknown): value is T[] {
return Array.isArray(value);
}
export function toArray(value?: string | List): List {
return isString(value) ? [value] : value ?? [];
}
export function isRedirectWithParameters(object: any | IqserRedirectToNavigationParameters): object is IqserRedirectToNavigationParameters {
@ -39,11 +42,11 @@ export function transformPermission(
route: ActivatedRouteSnapshot | Route,
state?: RouterStateSnapshot,
): IqserPermissionsData {
const only = isFunction<AllowFn>(permissions.allow) ? permissions.allow(route, state) : toArray(permissions.allow);
const allow = isFunction<AllowFn>(permissions.allow) ? permissions.allow(route, state) : toArray(permissions.allow);
const redirectTo = permissions.redirectTo;
return {
allow: only,
allow,
redirectTo,
};
}