diff --git a/backend/src/collections/roledb/roledb.service.ts b/backend/src/collections/roledb/roledb.service.ts index 05030c6..33f1562 100644 --- a/backend/src/collections/roledb/roledb.service.ts +++ b/backend/src/collections/roledb/roledb.service.ts @@ -117,7 +117,7 @@ export class RolesService { return Fail('Cannot modify immutable role'); } - roleToModify.permissions = permissions; + roleToModify.permissions = [...new Set(permissions)]; try { return await this.rolesRepository.save(roleToModify); diff --git a/backend/src/routes/api/roles/roles.controller.ts b/backend/src/routes/api/roles/roles.controller.ts index 7fa3ad3..62c79f5 100644 --- a/backend/src/routes/api/roles/roles.controller.ts +++ b/backend/src/routes/api/roles/roles.controller.ts @@ -54,7 +54,7 @@ export class RolesController { return role; } - @Post('/permissions') + @Post('/update') async updateRole( @Body() body: RoleUpdateRequest, ): Promise { diff --git a/frontend/src/app/models/forms/login.control.ts b/frontend/src/app/models/forms/login.control.ts index 987e421..cffb33c 100644 --- a/frontend/src/app/models/forms/login.control.ts +++ b/frontend/src/app/models/forms/login.control.ts @@ -5,7 +5,7 @@ import { CreateUsernameError, PasswordValidators, UsernameValidators -} from './default-validators'; +} from './user-validators'; import { UserPassModel } from './userpass.model'; export class LoginControl { diff --git a/frontend/src/app/models/forms/register.control.ts b/frontend/src/app/models/forms/register.control.ts index 2791452..649b71c 100644 --- a/frontend/src/app/models/forms/register.control.ts +++ b/frontend/src/app/models/forms/register.control.ts @@ -6,7 +6,7 @@ import { CreateUsernameError, PasswordValidators, UsernameValidators -} from './default-validators'; +} from './user-validators'; import { UserPassModel } from './userpass.model'; export class RegisterControl { diff --git a/frontend/src/app/models/forms/role-validators.ts b/frontend/src/app/models/forms/role-validators.ts new file mode 100644 index 0000000..2bb6ec2 --- /dev/null +++ b/frontend/src/app/models/forms/role-validators.ts @@ -0,0 +1,26 @@ +import { ValidationErrors, Validators } from '@angular/forms'; +import { errorsToError } from './util.validator'; + +export const RoleNameValidators = [ + Validators.minLength(4), + Validators.maxLength(32), + Validators.pattern('^[a-zA-Z0-9]+$'), +]; + +export const CreateRoleNameError = ( + errors: ValidationErrors | null +): string => { + const error = errorsToError(errors); + switch (error) { + case 'required': + return 'Role name is required'; + case 'minlength': + return 'Role name is too short'; + case 'maxlength': + return 'Role name is too long'; + case 'pattern': + return 'Role name can only contain letters and numbers'; + default: + return 'Invalid role name'; + } +}; diff --git a/frontend/src/app/models/forms/role.model.ts b/frontend/src/app/models/forms/role.model.ts new file mode 100644 index 0000000..29db6cd --- /dev/null +++ b/frontend/src/app/models/forms/role.model.ts @@ -0,0 +1,6 @@ +import { Permissions } from 'picsur-shared/dist/dto/permissions'; + +export interface RoleModel { + name: string; + permissions: Permissions; +} diff --git a/frontend/src/app/models/forms/updaterole.control.ts b/frontend/src/app/models/forms/updaterole.control.ts new file mode 100644 index 0000000..ced8dc7 --- /dev/null +++ b/frontend/src/app/models/forms/updaterole.control.ts @@ -0,0 +1,112 @@ +import { FormControl } from '@angular/forms'; +import Fuse from 'fuse.js'; +import { Permission, Permissions } from 'picsur-shared/dist/dto/permissions'; +import { PermanentRolesList } from 'picsur-shared/dist/dto/roles.dto'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { RoleNameValidators } from './role-validators'; +import { RoleModel } from './role.model'; +import { CreateUsernameError } from './user-validators'; + +export class UpdateRoleControl { + // Set once + private permissions: Permissions = []; + + // Variables + private selectablePermissionsSubject = new BehaviorSubject([]); + private permissionsInputSubscription: null | Subscription; + + public rolename = new FormControl('', RoleNameValidators); + + public permissionControl = new FormControl('', []); + public selectablePermissions = + this.selectablePermissionsSubject.asObservable(); + public selectedPermissions: Permissions = []; + + public get rolenameValue() { + return this.rolename.value; + } + + public get rolenameError() { + return CreateUsernameError(this.rolename.errors); + } + + constructor() { + this.permissionsInputSubscription = + this.permissionControl.valueChanges.subscribe((roles) => { + this.updateSelectablePermissions(); + }); + } + + public destroy() { + if (this.permissionsInputSubscription) { + this.permissionsInputSubscription.unsubscribe(); + this.permissionsInputSubscription = null; + } + } + + public addPermission(role: Permission) { + if (!this.selectablePermissionsSubject.value.includes(role)) return; + + this.selectedPermissions.push(role); + this.clearInput(); + } + + public removePermission(role: Permission) { + this.selectedPermissions = this.selectedPermissions.filter( + (r) => r !== role + ); + this.updateSelectablePermissions(); + } + + public isRemovable(role: Permission) { + if (PermanentRolesList.includes(role)) return false; + return true; + } + + // Data interaction + + public putAllPermissions(permissions: Permissions) { + this.permissions = permissions; + this.updateSelectablePermissions(); + } + + public putRoleName(rolename: string) { + this.rolename.setValue(rolename); + } + + public putPermissions(permissions: Permissions) { + this.selectedPermissions = permissions; + this.updateSelectablePermissions(); + } + + public getData(): RoleModel { + return { + name: this.rolenameValue, + permissions: this.selectedPermissions, + }; + } + + // Logic + + private updateSelectablePermissions() { + const availablePermissins = this.permissions.filter( + (r) => !this.selectedPermissions.includes(r) + ); + + const searchValue = this.permissionControl.value; + if (searchValue && availablePermissins.length > 0) { + const fuse = new Fuse(availablePermissins); + const result = fuse + .search(this.permissionControl.value ?? '') + .map((r) => r.item); + + this.selectablePermissionsSubject.next(result); + } else { + this.selectablePermissionsSubject.next(availablePermissins); + } + } + + private clearInput() { + this.permissionControl.setValue(''); + } +} diff --git a/frontend/src/app/models/forms/updateuser.control.ts b/frontend/src/app/models/forms/updateuser.control.ts index dc0b82f..559b33d 100644 --- a/frontend/src/app/models/forms/updateuser.control.ts +++ b/frontend/src/app/models/forms/updateuser.control.ts @@ -4,13 +4,13 @@ import { Permissions } from 'picsur-shared/dist/dto/permissions'; import { PermanentRolesList } from 'picsur-shared/dist/dto/roles.dto'; import { ERole } from 'picsur-shared/dist/entities/role.entity'; import { BehaviorSubject, Subscription } from 'rxjs'; +import { FullUserModel } from './fulluser.model'; import { CreatePasswordError, CreateUsernameError, PasswordValidators, UsernameValidators -} from './default-validators'; -import { FullUserModel } from './fulluser.model'; +} from './user-validators'; export class UpdateUserControl { // Set once diff --git a/frontend/src/app/models/forms/default-validators.ts b/frontend/src/app/models/forms/user-validators.ts similarity index 88% rename from frontend/src/app/models/forms/default-validators.ts rename to frontend/src/app/models/forms/user-validators.ts index 314a7c9..e028f9e 100644 --- a/frontend/src/app/models/forms/default-validators.ts +++ b/frontend/src/app/models/forms/user-validators.ts @@ -1,16 +1,9 @@ import { ValidationErrors, Validators } from '@angular/forms'; +import { errorsToError } from './util.validator'; // Match this with user entity in shared lib // (Security is not handled here, this is only for the user) -function errorsToError(errors: ValidationErrors | null): string { - if (errors) { - const error = Object.keys(errors)[0]; - return error; - } - return 'unkown'; -} - export const UsernameValidators = [ Validators.minLength(4), Validators.maxLength(32), @@ -57,3 +50,4 @@ export const CreatePasswordError = ( return 'Invalid password'; } }; + diff --git a/frontend/src/app/models/forms/util.validator.ts b/frontend/src/app/models/forms/util.validator.ts new file mode 100644 index 0000000..6f93154 --- /dev/null +++ b/frontend/src/app/models/forms/util.validator.ts @@ -0,0 +1,9 @@ +import { ValidationErrors } from '@angular/forms'; + +export function errorsToError(errors: ValidationErrors | null): string { + if (errors) { + const error = Object.keys(errors)[0]; + return error; + } + return 'unkown'; +} diff --git a/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.html b/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.html new file mode 100644 index 0000000..471b18b --- /dev/null +++ b/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.html @@ -0,0 +1,90 @@ + +

Editing {{ model.rolenameValue }}

+
+ +

Add new role

+
+ +
+
+
+ + Role name + + + {{ model.rolenameError }} + + +
+
+ +
+
+ + Permissions + + + {{ uiFriendlyPermission(permission) }} + + + + + + + {{ permission }} + + + +
+
+ +
+
+ + + +
+
+
diff --git a/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.scss b/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.scss new file mode 100644 index 0000000..c7acb4b --- /dev/null +++ b/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.scss @@ -0,0 +1,3 @@ +mat-form-field { + width: 100%; +} diff --git a/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.ts b/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.ts new file mode 100644 index 0000000..aed0432 --- /dev/null +++ b/frontend/src/app/routes/settings/roles/settings-roles-edit/settings-roles-edit.component.ts @@ -0,0 +1,126 @@ +import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; +import { Component, OnInit } from '@angular/core'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatChipInputEvent } from '@angular/material/chips'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + Permission, + PermissionsList, + UIFriendlyPermissions +} from 'picsur-shared/dist/dto/permissions'; +import { HasFailed } from 'picsur-shared/dist/types'; +import { UpdateRoleControl } from 'src/app/models/forms/updaterole.control'; +import { SnackBarType } from 'src/app/models/snack-bar-type'; +import { RolesService } from 'src/app/services/api/roles.service'; +import { UtilService } from 'src/app/util/util.service'; + +enum EditMode { + edit = 'edit', + add = 'add', +} + +@Component({ + selector: 'app-settings-roles-edit', + templateUrl: './settings-roles-edit.component.html', + styleUrls: ['./settings-roles-edit.component.scss'], +}) +export class SettingsRolesEditComponent implements OnInit { + readonly separatorKeysCodes: number[] = [ENTER, COMMA, SPACE]; + + private mode: EditMode = EditMode.edit; + + model = new UpdateRoleControl(); + + get adding() { + return this.mode === EditMode.add; + } + get editing() { + return this.mode === EditMode.edit; + } + + constructor( + private route: ActivatedRoute, + private router: Router, + private utilService: UtilService, + private rolesService: RolesService + ) {} + + ngOnInit() { + Promise.all([this.initRole(), this.initPermissions()]).catch(console.error); + } + + private async initRole() { + const rolename = this.route.snapshot.paramMap.get('role'); + if (!rolename) { + this.mode = EditMode.add; + return; + } + + this.mode = EditMode.edit; + this.model.putRoleName(rolename); + + const role = await this.rolesService.getRole(rolename); + if (HasFailed(role)) { + this.utilService.showSnackBar('Failed to get role', SnackBarType.Error); + return; + } + + this.model.putRoleName(role.name); + this.model.putPermissions(role.permissions); + } + + private async initPermissions() { + this.model.putAllPermissions(PermissionsList); + } + + removePermission(permission: Permission) { + this.model.removePermission(permission); + } + + addRole(event: MatChipInputEvent) { + const value = (event.value ?? '').trim(); + this.model.addPermission(value as Permission); + } + + selectedRole(event: MatAutocompleteSelectedEvent): void { + this.model.addPermission(event.option.viewValue as Permission); + } + + cancel() { + this.router.navigate(['/settings/roles']); + } + + uiFriendlyPermission(permission: Permission) { + return UIFriendlyPermissions[permission]; + } + + async updateUser() { + const data = this.model.getData(); + + if (this.adding) { + const resultRole = await this.rolesService.createRole(data); + if (HasFailed(resultRole)) { + this.utilService.showSnackBar( + 'Failed to create role', + SnackBarType.Error + ); + return; + } + + this.utilService.showSnackBar('Role created', SnackBarType.Success); + } else { + const resultRole = await this.rolesService.updateRole(data); + if (HasFailed(resultRole)) { + this.utilService.showSnackBar( + 'Failed to update role', + SnackBarType.Error + ); + return; + } + + this.utilService.showSnackBar('Role updated', SnackBarType.Success); + } + + this.router.navigate(['/settings/roles']); + } +} diff --git a/frontend/src/app/routes/settings/roles/settings-roles.component.html b/frontend/src/app/routes/settings/roles/settings-roles.component.html index 42a708d..e0a0cb8 100644 --- a/frontend/src/app/routes/settings/roles/settings-roles.component.html +++ b/frontend/src/app/routes/settings/roles/settings-roles.component.html @@ -7,7 +7,9 @@ - Permissions + Permissions Actions -