Add role edit view

This commit is contained in:
rubikscraft 2022-03-24 11:45:43 +01:00
parent e4575b7f5b
commit 6417651419
No known key found for this signature in database
GPG key ID: 1463EBE9200A5CD4
25 changed files with 572 additions and 81 deletions

View file

@ -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);

View file

@ -54,7 +54,7 @@ export class RolesController {
return role;
}
@Post('/permissions')
@Post('/update')
async updateRole(
@Body() body: RoleUpdateRequest,
): Promise<RoleUpdateResponse> {

View file

@ -5,7 +5,7 @@ import {
CreateUsernameError,
PasswordValidators,
UsernameValidators
} from './default-validators';
} from './user-validators';
import { UserPassModel } from './userpass.model';
export class LoginControl {

View file

@ -6,7 +6,7 @@ import {
CreateUsernameError,
PasswordValidators,
UsernameValidators
} from './default-validators';
} from './user-validators';
import { UserPassModel } from './userpass.model';
export class RegisterControl {

View file

@ -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';
}
};

View file

@ -0,0 +1,6 @@
import { Permissions } from 'picsur-shared/dist/dto/permissions';
export interface RoleModel {
name: string;
permissions: Permissions;
}

View file

@ -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<Permissions>([]);
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('');
}
}

View file

@ -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

View file

@ -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';
}
};

View file

@ -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';
}

View file

@ -0,0 +1,90 @@
<ng-container *ngIf="editing">
<h1>Editing {{ model.rolenameValue }}</h1>
</ng-container>
<ng-container *ngIf="adding">
<h1>Add new role</h1>
</ng-container>
<form (ngSubmit)="updateUser()">
<div class="row" *ngIf="adding">
<div class="col-lg-6 col-12">
<mat-form-field appearance="outline" color="accent">
<mat-label>Role name</mat-label>
<input
matInput
type="text"
[formControl]="model.rolename"
name="rolename"
autocorrect="off"
autocapitalize="none"
required
/>
<mat-error *ngIf="model.rolename.errors">
{{ model.rolenameError }}
</mat-error>
</mat-form-field>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-12">
<mat-form-field appearance="outline" color="accent">
<mat-label>Permissions</mat-label>
<mat-chip-list #chipList aria-label="Permissions Selection">
<mat-chip
*ngFor="let permission of model.selectedPermissions"
[removable]="model.isRemovable(permission)"
[disabled]="!model.isRemovable(permission)"
(removed)="removePermission(permission)"
>
{{ uiFriendlyPermission(permission) }}
<button *ngIf="model.isRemovable(permission)" matChipRemove>
<mat-icon>cancel</mat-icon>
</button>
</mat-chip>
<input
placeholder="Add permission..."
#fruitInput
[formControl]="model.permissionControl"
[value]="model.permissionControl.value"
[matAutocomplete]="auto"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addRole($event)"
autocorrect="off"
autocapitalize="none"
/>
</mat-chip-list>
<mat-autocomplete
#auto="matAutocomplete"
(optionSelected)="selectedRole($event)"
>
<mat-option
*ngFor="let permission of model.selectablePermissions | async"
[value]="permission"
>
{{ permission }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
</div>
<div class="row">
<div class="col-12 py-4">
<button mat-raised-button color="accent" type="submit">
{{ editing ? "Update" : "Add" }}
</button>
<button
mat-raised-button
class="ms-2"
color="primary"
type="button"
(click)="cancel()"
>
Cancel
</button>
</div>
</div>
</form>

View file

@ -0,0 +1,3 @@
mat-form-field {
width: 100%;
}

View file

@ -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']);
}
}

View file

@ -7,7 +7,9 @@
</ng-container>
<ng-container matColumnDef="permissions">
<mat-header-cell class="d-none d-md-flex" *matHeaderCellDef>Permissions</mat-header-cell>
<mat-header-cell class="d-none d-md-flex" *matHeaderCellDef
>Permissions</mat-header-cell
>
<mat-cell class="d-none d-md-flex" *matCellDef="let role">
<mat-chip-list aria-label="Role Permissions">
<mat-chip
@ -38,10 +40,14 @@
<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef>Actions</mat-header-cell>
<mat-cell *matCellDef="let role">
<button mat-icon-button (click)="editRole(role)">
<mat-icon fontSet="material-icons-outlined" aria-label="Edit"
>edit</mat-icon
>
<button
*ngIf="!isImmutable(role)"
mat-icon-button
(click)="editRole(role)"
>
<mat-icon fontSet="material-icons-outlined" aria-label="Edit">
edit
</mat-icon>
</button>
<button
*ngIf="!isSystem(role)"

View file

