From 7f5298acbaf363539f54620316368cb8a1cfae43 Mon Sep 17 00:00:00 2001 From: Ricardo Marques Date: Tue, 12 Jun 2018 15:55:08 +0100 Subject: [PATCH] mgr/dashboard: Ceph dashboard user management Fixes: https://tracker.ceph.com/issues/24446 Signed-off-by: Ricardo Marques --- .../frontend/src/app/app-routing.module.ts | 5 + .../frontend/src/app/core/auth/auth.module.ts | 18 +- .../app/core/auth/logout/logout.component.ts | 9 +- .../auth/user-form/user-form-mode.enum.ts | 3 + .../auth/user-form/user-form-role.model.ts | 11 + .../auth/user-form/user-form.component.html | 197 ++++++++++++++ .../auth/user-form/user-form.component.scss | 0 .../user-form/user-form.component.spec.ts | 243 ++++++++++++++++++ .../auth/user-form/user-form.component.ts | 231 +++++++++++++++++ .../core/auth/user-form/user-form.model.ts | 7 + .../auth/user-list/user-list.component.html | 86 +++++++ .../auth/user-list/user-list.component.scss | 0 .../user-list/user-list.component.spec.ts | 30 +++ .../auth/user-list/user-list.component.ts | 114 ++++++++ .../administration.component.html | 18 ++ .../administration.component.scss | 0 .../administration.component.spec.ts | 26 ++ .../administration.component.ts | 19 ++ .../app/core/navigation/navigation.module.ts | 4 +- .../navigation/navigation.component.html | 3 + .../src/app/shared/api/auth.service.ts | 14 +- .../src/app/shared/api/role.service.spec.ts | 35 +++ .../src/app/shared/api/role.service.ts | 15 ++ .../src/app/shared/api/user.service.spec.ts | 74 ++++++ .../src/app/shared/api/user.service.ts | 32 +++ .../src/app/shared/enum/components.enum.ts | 3 +- .../src/app/shared/models/permissions.ts | 2 + .../shared/services/auth-storage.service.ts | 4 + 28 files changed, 1184 insertions(+), 19 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-mode.enum.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 3ed80e16c40c8..0d44e19ec7d9e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -20,6 +20,8 @@ import { RgwDaemonListComponent } from './ceph/rgw/rgw-daemon-list/rgw-daemon-li import { RgwUserFormComponent } from './ceph/rgw/rgw-user-form/rgw-user-form.component'; import { RgwUserListComponent } from './ceph/rgw/rgw-user-list/rgw-user-list.component'; import { LoginComponent } from './core/auth/login/login.component'; +import { UserFormComponent } from './core/auth/user-form/user-form.component'; +import { UserListComponent } from './core/auth/user-list/user-list.component'; import { ForbiddenComponent } from './core/forbidden/forbidden.component'; import { NotFoundComponent } from './core/not-found/not-found.component'; import { AuthGuardService } from './shared/services/auth-guard.service'; @@ -108,6 +110,9 @@ const routes: Routes = [ { path: 'cephfs', component: CephfsListComponent, canActivate: [AuthGuardService] }, { path: 'configuration', component: ConfigurationComponent, canActivate: [AuthGuardService] }, { path: 'mirroring', component: MirroringComponent, canActivate: [AuthGuardService] }, + { path: 'users', component: UserListComponent, canActivate: [AuthGuardService] }, + { path: 'users/add', component: UserFormComponent, canActivate: [AuthGuardService] }, + { path: 'users/edit/:username', component: UserFormComponent, canActivate: [AuthGuardService] }, { path: '403', component: ForbiddenComponent }, { path: '404', component: NotFoundComponent }, { path: 'osd', component: OsdListComponent, canActivate: [AuthGuardService] }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts index e96b1b30b8f84..f9e395c145142 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts @@ -1,18 +1,28 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { SharedModule } from '../../shared/shared.module'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; + +import { BsDropdownModule, PopoverModule, TabsModule } from 'ngx-bootstrap'; +import { SharedModule } from '../../shared/shared.module'; import { LoginComponent } from './login/login.component'; import { LogoutComponent } from './logout/logout.component'; +import { UserFormComponent } from './user-form/user-form.component'; +import { UserListComponent } from './user-list/user-list.component'; @NgModule({ imports: [ + BsDropdownModule.forRoot(), CommonModule, FormsModule, - SharedModule + PopoverModule.forRoot(), + ReactiveFormsModule, + SharedModule, + TabsModule.forRoot(), + RouterModule ], - declarations: [LoginComponent, LogoutComponent], + declarations: [LoginComponent, LogoutComponent, UserListComponent, UserFormComponent], exports: [LogoutComponent] }) export class AuthModule { } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/logout/logout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/logout/logout.component.ts index 4a8405e2eefad..36098d9e357ec 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/logout/logout.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/logout/logout.component.ts @@ -9,15 +9,12 @@ import { AuthService } from '../../../shared/api/auth.service'; styleUrls: ['./logout.component.scss'] }) export class LogoutComponent implements OnInit { + constructor(private authService: AuthService, private router: Router) {} - constructor(private authService: AuthService, - private router: Router) { } - - ngOnInit() { - } + ngOnInit() {} logout() { - this.authService.logout().then(() => { + this.authService.logout(() => { this.router.navigate(['/login']); }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-mode.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-mode.enum.ts new file mode 100644 index 0000000000000..8cae7d15fc033 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-mode.enum.ts @@ -0,0 +1,3 @@ +export enum UserFormMode { + editing = 'editing' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts new file mode 100644 index 0000000000000..6f3ce000a0604 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts @@ -0,0 +1,11 @@ +export class UserFormRoleModel implements SelectBadgesOption { + name: string; + description: string; + selected = false; + scopes_permissions: object; + + constructor(name, description) { + this.name = name; + this.description = description; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html new file mode 100644 index 0000000000000..60dd162dc47ee --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html @@ -0,0 +1,197 @@ + + +
+
+
+
+