@ -1,10 +1,12 @@
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import {
Permission,
UIFriendlyPermissions
} from 'picsur-shared/dist/dto/permissions';
import { ImmuteableRolesList, SystemRolesList } from 'picsur-shared/dist/dto/roles.dto';
import { ERole } from 'picsur-shared/dist/entities/role.entity';
import { HasFailed } from 'picsur-shared/dist/types';
import { SnackBarType } from 'src/app/models/snack-bar-type';
@ -31,18 +33,72 @@ export class SettingsRolesComponent implements OnInit, AfterViewInit {
constructor(
private rolesService: RolesService,
private utilService: UtilService
private utilService: UtilService,
private router: Router
) {}
ngOnInit(): void {
this.initRoles().catch(console.error);
this.fetchRoles().catch(console.error);
}
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
}
async initRoles() {
addRole() {
this.router.navigate(['/settings/roles/add']);
}
editRole(role: ERole) {
this.router.navigate(['/settings/roles/edit', role.name]);
}
async deleteRole(role: ERole) {
const pressedButton = await this.utilService.showDialog({
title: `Are you sure you want to delete ${role.name}?`,
description: 'This action cannot be undone.',
buttons: [
{
color: 'red',
name: 'delete',
text: 'Delete',
},
{
color: 'primary',
name: 'cancel',
text: 'Cancel',
},
],
});
if (pressedButton === 'delete') {
const result = await this.rolesService.deleteRole(role.name);
if (HasFailed(result)) {
this.utilService.showSnackBar(
'Failed to delete user',
SnackBarType.Error
);
} else {
this.utilService.showSnackBar('User deleted', SnackBarType.Success);
}
}
await this.fetchRoles();
}
uiFriendlyPermission(permission: Permission) {
return UIFriendlyPermissions[permission];
}
isSystem(role: ERole) {
return SystemRolesList.includes(role.name);
}
isImmutable(role: ERole) {
return ImmuteableRolesList.includes(role.name);
}
private async fetchRoles() {
const roles = await this.rolesService.getRoles();
if (HasFailed(roles)) {
this.utilService.showSnackBar('Failed to load roles', SnackBarType.Error);
@ -51,18 +107,4 @@ export class SettingsRolesComponent implements OnInit, AfterViewInit {
this.dataSource.data = roles;
}
addRole() {}
editRole(role: ERole) {}
deleteRole(role: ERole) {}
uiFriendlyPermission(permission: Permission) {
return UIFriendlyPermissions[permission];
}
isSystem(role: ERole) {
return false;
}
}

View file

@ -1,15 +1,20 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatTableModule } from '@angular/material/table';
import { SettingsRolesEditComponent } from './settings-roles-edit/settings-roles-edit.component';
import { SettingsRolesComponent } from './settings-roles.component';
import { SettingsRolesRoutingModule } from './settings-roles.routing.module';
@NgModule({
declarations: [SettingsRolesComponent],
declarations: [SettingsRolesComponent, SettingsRolesEditComponent],
imports: [
CommonModule,
SettingsRolesRoutingModule,
@ -18,6 +23,11 @@ import { SettingsRolesRoutingModule } from './settings-roles.routing.module';
MatTableModule,
MatChipsModule,
MatPaginatorModule,
FormsModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
MatAutocompleteModule
],
})
export class SettingsRolesRouteModule {}

View file

@ -1,6 +1,7 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { PRoutes } from 'src/app/models/picsur-routes';
import { SettingsRolesEditComponent } from './settings-roles-edit/settings-roles-edit.component';
import { SettingsRolesComponent } from './settings-roles.component';
const routes: PRoutes = [
@ -8,6 +9,14 @@ const routes: PRoutes = [
path: '',
component: SettingsRolesComponent,
},
{
path: 'edit/:role',
component: SettingsRolesEditComponent,
},
{
path: 'add',
component: SettingsRolesEditComponent,
}
];
@NgModule({

View file

@ -6,13 +6,6 @@
</ng-container>
<form (ngSubmit)="updateUser()">
<div class="row">
<div class="col-12 py-2" *ngIf="updateFail">
<mat-error *ngIf="adding"> Failed to add user </mat-error>
<mat-error *ngIf="editing"> Failed to update user </mat-error>
</div>
</div>
<div class="row" *ngIf="adding">
<div class="col-lg-6 col-12">
<mat-form-field appearance="outline" color="accent">

View file

@ -29,7 +29,6 @@ export class SettingsUsersEditComponent implements OnInit {
private mode: EditMode = EditMode.edit;
model = new UpdateUserControl();
updateFail: boolean = false;
get adding() {
return this.mode === EditMode.add;

View file

@ -1,7 +1,18 @@
import { Injectable } from '@angular/core';
import { RoleListResponse } from 'picsur-shared/dist/dto/api/roles.dto';
import {
RoleCreateRequest,
RoleCreateResponse,
RoleDeleteRequest,
RoleDeleteResponse,
RoleInfoRequest,
RoleInfoResponse,
RoleListResponse,
RoleUpdateRequest,
RoleUpdateResponse
} from 'picsur-shared/dist/dto/api/roles.dto';
import { ERole } from 'picsur-shared/dist/entities/role.entity';
import { AsyncFailable, HasFailed } from 'picsur-shared/dist/types';
import { RoleModel } from 'src/app/models/forms/role.model';
import { ApiService } from './api.service';
@Injectable({
@ -22,4 +33,56 @@ export class RolesService {
return result.roles;
}
public async getRole(name: string): AsyncFailable<ERole> {
const body = {
name,
};
const result = await this.apiService.post(
RoleInfoRequest,
RoleInfoResponse,
'/api/roles/info',
body
);
return result;
}
public async createRole(role: RoleModel): AsyncFailable<ERole> {
const result = await this.apiService.post(
RoleCreateRequest,
RoleCreateResponse,
'/api/roles/create',
role
);
return result;
}
public async updateRole(role: RoleModel): AsyncFailable<ERole> {
const result = await this.apiService.post(
RoleUpdateRequest,
RoleUpdateResponse,
'/api/roles/update',
role
);
return result;
}
public async deleteRole(name: string): AsyncFailable<ERole> {
const body = {
name,
};
const result = await this.apiService.post(
RoleDeleteRequest,
RoleDeleteResponse,
'/api/roles/delete',
body
);
return result;
}
}

View file

@ -1,4 +1,4 @@
import { Component, Inject, OnInit } from '@angular/core';
import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
export interface DialogButton {
@ -19,16 +19,12 @@ export interface DialogData {
templateUrl: './confirm-dialog.component.html',
styleUrls: ['./confirm-dialog.component.scss'],
})
export class ConfirmDialogComponent implements OnInit {
export class ConfirmDialogComponent {
constructor(
public dialogRef: MatDialogRef<ConfirmDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: DialogData,
) {}
ngOnInit(): void {
console.log('Dialog', this.data);
}
onButton(name: string) {
this.dialogRef.close(name);
}

View file

@ -1,15 +1,18 @@
import { Type } from 'class-transformer';
import { IsArray, IsDefined, IsEnum, IsInt, IsNotEmpty, IsPositive, IsString, ValidateNested } from 'class-validator';
import { ERole } from '../../entities/role.entity';
import { Permissions, PermissionsList } from '../permissions';
import {
IsArray,
IsDefined,
IsInt, IsPositive,
ValidateNested
} from 'class-validator';
import {
ERole,
RoleNameObject,
RoleNamePermsObject
} from '../../entities/role.entity';
// RoleInfo
export class RoleInfoRequest {
@IsNotEmpty()
@IsString()
name: string;
}
export class RoleInfoRequest extends RoleNameObject {}
export class RoleInfoResponse extends ERole {}
// RoleList
@ -27,15 +30,7 @@ export class RoleListResponse {
}
// RoleUpdate
export class RoleUpdateRequest {
@IsNotEmpty()
@IsString()
name: string;
@IsArray()
@IsEnum(PermissionsList, { each: true })
permissions: Permissions;
}
export class RoleUpdateRequest extends RoleNamePermsObject {}
export class RoleUpdateResponse extends ERole {}
// RoleCreate
@ -43,8 +38,5 @@ export class RoleCreateRequest extends ERole {}
export class RoleCreateResponse extends ERole {}
// RoleDelete
export class RoleDeleteRequest {
@IsNotEmpty()
name: string;
}
export class RoleDeleteRequest extends RoleNameObject {}
export class RoleDeleteResponse extends ERole {}

View file

@ -5,10 +5,12 @@ import { Permission, Permissions, PermissionsList } from './permissions';
// These roles can never be removed or added to a user.
const PermanentRolesTuple = tuple('guest', 'user');
// These roles can never be modified
const ImmuteableRolesTuple = tuple('admin');
// These roles can never be removed from the server
const SystemRolesTuple = tuple(...PermanentRolesTuple, ...ImmuteableRolesTuple);
// These roles will be applied by default to new users
export const DefaultRolesList: string[] = ['user'];

View file

@ -1,16 +1,20 @@
import { IsArray, IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { IsArray, IsEnum } from 'class-validator';
import { Permissions, PermissionsList } from '../dto/permissions';
import { EntityID } from '../validators/entity-id.validator';
import { IsRoleName } from '../validators/role.validators';
export class ERole {
@EntityID()
id?: number;
@IsNotEmpty()
@IsString()
export class RoleNameObject {
@IsRoleName()
name: string;
}
export class RoleNamePermsObject extends RoleNameObject {
@IsArray()
@IsEnum(PermissionsList, { each: true })
permissions: Permissions;
}
export class ERole extends RoleNamePermsObject {
@EntityID()
id?: number;
}

View file

@ -0,0 +1,9 @@
import { IsAlphanumeric, IsNotEmpty, IsString, Length } from 'class-validator';
import { ComposeValidators } from './compose.validator';
export const IsRoleName = ComposeValidators(
IsNotEmpty(),
IsString(),
Length(4, 32),
IsAlphanumeric(),
);