+ {mode, select, editing {Edit} other {Add}} User +

+
+
+ + +
+ +
+ + + This field is required. + +
+
+ + +
+ +
+
+ + + + +
+ + This field is required. + +
+
+ + +
+ +
+
+ + + + +
+ + This field is required. + + + Password confirmation doesn't match the password. + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ + + + Invalid email. + +
+
+ + + +
+ + + +
+ +
+ +
+
+
+ + +

You are about to remove "user read / update" permissions from your own user.

+
+

If you continue, you will no longer be able to add or remove roles from any user.

+ + Are you sure you want to continue? +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts new file mode 100644 index 0000000000000..5dfaa74ba57e8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts @@ -0,0 +1,243 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastModule } from 'ng2-toastr'; +import { BsModalService } from 'ngx-bootstrap'; +import { of } from 'rxjs'; + +import { configureTestBed } from '../../../../testing/unit-test-helper'; +import { RoleService } from '../../../shared/api/role.service'; +import { UserService } from '../../../shared/api/user.service'; +import { ComponentsModule } from '../../../shared/components/components.module'; +import { CdFormGroup } from '../../../shared/forms/cd-form-group'; +import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../shared/services/notification.service'; +import { SharedModule } from '../../../shared/shared.module'; +import { UserFormComponent } from './user-form.component'; +import { UserFormModel } from './user-form.model'; + +describe('UserFormComponent', () => { + let component: UserFormComponent; + let form: CdFormGroup; + let fixture: ComponentFixture; + let httpTesting: HttpTestingController; + let userService: UserService; + let modalService: BsModalService; + let router: Router; + const setUrl = (url) => Object.defineProperty(router, 'url', { value: url }); + + @Component({ selector: 'cd-fake', template: '' }) + class FakeComponent {} + + const routes: Routes = [ + { path: 'login', component: FakeComponent }, + { path: 'users', component: FakeComponent } + ]; + + configureTestBed({ + imports: [ + [RouterTestingModule.withRoutes(routes)], + HttpClientTestingModule, + ReactiveFormsModule, + RouterTestingModule, + ComponentsModule, + ToastModule.forRoot(), + SharedModule + ], + declarations: [UserFormComponent, FakeComponent] + }, true); + + beforeEach(() => { + fixture = TestBed.createComponent(UserFormComponent); + component = fixture.componentInstance; + form = component.userForm; + httpTesting = TestBed.get(HttpTestingController); + userService = TestBed.get(UserService); + modalService = TestBed.get(BsModalService); + router = TestBed.get(Router); + spyOn(router, 'navigate'); + fixture.detectChanges(); + const notify = TestBed.get(NotificationService); + spyOn(notify, 'show'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(form).toBeTruthy(); + }); + + describe('create mode', () => { + beforeEach(() => { + setUrl('/users/add'); + component.ngOnInit(); + }); + + it('should not disable fields', () => { + ['username', 'name', 'password', 'confirmpassword', 'email', 'roles'].forEach((key) => + expect(form.get(key).disabled).toBeFalsy() + ); + }); + + it('should validate username required', () => { + form.get('username').setValue(''); + expect(form.get('username').hasError('required')).toBeTruthy(); + }); + + it('should validate password required', () => { + ['password', 'confirmpassword'].forEach((key) => + expect(form.get(key).hasError('required')).toBeTruthy() + ); + }); + + it('should validate password match', () => { + form.get('password').setValue('aaa'); + form.get('confirmpassword').setValue('bbb'); + expect(form.get('confirmpassword').hasError('match')).toBeTruthy(); + form.get('confirmpassword').setValue('aaa'); + expect(form.get('confirmpassword').valid).toBeTruthy(); + }); + + it('should validate email', () => { + form.get('email').setValue('aaa'); + expect(form.get('email').hasError('email')).toBeTruthy(); + }); + + it('should set mode', () => { + expect(component.mode).toBeUndefined(); + }); + + it('should submit', () => { + const user: UserFormModel = { + username: 'user0', + password: 'pass0', + name: 'User 0', + email: 'user0@email.com', + roles: ['administrator'] + }; + Object.keys(user).forEach((k) => form.get(k).setValue(user[k])); + form.get('confirmpassword').setValue(user.password); + component.submit(); + const userReq = httpTesting.expectOne('api/user'); + expect(userReq.request.method).toBe('POST'); + expect(userReq.request.body).toEqual(user); + userReq.flush({}); + expect(router.navigate).toHaveBeenCalledWith(['/users']); + }); + }); + + describe('edit mode', () => { + const user: UserFormModel = { + username: 'user1', + password: undefined, + name: 'User 1', + email: 'user1@email.com', + roles: ['administrator'] + }; + const roles = [ + { + name: 'administrator', + description: 'Administrator', + scopes_permissions: { + user: ['create', 'delete', 'read', 'update'] + } + }, + { + name: 'read-only', + description: 'Read-Only', + scopes_permissions: { + user: ['read'] + } + }, + { + name: 'user-manager', + description: 'User Manager', + scopes_permissions: { + user: ['create', 'delete', 'read', 'update'] + } + } + ]; + + beforeEach(() => { + spyOn(userService, 'get').and.callFake(() => of(user)); + spyOn(TestBed.get(RoleService), 'list').and.callFake(() => of(roles)); + setUrl('/users/edit/user1'); + component.ngOnInit(); + const req = httpTesting.expectOne('api/role'); + expect(req.request.method).toBe('GET'); + req.flush(roles); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should disable fields if editing', () => { + expect(form.get('username').disabled).toBeTruthy(); + ['name', 'password', 'confirmpassword', 'email', 'roles'].forEach((key) => + expect(form.get(key).disabled).toBeFalsy() + ); + }); + + it('should set control values', () => { + ['username', 'name', 'email', 'roles'].forEach((key) => + expect(form.getValue(key)).toBe(user[key]) + ); + ['password', 'confirmpassword'].forEach((key) => expect(form.getValue(key)).toBeFalsy()); + }); + + it('should set mode', () => { + expect(component.mode).toBe('editing'); + }); + + it('should validate password not required', () => { + ['password', 'confirmpassword'].forEach((key) => { + form.get(key).setValue(''); + expect(form.get(key).hasError('required')).toBeFalsy(); + }); + }); + + it('should alert if user is removing needed role permission', () => { + spyOn(TestBed.get(AuthStorageService), 'getUsername').and.callFake(() => user.username); + let modalBodyTpl = null; + spyOn(modalService, 'show').and.callFake((content, config) => { + modalBodyTpl = config.initialState.bodyTpl; + }); + form.get('roles').setValue(['read-only']); + component.submit(); + expect(modalBodyTpl).toEqual(component.removeSelfUserReadUpdatePermissionTpl); + }); + + it('should logout if current user roles have been changed', () => { + spyOn(TestBed.get(AuthStorageService), 'getUsername').and.callFake(() => user.username); + form.get('roles').setValue(['user-manager']); + component.submit(); + const userReq = httpTesting.expectOne(`api/user/${user.username}`); + expect(userReq.request.method).toBe('PUT'); + userReq.flush({}); + const authReq = httpTesting.expectOne('api/auth'); + expect(authReq.request.method).toBe('DELETE'); + authReq.flush(null); + expect(router.navigate).toHaveBeenCalledWith(['/login']); + }); + + it('should submit', () => { + spyOn(TestBed.get(AuthStorageService), 'getUsername').and.callFake(() => user.username); + component.submit(); + const userReq = httpTesting.expectOne(`api/user/${user.username}`); + expect(userReq.request.method).toBe('PUT'); + expect(userReq.request.body).toEqual({ + username: 'user1', + password: '', + name: 'User 1', + email: 'user1@email.com', + roles: ['administrator'] + }); + userReq.flush({}); + expect(router.navigate).toHaveBeenCalledWith(['/users']); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts new file mode 100644 index 0000000000000..02ff0340b3aeb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts @@ -0,0 +1,231 @@ +import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import * as _ from 'lodash'; +import { BsModalRef, BsModalService } from 'ngx-bootstrap'; + +import { AuthService } from '../../../shared/api/auth.service'; +import { RoleService } from '../../../shared/api/role.service'; +import { UserService } from '../../../shared/api/user.service'; +import { ConfirmationModalComponent } from '../../../shared/components/confirmation-modal/confirmation-modal.component'; +import { NotificationType } from '../../../shared/enum/notification-type.enum'; +import { CdFormGroup } from '../../../shared/forms/cd-form-group'; +import { CdValidators } from '../../../shared/forms/cd-validators'; +import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../shared/services/notification.service'; +import { UserFormMode } from './user-form-mode.enum'; +import { UserFormRoleModel } from './user-form-role.model'; +import { UserFormModel } from './user-form.model'; + +@Component({ + selector: 'cd-user-form', + templateUrl: './user-form.component.html', + styleUrls: ['./user-form.component.scss'] +}) +export class UserFormComponent implements OnInit { + @ViewChild('removeSelfUserReadUpdatePermissionTpl') + removeSelfUserReadUpdatePermissionTpl: TemplateRef; + + modalRef: BsModalRef; + + userForm: CdFormGroup; + response: UserFormModel; + + userFormMode = UserFormMode; + mode: UserFormMode; + allRoles: Array; + + constructor( + private authService: AuthService, + private authStorageService: AuthStorageService, + private route: ActivatedRoute, + private router: Router, + private modalService: BsModalService, + private roleService: RoleService, + private userService: UserService, + private notificationService: NotificationService + ) { + this.createForm(); + } + + createForm() { + this.userForm = new CdFormGroup( + { + username: new FormControl('', { + validators: [Validators.required] + }), + name: new FormControl(''), + password: new FormControl('', { + validators: [] + }), + confirmpassword: new FormControl('', { + updateOn: 'blur', + validators: [] + }), + email: new FormControl('', { + validators: [Validators.email] + }), + roles: new FormControl([]) + }, + { + validators: [CdValidators.match('password', 'confirmpassword')] + } + ); + } + + ngOnInit() { + if (this.router.url.startsWith('/users/edit')) { + this.mode = this.userFormMode.editing; + } + this.roleService.list().subscribe((roles: Array) => { + this.allRoles = roles; + }); + if (this.mode === this.userFormMode.editing) { + this.initEdit(); + } else { + this.initAdd(); + } + } + + initAdd() { + ['password', 'confirmpassword'].forEach((controlName) => + this.userForm.get(controlName).setValidators([Validators.required]) + ); + } + + initEdit() { + ['password', 'confirmpassword'].forEach((controlName) => + this.userForm.get(controlName).setValidators([]) + ); + this.disableForEdit(); + this.route.params.subscribe((params: { username: string }) => { + const username = params.username; + this.userService.get(username).subscribe((userFormModel: UserFormModel) => { + this.response = _.cloneDeep(userFormModel); + this.setResponse(userFormModel); + }); + }); + } + + disableForEdit() { + this.userForm.get('username').disable(); + } + + setResponse(response: UserFormModel) { + ['username', 'name', 'email', 'roles'].forEach((key) => + this.userForm.get(key).setValue(response[key]) + ); + } + + getRequest(): UserFormModel { + const userFormModel = new UserFormModel(); + ['username', 'password', 'name', 'email', 'roles'].forEach( + (key) => (userFormModel[key] = this.userForm.get(key).value) + ); + return userFormModel; + } + + createAction() { + const userFormModel = this.getRequest(); + this.userService.create(userFormModel).subscribe( + () => { + this.notificationService.show( + NotificationType.success, + `User "${userFormModel.username}" has been created.`, + 'Create User' + ); + this.router.navigate(['/users']); + }, + () => { + this.userForm.setErrors({ cdSubmitButton: true }); + } + ); + } + + editAction() { + if (this.isUserRemovingNeededRolePermissions()) { + const initialState = { + titleText: 'Update user', + buttonText: 'Continue', + bodyTpl: this.removeSelfUserReadUpdatePermissionTpl, + onSubmit: () => { + this.modalRef.hide(); + this.doEditAction(); + }, + onCancel: () => { + this.userForm.setErrors({ cdSubmitButton: true }); + this.userForm.get('roles').reset(this.userForm.get('roles').value); + } + }; + this.modalRef = this.modalService.show(ConfirmationModalComponent, { initialState }); + } else { + this.doEditAction(); + } + } + + private isCurrentUser(): boolean { + return this.authStorageService.getUsername() === this.userForm.getValue('username'); + } + + private isUserChangingRoles(): boolean { + const isCurrentUser = this.isCurrentUser(); + return ( + isCurrentUser && + this.response && + !_.isEqual(this.response.roles, this.userForm.getValue('roles')) + ); + } + + private isUserRemovingNeededRolePermissions(): boolean { + const isCurrentUser = this.isCurrentUser(); + return isCurrentUser && !this.hasUserReadUpdatePermissions(this.userForm.getValue('roles')); + } + + private hasUserReadUpdatePermissions(roles: Array = []) { + for (const role of this.allRoles) { + if (roles.indexOf(role.name) !== -1 && role.scopes_permissions['user']) { + const userPermissions = role.scopes_permissions['user']; + return ['read', 'update'].every((permission) => { + return userPermissions.indexOf(permission) !== -1; + }); + } + } + return false; + } + + private doEditAction() { + const userFormModel = this.getRequest(); + this.userService.update(userFormModel).subscribe( + () => { + if (this.isUserChangingRoles()) { + this.authService.logout(() => { + this.notificationService.show( + NotificationType.info, + 'You were automatically logged out because your roles have been changed.' + ); + this.router.navigate(['/login']); + }); + } else { + this.notificationService.show( + NotificationType.success, + `User "${userFormModel.username}" has been updated.`, + 'Edit User' + ); + this.router.navigate(['/users']); + } + }, + () => { + this.userForm.setErrors({ cdSubmitButton: true }); + } + ); + } + + submit() { + if (this.mode === this.userFormMode.editing) { + this.editAction(); + } else { + this.createAction(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts new file mode 100644 index 0000000000000..cd3b188ad8049 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts @@ -0,0 +1,7 @@ +export class UserFormModel { + username: string; + password: string; + name: string; + email: string; + roles: Array; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html new file mode 100644 index 0000000000000..f2e902cca3828 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html @@ -0,0 +1,86 @@ + + + +
+
+ + + + + +
+
+
+ + + + {{ role }}{{ !isLast ? ", " : "" }} + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts new file mode 100644 index 0000000000000..8511e46c55cc8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts @@ -0,0 +1,30 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastModule } from 'ng2-toastr'; + +import { SharedModule } from '../../../shared/shared.module'; +import { UserListComponent } from './user-list.component'; + +describe('UserListComponent', () => { + let component: UserListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [SharedModule, ToastModule.forRoot(), RouterTestingModule, HttpClientTestingModule], + declarations: [UserListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UserListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts new file mode 100644 index 0000000000000..03be329b7be40 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts @@ -0,0 +1,114 @@ +import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; + +import { BsModalRef, BsModalService } from 'ngx-bootstrap'; + +import { UserService } from '../../../shared/api/user.service'; +import { DeletionModalComponent } from '../../../shared/components/deletion-modal/deletion-modal.component'; +import { EmptyPipe } from '../../../shared/empty.pipe'; +import { NotificationType } from '../../../shared/enum/notification-type.enum'; +import { CdTableColumn } from '../../../shared/models/cd-table-column'; +import { CdTableSelection } from '../../../shared/models/cd-table-selection'; +import { Permission } from '../../../shared/models/permissions'; +import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../shared/services/notification.service'; + +@Component({ + selector: 'cd-user-list', + templateUrl: './user-list.component.html', + styleUrls: ['./user-list.component.scss'] +}) +export class UserListComponent implements OnInit { + @ViewChild('userRolesTpl') userRolesTpl: TemplateRef; + + permission: Permission; + columns: CdTableColumn[]; + users: Array; + selection = new CdTableSelection(); + + modalRef: BsModalRef; + + constructor( + private userService: UserService, + private emptyPipe: EmptyPipe, + private modalService: BsModalService, + private notificationService: NotificationService, + private authStorageService: AuthStorageService + ) { + this.permission = this.authStorageService.getPermissions().user; + } + + ngOnInit() { + this.columns = [ + { + name: 'Username', + prop: 'username', + flexGrow: 1 + }, + { + name: 'Name', + prop: 'name', + flexGrow: 1, + pipe: this.emptyPipe + }, + { + name: 'Email', + prop: 'email', + flexGrow: 1, + pipe: this.emptyPipe + }, + { + name: 'Roles', + prop: 'roles', + flexGrow: 1, + cellTemplate: this.userRolesTpl + } + ]; + } + + getUsers() { + this.userService.list().subscribe((users: Array) => { + this.users = users; + }); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + deleteUser(username: string) { + this.userService.delete(username).subscribe( + () => { + this.getUsers(); + this.modalRef.hide(); + this.notificationService.show( + NotificationType.success, + `User "${username}" has been deleted.`, + 'Delete User' + ); + }, + () => { + this.modalRef.content.stopLoadingSpinner(); + } + ); + } + + deleteUserModal() { + const sessionUsername = this.authStorageService.getUsername(); + const username = this.selection.first().username; + if (sessionUsername === username) { + this.notificationService.show( + NotificationType.error, + `You are currently authenticated with user "${username}".`, + 'Cannot Delete User' + ); + return; + } + this.modalRef = this.modalService.show(DeletionModalComponent); + this.modalRef.content.setUp({ + metaType: 'User', + pattern: `${username}`, + deletionMethod: () => this.deleteUser(username), + modalRef: this.modalRef + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html new file mode 100644 index 0000000000000..934c003d22b05 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html @@ -0,0 +1,18 @@ +
+ + + + + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.spec.ts new file mode 100644 index 0000000000000..a4c47b4f7eadd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharedModule } from '../../../shared/shared.module'; +import { AdministrationComponent } from './administration.component'; + +describe('AdministrationComponent', () => { + let component: AdministrationComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [SharedModule], + declarations: [AdministrationComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdministrationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts new file mode 100644 index 0000000000000..eacef89848d64 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts @@ -0,0 +1,19 @@ +import { Component, OnInit } from '@angular/core'; + +import { Permission } from '../../../shared/models/permissions'; +import { AuthStorageService } from '../../../shared/services/auth-storage.service'; + +@Component({ + selector: 'cd-administration', + templateUrl: './administration.component.html', + styleUrls: ['./administration.component.scss'] +}) +export class AdministrationComponent implements OnInit { + userPermission: Permission; + + constructor(private authStorageService: AuthStorageService) { + this.userPermission = this.authStorageService.getPermissions().user; + } + + ngOnInit() {} +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts index aaf9bb91fe80a..178eeade9b7ac 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts @@ -8,6 +8,7 @@ import { AppRoutingModule } from '../../app-routing.module'; import { SharedModule } from '../../shared/shared.module'; import { AuthModule } from '../auth/auth.module'; import { AboutComponent } from './about/about.component'; +import { AdministrationComponent } from './administration/administration.component'; import { DashboardHelpComponent } from './dashboard-help/dashboard-help.component'; import { NavigationComponent } from './navigation/navigation.component'; import { NotificationsComponent } from './notifications/notifications.component'; @@ -31,7 +32,8 @@ import { TaskManagerComponent } from './task-manager/task-manager.component'; NavigationComponent, NotificationsComponent, TaskManagerComponent, - DashboardHelpComponent + DashboardHelpComponent, + AdministrationComponent ], exports: [NavigationComponent] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html index e7cc38ae1aac0..d212e21deea94 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -219,6 +219,9 @@
  • +
  • + +
  • diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts index 7284d862c1864..9954e346b1a8d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts @@ -20,12 +20,12 @@ export class AuthService { }); } - logout() { - return this.http - .delete('api/auth') - .toPromise() - .then(() => { - this.authStorageService.remove(); - }); + logout(callback: Function) { + return this.http.delete('api/auth').subscribe(() => { + this.authStorageService.remove(); + if (callback) { + callback(); + } + }); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts new file mode 100644 index 0000000000000..ac057ce49c952 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts @@ -0,0 +1,35 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '../../../testing/unit-test-helper'; +import { RoleService } from './role.service'; + +describe('RoleService', () => { + let service: RoleService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [RoleService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.get(RoleService); + httpTesting = TestBed.get(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/role'); + expect(req.request.method).toBe('GET'); + }); + +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts new file mode 100644 index 0000000000000..9c828b4255fa2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts @@ -0,0 +1,15 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { ApiModule } from './api.module'; + +@Injectable({ + providedIn: ApiModule +}) +export class RoleService { + constructor(private http: HttpClient) {} + + list() { + return this.http.get('api/role'); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts new file mode 100644 index 0000000000000..1e87ca011d32e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts @@ -0,0 +1,74 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '../../../testing/unit-test-helper'; +import { UserFormModel } from '../../core/auth/user-form/user-form.model'; +import { UserService } from './user.service'; + +describe('UserService', () => { + let service: UserService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [UserService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.get(UserService); + httpTesting = TestBed.get(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call create', () => { + const user = new UserFormModel(); + user.username = 'user0'; + user.password = 'pass0'; + user.name = 'User 0'; + user.email = 'user0@email.com'; + user.roles = ['administrator']; + service.create(user).subscribe(); + const req = httpTesting.expectOne('api/user'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(user); + }); + + it('should call delete', () => { + service.delete('user0').subscribe(); + const req = httpTesting.expectOne('api/user/user0'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call update', () => { + const user = new UserFormModel(); + user.username = 'user0'; + user.password = 'pass0'; + user.name = 'User 0'; + user.email = 'user0@email.com'; + user.roles = ['administrator']; + service.update(user).subscribe(); + const req = httpTesting.expectOne('api/user/user0'); + expect(req.request.body).toEqual(user); + expect(req.request.method).toBe('PUT'); + }); + + it('should call get', () => { + service.get('user0').subscribe(); + const req = httpTesting.expectOne('api/user/user0'); + expect(req.request.method).toBe('GET'); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/user'); + expect(req.request.method).toBe('GET'); + }); + +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts new file mode 100644 index 0000000000000..2643c7c35004b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts @@ -0,0 +1,32 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { UserFormModel } from '../../core/auth/user-form/user-form.model'; +import { ApiModule } from './api.module'; + +@Injectable({ + providedIn: ApiModule +}) +export class UserService { + constructor(private http: HttpClient) {} + + list() { + return this.http.get('api/user'); + } + + delete(username: string) { + return this.http.delete(`api/user/${username}`); + } + + get(username: string) { + return this.http.get(`api/user/${username}`); + } + + create(user: UserFormModel) { + return this.http.post(`api/user`, user); + } + + update(user: UserFormModel) { + return this.http.put(`api/user/${user.username}`, user); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts index 2c6dd9b1766de..39b2228e08b36 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts @@ -3,5 +3,6 @@ export enum Components { cephfs = 'CephFS', rbd = 'RBD', pool = 'Pool', - osd = 'OSD' + osd = 'OSD', + user = 'User' } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts index f97652cfac5a9..e8a50e9e42b92 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts @@ -25,6 +25,7 @@ export class Permissions { cephfs: Permission; manager: Permission; log: Permission; + user: Permission; constructor(serverPermissions: any) { this.hosts = new Permission(serverPermissions['hosts']); @@ -39,5 +40,6 @@ export class Permissions { this.cephfs = new Permission(serverPermissions['cephfs']); this.manager = new Permission(serverPermissions['manager']); this.log = new Permission(serverPermissions['log']); + this.user = new Permission(serverPermissions['user']); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts index cbcda41209e1a..86afaff40fba1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts @@ -22,6 +22,10 @@ export class AuthStorageService { return localStorage.getItem('dashboard_username') !== null; } + getUsername() { + return localStorage.getItem('dashboard_username'); + } + getPermissions(): Permissions { return JSON.parse( localStorage.getItem('dashboard_permissions') || JSON.stringify(new Permissions({})) -- 2.39.5