diff options
Diffstat (limited to 'src/pybind/mgr/dashboard/frontend/src/app/core')
96 files changed, 5852 insertions, 0 deletions
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 new file mode 100644 index 000000000..74583431c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts @@ -0,0 +1,87 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule, Routes } from '@angular/router'; + +import { NgbNavModule, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgxPipeFunctionModule } from 'ngx-pipe-function'; + +import { ActionLabels, URLVerbs } from '~/app/shared/constants/app.constants'; +import { SharedModule } from '~/app/shared/shared.module'; +import { LoginPasswordFormComponent } from './login-password-form/login-password-form.component'; +import { LoginComponent } from './login/login.component'; +import { RoleDetailsComponent } from './role-details/role-details.component'; +import { RoleFormComponent } from './role-form/role-form.component'; +import { RoleListComponent } from './role-list/role-list.component'; +import { UserFormComponent } from './user-form/user-form.component'; +import { UserListComponent } from './user-list/user-list.component'; +import { UserPasswordFormComponent } from './user-password-form/user-password-form.component'; +import { UserTabsComponent } from './user-tabs/user-tabs.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + SharedModule, + NgbNavModule, + NgbPopoverModule, + NgxPipeFunctionModule, + RouterModule + ], + declarations: [ + LoginComponent, + LoginPasswordFormComponent, + RoleDetailsComponent, + RoleFormComponent, + RoleListComponent, + UserTabsComponent, + UserListComponent, + UserFormComponent, + UserPasswordFormComponent + ] +}) +export class AuthModule {} + +const routes: Routes = [ + { path: '', redirectTo: 'users', pathMatch: 'full' }, + { + path: 'users', + data: { breadcrumbs: 'Users' }, + children: [ + { path: '', component: UserListComponent }, + { + path: URLVerbs.CREATE, + component: UserFormComponent, + data: { breadcrumbs: ActionLabels.CREATE } + }, + { + path: `${URLVerbs.EDIT}/:username`, + component: UserFormComponent, + data: { breadcrumbs: ActionLabels.EDIT } + } + ] + }, + { + path: 'roles', + data: { breadcrumbs: 'Roles' }, + children: [ + { path: '', component: RoleListComponent }, + { + path: URLVerbs.CREATE, + component: RoleFormComponent, + data: { breadcrumbs: ActionLabels.CREATE } + }, + { + path: `${URLVerbs.EDIT}/:name`, + component: RoleFormComponent, + data: { breadcrumbs: ActionLabels.EDIT } + } + ] + } +]; + +@NgModule({ + imports: [AuthModule, RouterModule.forChild(routes)] +}) +export class RoutedAuthModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.html new file mode 100755 index 000000000..2dc30df52 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.html @@ -0,0 +1,95 @@ +<div> + <h2 i18n>Please set a new password.</h2> + <h4 i18n>You will be redirected to the login page afterwards.</h4> + <form #frm="ngForm" + [formGroup]="userForm" + novalidate> + + <!-- Old password --> + <div class="form-group has-feedback"> + <div class="input-group"> + <input class="form-control" + type="password" + placeholder="Old password..." + id="oldpassword" + formControlName="oldpassword" + autocomplete="new-password" + autofocus> + <span class="input-group-append"> + <button class="btn btn-outline-light btn-password" + cdPasswordButton="oldpassword"> + </button> + </span> + </div> + <span class="invalid-feedback" + *ngIf="userForm.showError('oldpassword', frm, 'required')" + i18n>This field is required.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('oldpassword', frm, 'notmatch')" + i18n>The old and new passwords must be different.</span> + </div> + + <!-- New password --> + <div class="form-group has-feedback"> + <div class="input-group"> + <input class="form-control" + type="password" + placeholder="New password..." + id="newpassword" + autocomplete="new-password" + formControlName="newpassword"> + <span class="input-group-append"> + <button type="button" + class="btn btn-outline-light btn-password" + cdPasswordButton="newpassword"> + </button> + </span> + </div> + <div class="password-strength-level"> + <div class="{{ passwordStrengthLevelClass }}" + data-toggle="tooltip" + title="{{ passwordValuation }}"> + </div> + </div> + <span class="invalid-feedback" + *ngIf="userForm.showError('newpassword', frm, 'required')" + i18n>This field is required.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('newpassword', frm, 'notmatch')" + i18n>The old and new passwords must be different.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('newpassword', frm, 'passwordPolicy')"> + {{ passwordValuation }} + </span> + </div> + + <!-- Confirm new password --> + <div class="form-group has-feedback"> + <div class="input-group"> + <input class="form-control" + type="password" + autocomplete="new-password" + placeholder="Confirm new password..." + id="confirmnewpassword" + formControlName="confirmnewpassword"> + <span class="input-group-append"> + <button class="btn btn-outline-light btn-password" + cdPasswordButton="confirmnewpassword"> + </button> + </span> + </div> + <span class="invalid-feedback" + *ngIf="userForm.showError('confirmnewpassword', frm, 'required')" + i18n>This field is required.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('confirmnewpassword', frm, 'match')" + i18n>Password confirmation doesn't match the new password.</span> + </div> + <cd-form-button-panel (submitActionEvent)="onSubmit()" + (backActionEvent)="onCancel()" + [form]="userForm" + [disabled]="userForm.invalid" + [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)" + wrappingClass="text-right"></cd-form-button-panel> + </form> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.scss new file mode 100755 index 000000000..15addd1e8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.scss @@ -0,0 +1,68 @@ +@use 'sass:map'; +@use './src/styles/vendor/variables' as vv; + +$dark-secondary: darken(vv.$secondary, 4%); + +::ng-deep cd-login-password-form { + h4 { + margin: 0 0 30px; + } + + .form-group { + background-color: $dark-secondary; + border-left: 4px solid vv.$white; + + &:focus-within { + border-left: 4px solid map.get(vv.$theme-colors, 'accent'); + } + } + + .btn-password, + .btn-password:focus, + .form-control, + .form-control:focus { + background-color: $dark-secondary; + border: 0; + box-shadow: none; + color: vv.$body-color-bright; + filter: none; + outline: none; + } + + .form-control::placeholder { + color: vv.$gray-600; + } + + .btn-password:focus { + outline-color: vv.$primary; + } + + button.btn:not(:first-child) { + margin-left: 5px; + } +} + +// This will override the colors applied by chrome +@keyframes autofill { + to { + background-color: $dark-secondary; + color: vv.$body-color-bright; + } +} + +input:-webkit-autofill { + animation-fill-mode: both; + animation-name: autofill; + border-radius: 0; + box-shadow: 0 0 0 1000px $dark-secondary inset; + -webkit-text-fill-color: vv.$body-color-bright; + transition-property: none; +} + +.invalid-feedback { + padding-left: 9px; +} + +.is-invalid.cd-form-control { + border-color: transparent; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.spec.ts new file mode 100755 index 000000000..062d076e4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.spec.ts @@ -0,0 +1,77 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastrModule } from 'ngx-toastr'; + +import { AuthService } from '~/app/shared/api/auth.service'; +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed, FormHelper } from '~/testing/unit-test-helper'; +import { LoginPasswordFormComponent } from './login-password-form.component'; + +describe('LoginPasswordFormComponent', () => { + let component: LoginPasswordFormComponent; + let fixture: ComponentFixture<LoginPasswordFormComponent>; + let form: CdFormGroup; + let formHelper: FormHelper; + let httpTesting: HttpTestingController; + let router: Router; + let authStorageService: AuthStorageService; + let authService: AuthService; + + configureTestBed({ + imports: [ + HttpClientTestingModule, + RouterTestingModule, + ReactiveFormsModule, + ComponentsModule, + ToastrModule.forRoot(), + SharedModule + ], + declarations: [LoginPasswordFormComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginPasswordFormComponent); + component = fixture.componentInstance; + httpTesting = TestBed.inject(HttpTestingController); + router = TestBed.inject(Router); + authStorageService = TestBed.inject(AuthStorageService); + authService = TestBed.inject(AuthService); + spyOn(router, 'navigate'); + fixture.detectChanges(); + form = component.userForm; + formHelper = new FormHelper(form); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should submit', () => { + spyOn(component, 'onPasswordChange').and.callThrough(); + spyOn(authService, 'logout'); + spyOn(authStorageService, 'getUsername').and.returnValue('test1'); + formHelper.setMultipleValues({ + oldpassword: 'foo', + newpassword: 'bar' + }); + formHelper.setValue('confirmnewpassword', 'bar', true); + component.onSubmit(); + const request = httpTesting.expectOne('api/user/test1/change_password'); + request.flush({}); + expect(component.onPasswordChange).toHaveBeenCalled(); + expect(authService.logout).toHaveBeenCalled(); + }); + + it('should cancel', () => { + spyOn(authService, 'logout'); + component.onCancel(); + expect(authService.logout).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.ts new file mode 100755 index 000000000..0e72cca35 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.ts @@ -0,0 +1,51 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; + +import { AuthService } from '~/app/shared/api/auth.service'; +import { UserService } from '~/app/shared/api/user.service'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { PasswordPolicyService } from '~/app/shared/services/password-policy.service'; +import { UserPasswordFormComponent } from '../user-password-form/user-password-form.component'; + +@Component({ + selector: 'cd-login-password-form', + templateUrl: './login-password-form.component.html', + styleUrls: ['./login-password-form.component.scss'] +}) +export class LoginPasswordFormComponent extends UserPasswordFormComponent { + constructor( + public actionLabels: ActionLabelsI18n, + public notificationService: NotificationService, + public userService: UserService, + public authStorageService: AuthStorageService, + public formBuilder: CdFormBuilder, + public router: Router, + public passwordPolicyService: PasswordPolicyService, + public authService: AuthService + ) { + super( + actionLabels, + notificationService, + userService, + authStorageService, + formBuilder, + router, + passwordPolicyService + ); + } + + onPasswordChange() { + // Logout here because changing the password will change the + // session token which will finally lead to a 401 when calling + // the REST API the next time. The API HTTP interceptor will + // then also redirect to the login page immediately. + this.authService.logout(); + } + + onCancel() { + this.authService.logout(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html new file mode 100644 index 000000000..8565c3615 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html @@ -0,0 +1,64 @@ +<div class="container" + *ngIf="isLoginActive"> + <form name="loginForm" + (ngSubmit)="login()" + #loginForm="ngForm" + novalidate> + + <!-- Username --> + <div class="form-group has-feedback d-flex flex-column py-3"> + <label class="placeholder pl-4" + for="username" + i18n>Username</label> + <input id="username" + name="username" + [(ngModel)]="model.username" + #username="ngModel" + type="text" + [attr.aria-invalid]="username.invalid" + aria-labelledby="username" + class="form-control pl-4" + required + autofocus> + <div class="invalid-feedback pl-4" + *ngIf="(loginForm.submitted || username.dirty) && username.invalid" + i18n>Username is required</div> + </div> + + <!-- Password --> + <div class="form-group has-feedback" + id="password-div"> + <div class="input-group d-flex flex-nowrap"> + <div class="d-flex flex-column flex-grow-1 py-3"> + <label class="placeholder pl-4" + for="password" + i18n>Password</label> + <input id="password" + name="password" + [(ngModel)]="model.password" + #password="ngModel" + type="password" + [attr.aria-invalid]="password.invalid" + aria-labelledby="password" + class="form-control pl-4" + required> + <div class="invalid-feedback pl-4" + *ngIf="(loginForm.submitted || password.dirty) && password.invalid" + i18n>Password is required</div> + </div> + <span class="form-group-append"> + <button type="button" + class="btn btn-outline-light btn-password h-100 px-4" + cdPasswordButton="password"> + </button> + </span> + </div> + </div> + + <input type="submit" + class="btn btn-accent px-5 py-2" + [disabled]="loginForm.invalid" + value="Log in" + i18n-value> + </form> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.scss new file mode 100644 index 000000000..0fdc3c6ba --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.scss @@ -0,0 +1,54 @@ +@use 'sass:map'; +@use './src/styles/vendor/variables' as vv; + +$dark-secondary: darken(vv.$secondary, 4%); + +::ng-deep cd-login { + .form-group { + background-color: $dark-secondary; + border-left: 4px solid vv.$white; + height: auto; + margin-bottom: 2rem; + + &:focus-within { + border-left: 4px solid map.get(vv.$theme-colors, 'accent'); + } + } + + .btn-password, + .btn-password:focus, + .form-control, + .form-control:focus { + background-color: $dark-secondary; + border: 0; + box-shadow: none; + color: vv.$body-color-bright; + filter: none; + outline: none; + } + + .placeholder { + color: vv.$gray-600; + } + + .btn-password:focus { + outline-color: vv.$primary; + } +} + +// This will override the colors applied by chrome +@keyframes autofill { + to { + background-color: $dark-secondary; + color: vv.$body-color-bright; + } +} + +input:-webkit-autofill { + animation-fill-mode: both; + animation-name: autofill; + border-radius: 0; + box-shadow: 0 0 0 1000px $dark-secondary inset; + -webkit-text-fill-color: vv.$body-color-bright; + transition-property: none; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts new file mode 100644 index 000000000..fc02e9bde --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts @@ -0,0 +1,58 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of } from 'rxjs'; + +import { AuthService } from '~/app/shared/api/auth.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AuthModule } from '../auth.module'; +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture<LoginComponent>; + let routerNavigateSpy: jasmine.Spy; + let authServiceLoginSpy: jasmine.Spy; + + configureTestBed({ + imports: [RouterTestingModule, HttpClientTestingModule, AuthModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + routerNavigateSpy = spyOn(TestBed.inject(Router), 'navigate'); + routerNavigateSpy.and.returnValue(true); + authServiceLoginSpy = spyOn(TestBed.inject(AuthService), 'login'); + authServiceLoginSpy.and.returnValue(of(null)); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should ensure no modal dialogs are opened', () => { + component['modalService']['modalsCount'] = 2; + component.ngOnInit(); + expect(component['modalService'].hasOpenModals()).toBeFalsy(); + }); + + it('should not show create cluster wizard if cluster creation was successful', () => { + component.postInstalled = true; + component.login(); + + expect(routerNavigateSpy).toHaveBeenCalledTimes(1); + expect(routerNavigateSpy).toHaveBeenCalledWith(['/']); + }); + + it('should show create cluster wizard if cluster creation was failed', () => { + component.postInstalled = false; + component.login(); + + expect(routerNavigateSpy).toHaveBeenCalledTimes(1); + expect(routerNavigateSpy).toHaveBeenCalledWith(['/expand-cluster']); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts new file mode 100644 index 000000000..a98548f94 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts @@ -0,0 +1,76 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import _ from 'lodash'; + +import { AuthService } from '~/app/shared/api/auth.service'; +import { Credentials } from '~/app/shared/models/credentials'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ModalService } from '~/app/shared/services/modal.service'; + +@Component({ + selector: 'cd-login', + templateUrl: './login.component.html', + styleUrls: ['./login.component.scss'] +}) +export class LoginComponent implements OnInit { + model = new Credentials(); + isLoginActive = false; + returnUrl: string; + postInstalled = false; + + constructor( + private authService: AuthService, + private authStorageService: AuthStorageService, + private modalService: ModalService, + private route: ActivatedRoute, + private router: Router + ) {} + + ngOnInit() { + if (this.authStorageService.isLoggedIn()) { + this.router.navigate(['']); + } else { + // Make sure all open modal dialogs are closed. This might be + // necessary when the logged in user is redirected to the login + // page after a 401. + this.modalService.dismissAll(); + + let token: string = null; + if (window.location.hash.indexOf('access_token=') !== -1) { + token = window.location.hash.split('access_token=')[1]; + const uri = window.location.toString(); + window.history.replaceState({}, document.title, uri.split('?')[0]); + } + this.authService.check(token).subscribe((login: any) => { + if (login.login_url) { + this.postInstalled = login.cluster_status === 'POST_INSTALLED'; + if (login.login_url === '#/login') { + this.isLoginActive = true; + } else { + window.location.replace(login.login_url); + } + } else { + this.authStorageService.set( + login.username, + login.permissions, + login.sso, + login.pwdExpirationDate + ); + this.router.navigate(['']); + } + }); + } + } + + login() { + this.authService.login(this.model).subscribe(() => { + const urlPath = this.postInstalled ? '/' : '/expand-cluster'; + let url = _.get(this.route.snapshot.queryParams, 'returnUrl', urlPath); + if (!this.postInstalled && this.route.snapshot.queryParams['returnUrl'] === '/dashboard') { + url = '/expand-cluster'; + } + this.router.navigate([url]); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html new file mode 100644 index 000000000..ca4b6781b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html @@ -0,0 +1,11 @@ +<ng-container *ngIf="selection"> + <cd-table [data]="scopes_permissions" + [columns]="columns" + columnMode="flex" + [toolHeader]="false" + [autoReload]="false" + [autoSave]="false" + [footer]="false" + [limit]="0"> + </cd-table> +</ng-container> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.scss new file mode 100644 index 000000000..2ec160998 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.scss @@ -0,0 +1,9 @@ +@use './src/styles/vendor/variables' as vv; + +.fa { + font-size: large; + + &.fa-square-o { + color: vv.$gray-400; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts new file mode 100644 index 000000000..b62cd32eb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts @@ -0,0 +1,67 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RoleDetailsComponent } from './role-details.component'; + +describe('RoleDetailsComponent', () => { + let component: RoleDetailsComponent; + let fixture: ComponentFixture<RoleDetailsComponent>; + + configureTestBed({ + imports: [SharedModule, RouterTestingModule, HttpClientTestingModule, NgbNavModule], + declarations: [RoleDetailsComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RoleDetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should create scopes permissions [1/2]', () => { + component.scopes = ['log', 'rgw']; + component.selection = { + description: 'RGW Manager', + name: 'rgw-manager', + scopes_permissions: { + rgw: ['read', 'create', 'update', 'delete'] + }, + system: true + }; + expect(component.scopes_permissions.length).toBe(0); + component.ngOnChanges(); + expect(component.scopes_permissions).toEqual([ + { scope: 'log', read: false, create: false, update: false, delete: false }, + { scope: 'rgw', read: true, create: true, update: true, delete: true } + ]); + }); + + it('should create scopes permissions [2/2]', () => { + component.scopes = ['cephfs', 'log', 'rgw']; + component.selection = { + description: 'Test', + name: 'test', + scopes_permissions: { + log: ['read', 'update'], + rgw: ['read', 'create', 'update'] + }, + system: false + }; + expect(component.scopes_permissions.length).toBe(0); + component.ngOnChanges(); + expect(component.scopes_permissions).toEqual([ + { scope: 'cephfs', read: false, create: false, update: false, delete: false }, + { scope: 'log', read: true, create: false, update: true, delete: false }, + { scope: 'rgw', read: true, create: true, update: true, delete: false } + ]); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts new file mode 100644 index 000000000..244a7861b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts @@ -0,0 +1,79 @@ +import { Component, Input, OnChanges, OnInit } from '@angular/core'; + +import _ from 'lodash'; + +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; + +@Component({ + selector: 'cd-role-details', + templateUrl: './role-details.component.html', + styleUrls: ['./role-details.component.scss'] +}) +export class RoleDetailsComponent implements OnChanges, OnInit { + @Input() + selection: any; + @Input() + scopes: Array<string>; + selectedItem: any; + + columns: CdTableColumn[]; + scopes_permissions: Array<any> = []; + + ngOnInit() { + this.columns = [ + { + prop: 'scope', + name: $localize`Scope`, + flexGrow: 2 + }, + { + prop: 'read', + name: $localize`Read`, + flexGrow: 1, + cellClass: 'text-center', + cellTransformation: CellTemplate.checkIcon + }, + { + prop: 'create', + name: $localize`Create`, + flexGrow: 1, + cellClass: 'text-center', + cellTransformation: CellTemplate.checkIcon + }, + { + prop: 'update', + name: $localize`Update`, + flexGrow: 1, + cellClass: 'text-center', + cellTransformation: CellTemplate.checkIcon + }, + { + prop: 'delete', + name: $localize`Delete`, + flexGrow: 1, + cellClass: 'text-center', + cellTransformation: CellTemplate.checkIcon + } + ]; + } + + ngOnChanges() { + if (this.selection) { + this.selectedItem = this.selection; + // Build the scopes/permissions data used by the data table. + const scopes_permissions: any[] = []; + _.each(this.scopes, (scope) => { + const scope_permission: any = { read: false, create: false, update: false, delete: false }; + scope_permission['scope'] = scope; + if (scope in this.selectedItem['scopes_permissions']) { + _.each(this.selectedItem['scopes_permissions'][scope], (permission) => { + scope_permission[permission] = true; + }); + } + scopes_permissions.push(scope_permission); + }); + this.scopes_permissions = scopes_permissions; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form-mode.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form-mode.enum.ts new file mode 100644 index 000000000..4f0a6f11f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form-mode.enum.ts @@ -0,0 +1,3 @@ +export enum RoleFormMode { + editing = 'editing' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html new file mode 100644 index 000000000..08904c1c2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html @@ -0,0 +1,121 @@ +<div class="cd-col-form" + *cdFormLoading="loading"> + <form name="roleForm" + #formDir="ngForm" + [formGroup]="roleForm" + novalidate> + <div class="card"> + <div i18n="form title" + class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div> + <div class="card-body"> + + <!-- Name --> + <div class="form-group row"> + <label class="cd-col-form-label" + [ngClass]="{'required': mode !== roleFormMode.editing}" + for="name" + i18n>Name</label> + <div class="cd-col-form-input"> + <input class="form-control" + type="text" + i18n-placeholder + placeholder="Name..." + id="name" + name="name" + formControlName="name" + autofocus> + <span class="invalid-feedback" + *ngIf="roleForm.showError('name', formDir, 'required')" + i18n>This field is required.</span> + <span class="invalid-feedback" + *ngIf="roleForm.showError('name', formDir, 'notUnique')" + i18n>The chosen name is already in use.</span> + </div> + </div> + + <!-- Description --> + <div class="form-group row"> + <label i18n + class="cd-col-form-label" + for="description">Description</label> + <div class="cd-col-form-input"> + <input class="form-control" + type="text" + i18n-placeholder + placeholder="Description..." + id="description" + name="description" + formControlName="description"> + </div> + </div> + + <!-- Permissions --> + <div class="form-group row"> + <label i18n + class="cd-col-form-label">Permissions</label> + <div class="cd-col-form-input"> + <cd-table [data]="scopes_permissions" + [columns]="columns" + columnMode="flex" + [toolHeader]="false" + [autoReload]="false" + [autoSave]="false" + [footer]="false" + [limit]="0"> + </cd-table> + </div> + </div> + + </div> + <div class="card-footer"> + <cd-form-button-panel (submitActionEvent)="submit()" + [form]="roleForm" + [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)" + wrappingClass="text-right"></cd-form-button-panel> + </div> + </div> + </form> +</div> + +<ng-template #cellScopeCheckboxTpl + let-column="column" + let-row="row" + let-value="value"> + <div class="custom-control custom-checkbox"> + <input class="custom-control-input" + id="scope_{{ row.scope }}" + type="checkbox" + [checked]="isRowChecked(row.scope)" + (change)="onClickCellCheckbox(row.scope, column.prop, $event)"> + <label class="datatable-permissions-scope-cell-label custom-control-label" + for="scope_{{ row.scope }}">{{ value }}</label> + </div> +</ng-template> + +<ng-template #cellPermissionCheckboxTpl + let-column="column" + let-row="row" + let-value="value"> + <div class="custom-control custom-checkbox"> + <input class="custom-control-input" + type="checkbox" + [checked]="value" + [id]="row.scope + '-' + column.prop" + (change)="onClickCellCheckbox(row.scope, column.prop, $event)"> + <label class="custom-control-label" + [for]="row.scope + '-' + column.prop"></label> + </div> +</ng-template> + +<ng-template #headerPermissionCheckboxTpl + let-column="column"> + <div class="custom-control custom-checkbox"> + <input class="custom-control-input" + id="header_{{ column.prop }}" + type="checkbox" + [checked]="isHeaderChecked(column.prop)" + (change)="onClickHeaderCheckbox(column.prop, $event)"> + <label class="datatable-permissions-header-cell-label custom-control-label" + for="header_{{ column.prop }}">{{ column.name }}</label> + </div> +</ng-template> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss new file mode 100644 index 000000000..3caafa2ee --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss @@ -0,0 +1,4 @@ +.datatable-permissions-header-cell-label, +.datatable-permissions-scope-cell-label { + font-weight: bold; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts new file mode 100644 index 000000000..7552f594b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts @@ -0,0 +1,222 @@ +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 { ToastrModule } from 'ngx-toastr'; +import { of } from 'rxjs'; + +import { RoleService } from '~/app/shared/api/role.service'; +import { ScopeService } from '~/app/shared/api/scope.service'; +import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed, FormHelper } from '~/testing/unit-test-helper'; +import { RoleFormComponent } from './role-form.component'; +import { RoleFormModel } from './role-form.model'; + +describe('RoleFormComponent', () => { + let component: RoleFormComponent; + let form: CdFormGroup; + let fixture: ComponentFixture<RoleFormComponent>; + let httpTesting: HttpTestingController; + let roleService: RoleService; + let router: Router; + const setUrl = (url: string) => Object.defineProperty(router, 'url', { value: url }); + + @Component({ selector: 'cd-fake', template: '' }) + class FakeComponent {} + + const routes: Routes = [{ path: 'roles', component: FakeComponent }]; + + configureTestBed( + { + imports: [ + RouterTestingModule.withRoutes(routes), + HttpClientTestingModule, + ReactiveFormsModule, + ToastrModule.forRoot(), + SharedModule + ], + declarations: [RoleFormComponent, FakeComponent] + }, + [LoadingPanelComponent] + ); + + beforeEach(() => { + fixture = TestBed.createComponent(RoleFormComponent); + component = fixture.componentInstance; + form = component.roleForm; + httpTesting = TestBed.inject(HttpTestingController); + roleService = TestBed.inject(RoleService); + router = TestBed.inject(Router); + spyOn(router, 'navigate'); + fixture.detectChanges(); + const notify = TestBed.inject(NotificationService); + spyOn(notify, 'show'); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(form).toBeTruthy(); + }); + + describe('create mode', () => { + let formHelper: FormHelper; + + beforeEach(() => { + setUrl('/user-management/roles/add'); + component.ngOnInit(); + formHelper = new FormHelper(form); + }); + + it('should not disable fields', () => { + ['name', 'description', 'scopes_permissions'].forEach((key) => + expect(form.get(key).disabled).toBeFalsy() + ); + }); + + it('should validate name required', () => { + formHelper.expectErrorChange('name', '', 'required'); + }); + + it('should set mode', () => { + expect(component.mode).toBeUndefined(); + }); + + it('should submit', () => { + const role: RoleFormModel = { + name: 'role1', + description: 'Role 1', + scopes_permissions: { osd: ['read'] } + }; + formHelper.setMultipleValues(role); + component.submit(); + const roleReq = httpTesting.expectOne('api/role'); + expect(roleReq.request.method).toBe('POST'); + expect(roleReq.request.body).toEqual(role); + roleReq.flush({}); + expect(router.navigate).toHaveBeenCalledWith(['/user-management/roles']); + }); + + it('should check all perms for a scope', () => { + formHelper.setValue('scopes_permissions', { cephfs: ['read'] }); + component.onClickCellCheckbox('grafana', 'scope'); + const scopes_permissions = form.getValue('scopes_permissions'); + expect(Object.keys(scopes_permissions)).toContain('grafana'); + expect(scopes_permissions['grafana']).toEqual(['create', 'delete', 'read', 'update']); + }); + + it('should uncheck all perms for a scope', () => { + formHelper.setValue('scopes_permissions', { cephfs: ['read', 'create', 'update', 'delete'] }); + component.onClickCellCheckbox('cephfs', 'scope'); + const scopes_permissions = form.getValue('scopes_permissions'); + expect(Object.keys(scopes_permissions)).not.toContain('cephfs'); + }); + + it('should uncheck all scopes and perms', () => { + component.scopes = ['cephfs', 'grafana']; + formHelper.setValue('scopes_permissions', { + cephfs: ['read', 'delete'], + grafana: ['update'] + }); + component.onClickHeaderCheckbox('scope', ({ + target: { checked: false } + } as unknown) as Event); + const scopes_permissions = form.getValue('scopes_permissions'); + expect(scopes_permissions).toEqual({}); + }); + + it('should check all scopes and perms', () => { + component.scopes = ['cephfs', 'grafana']; + formHelper.setValue('scopes_permissions', { + cephfs: ['create', 'update'], + grafana: ['delete'] + }); + component.onClickHeaderCheckbox('scope', ({ target: { checked: true } } as unknown) as Event); + const scopes_permissions = form.getValue('scopes_permissions'); + const keys = Object.keys(scopes_permissions); + expect(keys).toEqual(['cephfs', 'grafana']); + keys.forEach((key) => { + expect(scopes_permissions[key].sort()).toEqual(['create', 'delete', 'read', 'update']); + }); + }); + + it('should check if column is checked', () => { + component.scopes_permissions = [ + { scope: 'a', read: true, create: true, update: true, delete: true }, + { scope: 'b', read: false, create: true, update: false, delete: true } + ]; + expect(component.isRowChecked('a')).toBeTruthy(); + expect(component.isRowChecked('b')).toBeFalsy(); + expect(component.isRowChecked('c')).toBeFalsy(); + }); + + it('should check if header is checked', () => { + component.scopes_permissions = [ + { scope: 'a', read: true, create: true, update: false, delete: true }, + { scope: 'b', read: false, create: true, update: false, delete: true } + ]; + expect(component.isHeaderChecked('read')).toBeFalsy(); + expect(component.isHeaderChecked('create')).toBeTruthy(); + expect(component.isHeaderChecked('update')).toBeFalsy(); + }); + }); + + describe('edit mode', () => { + const role: RoleFormModel = { + name: 'role1', + description: 'Role 1', + scopes_permissions: { osd: ['read', 'create'] } + }; + const scopes = ['osd', 'user']; + beforeEach(() => { + spyOn(roleService, 'get').and.callFake(() => of(role)); + spyOn(TestBed.inject(ScopeService), 'list').and.callFake(() => of(scopes)); + setUrl('/user-management/roles/edit/role1'); + component.ngOnInit(); + const reqScopes = httpTesting.expectOne('ui-api/scope'); + expect(reqScopes.request.method).toBe('GET'); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should disable fields if editing', () => { + expect(form.get('name').disabled).toBeTruthy(); + ['description', 'scopes_permissions'].forEach((key) => + expect(form.get(key).disabled).toBeFalsy() + ); + }); + + it('should set control values', () => { + ['name', 'description', 'scopes_permissions'].forEach((key) => + expect(form.getValue(key)).toBe(role[key]) + ); + }); + + it('should set mode', () => { + expect(component.mode).toBe('editing'); + }); + + it('should submit', () => { + component.onClickCellCheckbox('osd', 'update'); + component.onClickCellCheckbox('osd', 'create'); + component.onClickCellCheckbox('user', 'read'); + component.submit(); + const roleReq = httpTesting.expectOne(`api/role/${role.name}`); + expect(roleReq.request.method).toBe('PUT'); + expect(roleReq.request.body).toEqual({ + name: 'role1', + description: 'Role 1', + scopes_permissions: { osd: ['read', 'update'], user: ['read'] } + }); + roleReq.flush({}); + expect(router.navigate).toHaveBeenCalledWith(['/user-management/roles']); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts new file mode 100644 index 000000000..21dff1c85 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts @@ -0,0 +1,315 @@ +import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import _ from 'lodash'; +import { forkJoin as observableForkJoin } from 'rxjs'; + +import { RoleService } from '~/app/shared/api/role.service'; +import { ScopeService } from '~/app/shared/api/scope.service'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { CdForm } from '~/app/shared/forms/cd-form'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { RoleFormMode } from './role-form-mode.enum'; +import { RoleFormModel } from './role-form.model'; + +@Component({ + selector: 'cd-role-form', + templateUrl: './role-form.component.html', + styleUrls: ['./role-form.component.scss'] +}) +export class RoleFormComponent extends CdForm implements OnInit { + @ViewChild('headerPermissionCheckboxTpl', { static: true }) + headerPermissionCheckboxTpl: TemplateRef<any>; + @ViewChild('cellScopeCheckboxTpl', { static: true }) + cellScopeCheckboxTpl: TemplateRef<any>; + @ViewChild('cellPermissionCheckboxTpl', { static: true }) + cellPermissionCheckboxTpl: TemplateRef<any>; + + roleForm: CdFormGroup; + response: RoleFormModel; + + columns: CdTableColumn[]; + scopes: Array<string> = []; + scopes_permissions: Array<any> = []; + + roleFormMode = RoleFormMode; + mode: RoleFormMode; + + action: string; + resource: string; + + constructor( + private route: ActivatedRoute, + private router: Router, + private roleService: RoleService, + private scopeService: ScopeService, + private notificationService: NotificationService, + public actionLabels: ActionLabelsI18n + ) { + super(); + this.resource = $localize`role`; + this.createForm(); + this.listenToChanges(); + } + + createForm() { + this.roleForm = new CdFormGroup({ + name: new FormControl('', { + validators: [Validators.required], + asyncValidators: [CdValidators.unique(this.roleService.exists, this.roleService)] + }), + description: new FormControl(''), + scopes_permissions: new FormControl({}) + }); + } + + ngOnInit() { + this.columns = [ + { + prop: 'scope', + name: $localize`All`, + flexGrow: 2, + cellTemplate: this.cellScopeCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + }, + { + prop: 'read', + name: $localize`Read`, + flexGrow: 1, + cellClass: 'text-center', + cellTemplate: this.cellPermissionCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + }, + { + prop: 'create', + name: $localize`Create`, + flexGrow: 1, + cellClass: 'text-center', + cellTemplate: this.cellPermissionCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + }, + { + prop: 'update', + name: $localize`Update`, + flexGrow: 1, + cellClass: 'text-center', + cellTemplate: this.cellPermissionCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + }, + { + prop: 'delete', + name: $localize`Delete`, + flexGrow: 1, + cellClass: 'text-center', + cellTemplate: this.cellPermissionCheckboxTpl, + headerTemplate: this.headerPermissionCheckboxTpl + } + ]; + if (this.router.url.startsWith('/user-management/roles/edit')) { + this.mode = this.roleFormMode.editing; + this.action = this.actionLabels.EDIT; + } else { + this.action = this.actionLabels.CREATE; + } + if (this.mode === this.roleFormMode.editing) { + this.initEdit(); + } else { + this.initCreate(); + } + } + + initCreate() { + // Load the scopes and initialize the default scopes/permissions data. + this.scopeService.list().subscribe((scopes: Array<string>) => { + this.scopes = scopes; + this.roleForm.get('scopes_permissions').setValue({}); + + this.loadingReady(); + }); + } + + initEdit() { + // Disable the 'Name' input field. + this.roleForm.get('name').disable(); + // Load the scopes and the role data. + this.route.params.subscribe((params: { name: string }) => { + const observables = []; + observables.push(this.scopeService.list()); + observables.push(this.roleService.get(params.name)); + observableForkJoin(observables).subscribe((resp: any[]) => { + this.scopes = resp[0]; + ['name', 'description', 'scopes_permissions'].forEach((key) => + this.roleForm.get(key).setValue(resp[1][key]) + ); + + this.loadingReady(); + }); + }); + } + + listenToChanges() { + // Create/Update the data which is used by the data table to display the + // scopes/permissions every time the form field value has been changed. + this.roleForm.get('scopes_permissions').valueChanges.subscribe((value) => { + const scopes_permissions: any[] = []; + _.each(this.scopes, (scope) => { + // Set the defaults values. + const scope_permission: any = { read: false, create: false, update: false, delete: false }; + scope_permission['scope'] = scope; + // Apply settings from the given value if they exist. + if (scope in value) { + _.each(value[scope], (permission) => { + scope_permission[permission] = true; + }); + } + scopes_permissions.push(scope_permission); + }); + this.scopes_permissions = scopes_permissions; + }); + } + + /** + * Checks if the specified row checkbox needs to be rendered as checked. + * @param {string} scope The scope to be checked, e.g. 'cephfs', 'grafana', + * 'osd', 'pool' ... + * @return Returns true if all permissions (read, create, update, delete) + * are checked for the specified scope, otherwise false. + */ + isRowChecked(scope: string) { + const scope_permission = _.find(this.scopes_permissions, (o) => { + return o['scope'] === scope; + }); + if (_.isUndefined(scope_permission)) { + return false; + } + return ( + scope_permission['read'] && + scope_permission['create'] && + scope_permission['update'] && + scope_permission['delete'] + ); + } + + /** + * Checks if the specified header checkbox needs to be rendered as checked. + * @param {string} property The property/permission (read, create, + * update, delete) to be checked. If 'scope' is given, all permissions + * are checked. + * @return Returns true if specified property/permission is selected + * for all scopes, otherwise false. + */ + isHeaderChecked(property: string) { + let permissions = [property]; + if ('scope' === property) { + permissions = ['read', 'create', 'update', 'delete']; + } + return permissions.every((permission) => { + return this.scopes_permissions.every((scope_permission) => { + return scope_permission[permission]; + }); + }); + } + + onClickCellCheckbox(scope: string, property: string, event: any = null) { + // Use a copy of the form field data to do not trigger the redrawing of the + // data table with every change. + const scopes_permissions = _.cloneDeep(this.roleForm.getValue('scopes_permissions')); + let permissions = [property]; + if ('scope' === property) { + permissions = ['read', 'create', 'update', 'delete']; + } + if (!(scope in scopes_permissions)) { + scopes_permissions[scope] = []; + } + // Add or remove the given permission(s) depending on the click event or if no + // click event is given then add/remove them if they are absent/exist. + if ( + (event && event.target['checked']) || + !_.isEqual(permissions.sort(), _.intersection(scopes_permissions[scope], permissions).sort()) + ) { + scopes_permissions[scope] = _.union(scopes_permissions[scope], permissions); + } else { + scopes_permissions[scope] = _.difference(scopes_permissions[scope], permissions); + if (_.isEmpty(scopes_permissions[scope])) { + _.unset(scopes_permissions, scope); + } + } + this.roleForm.get('scopes_permissions').setValue(scopes_permissions); + } + + onClickHeaderCheckbox(property: 'scope' | 'read' | 'create' | 'update' | 'delete', event: any) { + // Use a copy of the form field data to do not trigger the redrawing of the + // data table with every change. + const scopes_permissions = _.cloneDeep(this.roleForm.getValue('scopes_permissions')); + let permissions = [property]; + if ('scope' === property) { + permissions = ['read', 'create', 'update', 'delete']; + } + _.each(permissions, (permission) => { + _.each(this.scopes, (scope) => { + if (event.target['checked']) { + scopes_permissions[scope] = _.union(scopes_permissions[scope], [permission]); + } else { + scopes_permissions[scope] = _.difference(scopes_permissions[scope], [permission]); + if (_.isEmpty(scopes_permissions[scope])) { + _.unset(scopes_permissions, scope); + } + } + }); + }); + this.roleForm.get('scopes_permissions').setValue(scopes_permissions); + } + + getRequest(): RoleFormModel { + const roleFormModel = new RoleFormModel(); + ['name', 'description', 'scopes_permissions'].forEach( + (key) => (roleFormModel[key] = this.roleForm.get(key).value) + ); + return roleFormModel; + } + + createAction() { + const roleFormModel = this.getRequest(); + this.roleService.create(roleFormModel).subscribe( + () => { + this.notificationService.show( + NotificationType.success, + $localize`Created role '${roleFormModel.name}'` + ); + this.router.navigate(['/user-management/roles']); + }, + () => { + this.roleForm.setErrors({ cdSubmitButton: true }); + } + ); + } + + editAction() { + const roleFormModel = this.getRequest(); + this.roleService.update(roleFormModel).subscribe( + () => { + this.notificationService.show( + NotificationType.success, + $localize`Updated role '${roleFormModel.name}'` + ); + this.router.navigate(['/user-management/roles']); + }, + () => { + this.roleForm.setErrors({ cdSubmitButton: true }); + } + ); + } + + submit() { + if (this.mode === this.roleFormMode.editing) { + this.editAction(); + } else { + this.createAction(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.model.ts new file mode 100644 index 000000000..74a7323be --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.model.ts @@ -0,0 +1,5 @@ +export class RoleFormModel { + name: string; + description: string; + scopes_permissions: any; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.html new file mode 100644 index 000000000..6b8a5d73e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.html @@ -0,0 +1,21 @@ +<cd-user-tabs></cd-user-tabs> + +<cd-table [data]="roles" + columnMode="flex" + [columns]="columns" + identifier="name" + selectionType="single" + [hasDetails]="true" + (setExpandedRow)="setExpandedRow($event)" + (fetchData)="getRoles()" + (updateSelection)="updateSelection($event)"> + <cd-table-actions class="table-actions" + [permission]="permission" + [selection]="selection" + [tableActions]="tableActions"> + </cd-table-actions> + <cd-role-details cdTableDetail + [selection]="expandedRow" + [scopes]="scopes"> + </cd-role-details> +</cd-table> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.spec.ts new file mode 100644 index 000000000..373e37b9d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.spec.ts @@ -0,0 +1,83 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; + +import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper'; +import { RoleDetailsComponent } from '../role-details/role-details.component'; +import { UserTabsComponent } from '../user-tabs/user-tabs.component'; +import { RoleListComponent } from './role-list.component'; + +describe('RoleListComponent', () => { + let component: RoleListComponent; + let fixture: ComponentFixture<RoleListComponent>; + + configureTestBed({ + declarations: [RoleListComponent, RoleDetailsComponent, UserTabsComponent], + imports: [ + BrowserAnimationsModule, + SharedModule, + ToastrModule.forRoot(), + NgbNavModule, + RouterTestingModule, + HttpClientTestingModule + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RoleListComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should test all TableActions combinations', () => { + const permissionHelper: PermissionHelper = new PermissionHelper(component.permission); + const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions( + component.tableActions + ); + + expect(tableActions).toEqual({ + 'create,update,delete': { + actions: ['Create', 'Clone', 'Edit', 'Delete'], + primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' } + }, + 'create,update': { + actions: ['Create', 'Clone', 'Edit'], + primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' } + }, + 'create,delete': { + actions: ['Create', 'Clone', 'Delete'], + primary: { multiple: 'Create', executing: 'Delete', single: 'Delete', no: 'Create' } + }, + create: { + actions: ['Create', 'Clone'], + primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' } + }, + 'update,delete': { + actions: ['Edit', 'Delete'], + primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' } + }, + update: { + actions: ['Edit'], + primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' } + }, + delete: { + actions: ['Delete'], + primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' } + }, + 'no-permissions': { + actions: [], + primary: { multiple: '', executing: '', single: '', no: '' } + } + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts new file mode 100644 index 000000000..83dcd69fa --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts @@ -0,0 +1,169 @@ +import { Component, OnInit } from '@angular/core'; + +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { forkJoin } from 'rxjs'; + +import { RoleService } from '~/app/shared/api/role.service'; +import { ScopeService } from '~/app/shared/api/scope.service'; +import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; +import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { FormModalComponent } from '~/app/shared/components/form-modal/form-modal.component'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { Permission } from '~/app/shared/models/permissions'; +import { EmptyPipe } from '~/app/shared/pipes/empty.pipe'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { URLBuilderService } from '~/app/shared/services/url-builder.service'; + +const BASE_URL = 'user-management/roles'; + +@Component({ + selector: 'cd-role-list', + templateUrl: './role-list.component.html', + styleUrls: ['./role-list.component.scss'], + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }] +}) +export class RoleListComponent extends ListWithDetails implements OnInit { + permission: Permission; + tableActions: CdTableAction[]; + columns: CdTableColumn[]; + roles: Array<any>; + scopes: Array<string>; + selection = new CdTableSelection(); + + modalRef: NgbModalRef; + + constructor( + private roleService: RoleService, + private scopeService: ScopeService, + private emptyPipe: EmptyPipe, + private authStorageService: AuthStorageService, + private modalService: ModalService, + private notificationService: NotificationService, + private urlBuilder: URLBuilderService, + public actionLabels: ActionLabelsI18n + ) { + super(); + this.permission = this.authStorageService.getPermissions().user; + const addAction: CdTableAction = { + permission: 'create', + icon: Icons.add, + routerLink: () => this.urlBuilder.getCreate(), + name: this.actionLabels.CREATE + }; + const cloneAction: CdTableAction = { + permission: 'create', + icon: Icons.clone, + name: this.actionLabels.CLONE, + disable: () => !this.selection.hasSingleSelection, + click: () => this.cloneRole() + }; + const editAction: CdTableAction = { + permission: 'update', + icon: Icons.edit, + disable: () => !this.selection.hasSingleSelection || this.selection.first().system, + routerLink: () => + this.selection.first() && this.urlBuilder.getEdit(this.selection.first().name), + name: this.actionLabels.EDIT + }; + const deleteAction: CdTableAction = { + permission: 'delete', + icon: Icons.destroy, + disable: () => !this.selection.hasSingleSelection || this.selection.first().system, + click: () => this.deleteRoleModal(), + name: this.actionLabels.DELETE + }; + this.tableActions = [addAction, cloneAction, editAction, deleteAction]; + } + + ngOnInit() { + this.columns = [ + { + name: $localize`Name`, + prop: 'name', + flexGrow: 3 + }, + { + name: $localize`Description`, + prop: 'description', + flexGrow: 5, + pipe: this.emptyPipe + }, + { + name: $localize`System Role`, + prop: 'system', + cellClass: 'text-center', + flexGrow: 1, + cellTransformation: CellTemplate.checkIcon + } + ]; + } + + getRoles() { + forkJoin([this.roleService.list(), this.scopeService.list()]).subscribe( + (data: [Array<any>, Array<string>]) => { + this.roles = data[0]; + this.scopes = data[1]; + } + ); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + deleteRole(role: string) { + this.roleService.delete(role).subscribe( + () => { + this.getRoles(); + this.modalRef.close(); + this.notificationService.show(NotificationType.success, $localize`Deleted role '${role}'`); + }, + () => { + this.modalRef.componentInstance.stopLoadingSpinner(); + } + ); + } + + deleteRoleModal() { + const name = this.selection.first().name; + this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { + itemDescription: 'Role', + itemNames: [name], + submitAction: () => this.deleteRole(name) + }); + } + + cloneRole() { + const name = this.selection.first().name; + this.modalRef = this.modalService.show(FormModalComponent, { + fields: [ + { + type: 'text', + name: 'newName', + value: `${name}_clone`, + label: $localize`New name`, + required: true + } + ], + titleText: $localize`Clone Role`, + submitButtonText: $localize`Clone Role`, + onSubmit: (values: object) => { + this.roleService.clone(name, values['newName']).subscribe(() => { + this.getRoles(); + this.notificationService.show( + NotificationType.success, + $localize`Cloned role '${values['newName']}' from '${name}'` + ); + }); + } + }); + } +} 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 000000000..8cae7d15f --- /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 000000000..2d323b04e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts @@ -0,0 +1,14 @@ +import { SelectOption } from '~/app/shared/components/select/select-option.model'; + +export class UserFormRoleModel implements SelectOption { + name: string; + description: string; + selected = false; + scopes_permissions: object; + enabled = true; + + constructor(name: string, description: string) { + 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 000000000..df97face8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html @@ -0,0 +1,263 @@ +<div class="cd-col-form" + *cdFormLoading="loading"> + <form name="userForm" + #formDir="ngForm" + [formGroup]="userForm" + novalidate> + <div class="card"> + <div i18n="form title" + class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div> + <div class="card-body"> + + <!-- Username --> + <div class="form-group row"> + <label class="cd-col-form-label" + [ngClass]="{'required': mode !== userFormMode.editing}" + for="username" + i18n>Username</label> + <div class="cd-col-form-input"> + <input class="form-control" + type="text" + placeholder="Username..." + id="username" + name="username" + formControlName="username" + autocomplete="off" + autofocus> + <span class="invalid-feedback" + *ngIf="userForm.showError('username', formDir, 'required')" + i18n>This field is required.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('username', formDir, 'notUnique')" + i18n>The username already exists.</span> + </div> + </div> + + <!-- Password --> + <div class="form-group row" + *ngIf="!authStorageService.isSSO()"> + <label class="cd-col-form-label" + for="password"> + <ng-container i18n>Password</ng-container> + <cd-helper *ngIf="passwordPolicyHelpText.length > 0" + class="text-pre-wrap" + html="{{ passwordPolicyHelpText }}"> + </cd-helper> + </label> + <div class="cd-col-form-input"> + <div class="input-group"> + <input class="form-control" + type="password" + placeholder="Password..." + id="password" + name="password" + autocomplete="new-password" + formControlName="password"> + <span class="input-group-append"> + <button type="button" + class="btn btn-light" + cdPasswordButton="password"> + </button> + </span> + </div> + <div class="password-strength-level"> + <div class="{{ passwordStrengthLevelClass }}" + data-toggle="tooltip" + title="{{ passwordValuation }}"> + </div> + </div> + <span class="invalid-feedback" + *ngIf="userForm.showError('password', formDir, 'required')" + i18n>This field is required.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('password', formDir, 'passwordPolicy')"> + {{ passwordValuation }} + </span> + </div> + </div> + + <!-- Confirm password --> + <div class="form-group row" + *ngIf="!authStorageService.isSSO()"> + <label i18n + class="cd-col-form-label" + for="confirmpassword">Confirm password</label> + <div class="cd-col-form-input"> + <div class="input-group"> + <input class="form-control" + type="password" + placeholder="Confirm password..." + id="confirmpassword" + name="confirmpassword" + autocomplete="new-password" + formControlName="confirmpassword"> + <span class="input-group-append"> + <button type="button" + class="btn btn-light" + cdPasswordButton="confirmpassword"> + </button> + </span> + <span class="invalid-feedback" + *ngIf="userForm.showError('confirmpassword', formDir, 'match')" + i18n>Password confirmation doesn't match the password.</span> + </div> + <span class="invalid-feedback" + *ngIf="userForm.showError('confirmpassword', formDir, 'required')" + i18n>This field is required.</span> + </div> + </div> + + <!-- Password expiration date --> + <div class="form-group row" + *ngIf="!authStorageService.isSSO()"> + <label class="cd-col-form-label" + [ngClass]="{'required': pwdExpirationSettings.pwdExpirationSpan > 0}" + for="pwdExpirationDate"> + <ng-container i18n>Password expiration date</ng-container> + <cd-helper class="text-pre-wrap" + *ngIf="pwdExpirationSettings.pwdExpirationSpan == 0"> + <p> + The Dashboard setting defining the expiration interval of + passwords is currently set to <strong>0</strong>. This means + if a date is set, the user password will only expire once. + </p> + <p> + Consider configuring the Dashboard setting + <a routerLink="/mgr-modules/edit/dashboard" + class="alert-link">USER_PWD_EXPIRATION_SPAN</a> + in order to let passwords expire periodically. + </p> + </cd-helper> + </label> + <div class="cd-col-form-input"> + <div class="input-group"> + <input class="form-control" + i18n-placeholder + placeholder="Password expiration date..." + id="pwdExpirationDate" + name="pwdExpirationDate" + formControlName="pwdExpirationDate" + [ngbPopover]="popContent" + triggers="manual" + #p="ngbPopover" + (click)="p.open()" + (keypress)="p.close()"> + <span class="input-group-append"> + <button type="button" + class="btn btn-light" + (click)="clearExpirationDate()"> + <i class="icon-prepend {{ icons.destroy }}"></i> + </button> + </span> + <span class="invalid-feedback" + *ngIf="userForm.showError('pwdExpirationDate', formDir, 'required')" + i18n>This field is required.</span> + </div> + </div> + </div> + + <!-- Name --> + <div class="form-group row"> + <label i18n + class="cd-col-form-label" + for="name">Full name</label> + <div class="cd-col-form-input"> + <input class="form-control" + type="text" + placeholder="Full name..." + id="name" + name="name" + formControlName="name"> + </div> + </div> + + <!-- Email --> + <div class="form-group row"> + <label i18n + class="cd-col-form-label" + for="email">Email</label> + <div class="cd-col-form-input"> + <input class="form-control" + type="email" + placeholder="Email..." + id="email" + name="email" + formControlName="email"> + + <span class="invalid-feedback" + *ngIf="userForm.showError('email', formDir, 'email')" + i18n>Invalid email.</span> + </div> + </div> + + <!-- Roles --> + <div class="form-group row"> + <label class="cd-col-form-label" + i18n>Roles</label> + <div class="cd-col-form-input"> + <span class="no-border full-height" + *ngIf="allRoles"> + <cd-select-badges [data]="userForm.controls.roles.value" + [options]="allRoles" + [messages]="messages"></cd-select-badges> + </span> + </div> + </div> + + <!-- Enabled --> + <div class="form-group row" + *ngIf="!isCurrentUser()"> + <div class="cd-col-form-offset"> + <div class="custom-control custom-checkbox"> + <input type="checkbox" + class="custom-control-input" + id="enabled" + name="enabled" + formControlName="enabled"> + <label class="custom-control-label" + for="enabled" + i18n>Enabled</label> + </div> + </div> + </div> + + <!-- Force change password --> + <div class="form-group row" + *ngIf="!isCurrentUser() && !authStorageService.isSSO()"> + <div class="cd-col-form-offset"> + <div class="custom-control custom-checkbox"> + <input type="checkbox" + class="custom-control-input" + id="pwdUpdateRequired" + name="pwdUpdateRequired" + formControlName="pwdUpdateRequired"> + <label class="custom-control-label" + for="pwdUpdateRequired" + i18n>User must change password at next logon</label> + </div> + </div> + </div> + + </div> + <div class="card-footer"> + <cd-form-button-panel (submitActionEvent)="submit()" + [form]="userForm" + [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)" + wrappingClass="text-right"></cd-form-button-panel> + </div> + </div> + </form> +</div> + +<ng-template #removeSelfUserReadUpdatePermissionTpl> + <p><strong i18n>You are about to remove "user read / update" permissions from your own user.</strong></p> + <br> + <p i18n>If you continue, you will no longer be able to add or remove roles from any user.</p> + + <ng-container i18n>Are you sure you want to continue?</ng-container> +</ng-template> + +<ng-template #popContent> + <cd-date-time-picker [control]="userForm.get('pwdExpirationDate')" + [hasTime]="false"></cd-date-time-picker> +</ng-template> 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 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.scss 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 000000000..4f95ac1e2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts @@ -0,0 +1,258 @@ +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 { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; +import { of } from 'rxjs'; + +import { RoleService } from '~/app/shared/api/role.service'; +import { SettingsService } from '~/app/shared/api/settings.service'; +import { UserService } from '~/app/shared/api/user.service'; +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { LoadingPanelComponent } from '~/app/shared/components/loading-panel/loading-panel.component'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { PasswordPolicyService } from '~/app/shared/services/password-policy.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed, FormHelper } from '~/testing/unit-test-helper'; +import { UserFormComponent } from './user-form.component'; +import { UserFormModel } from './user-form.model'; + +describe('UserFormComponent', () => { + let component: UserFormComponent; + let form: CdFormGroup; + let fixture: ComponentFixture<UserFormComponent>; + let httpTesting: HttpTestingController; + let userService: UserService; + let modalService: ModalService; + let router: Router; + let formHelper: FormHelper; + + const setUrl = (url: string) => 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, + ComponentsModule, + ToastrModule.forRoot(), + SharedModule, + NgbPopoverModule + ], + declarations: [UserFormComponent, FakeComponent] + }, + [LoadingPanelComponent] + ); + + beforeEach(() => { + spyOn(TestBed.inject(PasswordPolicyService), 'getHelpText').and.callFake(() => of('')); + fixture = TestBed.createComponent(UserFormComponent); + component = fixture.componentInstance; + form = component.userForm; + httpTesting = TestBed.inject(HttpTestingController); + userService = TestBed.inject(UserService); + modalService = TestBed.inject(ModalService); + router = TestBed.inject(Router); + spyOn(router, 'navigate'); + fixture.detectChanges(); + const notify = TestBed.inject(NotificationService); + spyOn(notify, 'show'); + formHelper = new FormHelper(form); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(form).toBeTruthy(); + }); + + describe('create mode', () => { + beforeEach(() => { + setUrl('/user-management/users/add'); + component.ngOnInit(); + }); + + it('should not disable fields', () => { + [ + 'username', + 'name', + 'password', + 'confirmpassword', + 'email', + 'roles', + 'pwdExpirationDate' + ].forEach((key) => expect(form.get(key).disabled).toBeFalsy()); + }); + + it('should validate username required', () => { + formHelper.expectErrorChange('username', '', 'required'); + formHelper.expectValidChange('username', 'user1'); + }); + + it('should validate password match', () => { + formHelper.setValue('password', 'aaa'); + formHelper.expectErrorChange('confirmpassword', 'bbb', 'match'); + formHelper.expectValidChange('confirmpassword', 'aaa'); + }); + + it('should validate email', () => { + formHelper.expectErrorChange('email', 'aaa', 'email'); + }); + + 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'], + enabled: true, + pwdExpirationDate: undefined, + pwdUpdateRequired: true + }; + formHelper.setMultipleValues(user); + formHelper.setValue('confirmpassword', 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(['/user-management/users']); + }); + }); + + describe('edit mode', () => { + const user: UserFormModel = { + username: 'user1', + password: undefined, + name: 'User 1', + email: 'user1@email.com', + roles: ['administrator'], + enabled: true, + pwdExpirationDate: undefined, + pwdUpdateRequired: true + }; + 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.inject(RoleService), 'list').and.callFake(() => of(roles)); + setUrl('/user-management/users/edit/user1'); + spyOn(TestBed.inject(SettingsService), 'getStandardSettings').and.callFake(() => + of({ + user_pwd_expiration_warning_1: 10, + user_pwd_expiration_warning_2: 5, + user_pwd_expiration_span: 90 + }) + ); + component.ngOnInit(); + const req = httpTesting.expectOne('api/role'); + expect(req.request.method).toBe('GET'); + req.flush(roles); + httpTesting.expectOne('ui-api/standard_settings'); + }); + + 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 alert if user is removing needed role permission', () => { + spyOn(TestBed.inject(AuthStorageService), 'getUsername').and.callFake(() => user.username); + let modalBodyTpl = null; + spyOn(modalService, 'show').and.callFake((_content, initialState) => { + modalBodyTpl = initialState.bodyTpl; + }); + formHelper.setValue('roles', ['read-only']); + component.submit(); + expect(modalBodyTpl).toEqual(component.removeSelfUserReadUpdatePermissionTpl); + }); + + it('should logout if current user roles have been changed', () => { + spyOn(TestBed.inject(AuthStorageService), 'getUsername').and.callFake(() => user.username); + formHelper.setValue('roles', ['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/logout'); + expect(authReq.request.method).toBe('POST'); + }); + + it('should submit', () => { + spyOn(TestBed.inject(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: '', + pwdUpdateRequired: true, + name: 'User 1', + email: 'user1@email.com', + roles: ['administrator'], + enabled: true + }); + userReq.flush({}); + expect(router.navigate).toHaveBeenCalledWith(['/user-management/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 000000000..1a0ddf35c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts @@ -0,0 +1,305 @@ +import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import _ from 'lodash'; +import moment from 'moment'; +import { forkJoin as observableForkJoin } from 'rxjs'; + +import { AuthService } from '~/app/shared/api/auth.service'; +import { RoleService } from '~/app/shared/api/role.service'; +import { SettingsService } from '~/app/shared/api/settings.service'; +import { UserService } from '~/app/shared/api/user.service'; +import { ConfirmationModalComponent } from '~/app/shared/components/confirmation-modal/confirmation-modal.component'; +import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { CdForm } from '~/app/shared/forms/cd-form'; +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { CdPwdExpirationSettings } from '~/app/shared/models/cd-pwd-expiration-settings'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { PasswordPolicyService } from '~/app/shared/services/password-policy.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 extends CdForm implements OnInit { + @ViewChild('removeSelfUserReadUpdatePermissionTpl', { static: true }) + removeSelfUserReadUpdatePermissionTpl: TemplateRef<any>; + + modalRef: NgbModalRef; + + userForm: CdFormGroup; + response: UserFormModel; + + userFormMode = UserFormMode; + mode: UserFormMode; + allRoles: Array<UserFormRoleModel>; + messages = new SelectMessages({ empty: $localize`There are no roles.` }); + action: string; + resource: string; + passwordPolicyHelpText = ''; + passwordStrengthLevelClass: string; + passwordValuation: string; + icons = Icons; + pwdExpirationSettings: CdPwdExpirationSettings; + pwdExpirationFormat = 'YYYY-MM-DD'; + + constructor( + private authService: AuthService, + private authStorageService: AuthStorageService, + private route: ActivatedRoute, + public router: Router, + private modalService: ModalService, + private roleService: RoleService, + private userService: UserService, + private notificationService: NotificationService, + public actionLabels: ActionLabelsI18n, + private passwordPolicyService: PasswordPolicyService, + private formBuilder: CdFormBuilder, + private settingsService: SettingsService + ) { + super(); + this.resource = $localize`user`; + this.createForm(); + this.messages = new SelectMessages({ empty: $localize`There are no roles.` }); + } + + createForm() { + this.passwordPolicyService.getHelpText().subscribe((helpText: string) => { + this.passwordPolicyHelpText = helpText; + }); + this.userForm = this.formBuilder.group( + { + username: [ + '', + [Validators.required], + [CdValidators.unique(this.userService.validateUserName, this.userService)] + ], + name: [''], + password: [ + '', + [], + [ + CdValidators.passwordPolicy( + this.userService, + () => this.userForm.getValue('username'), + (_valid: boolean, credits: number, valuation: string) => { + this.passwordStrengthLevelClass = this.passwordPolicyService.mapCreditsToCssClass( + credits + ); + this.passwordValuation = _.defaultTo(valuation, ''); + } + ) + ] + ], + confirmpassword: [''], + pwdExpirationDate: [undefined], + email: ['', [CdValidators.email]], + roles: [[]], + enabled: [true, [Validators.required]], + pwdUpdateRequired: [true] + }, + { + validators: [CdValidators.match('password', 'confirmpassword')] + } + ); + } + + ngOnInit() { + if (this.router.url.startsWith('/user-management/users/edit')) { + this.mode = this.userFormMode.editing; + this.action = this.actionLabels.EDIT; + } else { + this.action = this.actionLabels.CREATE; + } + + const observables = [this.roleService.list(), this.settingsService.getStandardSettings()]; + observableForkJoin(observables).subscribe( + (result: [UserFormRoleModel[], CdPwdExpirationSettings]) => { + this.allRoles = _.map(result[0], (role) => { + role.enabled = true; + return role; + }); + this.pwdExpirationSettings = new CdPwdExpirationSettings(result[1]); + + if (this.mode === this.userFormMode.editing) { + this.initEdit(); + } else { + if (this.pwdExpirationSettings.pwdExpirationSpan > 0) { + const pwdExpirationDateField = this.userForm.get('pwdExpirationDate'); + const expirationDate = moment(); + expirationDate.add(this.pwdExpirationSettings.pwdExpirationSpan, 'day'); + pwdExpirationDateField.setValue(expirationDate.format(this.pwdExpirationFormat)); + pwdExpirationDateField.setValidators([Validators.required]); + } + + this.loadingReady(); + } + } + ); + } + + initEdit() { + 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); + + this.loadingReady(); + }); + }); + } + + disableForEdit() { + this.userForm.get('username').disable(); + } + + setResponse(response: UserFormModel) { + ['username', 'name', 'email', 'roles', 'enabled', 'pwdUpdateRequired'].forEach((key) => + this.userForm.get(key).setValue(response[key]) + ); + const expirationDate = response['pwdExpirationDate']; + if (expirationDate) { + this.userForm + .get('pwdExpirationDate') + .setValue(moment(expirationDate * 1000).format(this.pwdExpirationFormat)); + } + } + + getRequest(): UserFormModel { + const userFormModel = new UserFormModel(); + ['username', 'password', 'name', 'email', 'roles', 'enabled', 'pwdUpdateRequired'].forEach( + (key) => (userFormModel[key] = this.userForm.get(key).value) + ); + const expirationDate = this.userForm.get('pwdExpirationDate').value; + if (expirationDate) { + const mom = moment(expirationDate, this.pwdExpirationFormat); + if ( + this.mode !== this.userFormMode.editing || + this.response.pwdExpirationDate !== mom.unix() + ) { + mom.set({ hour: 23, minute: 59, second: 59 }); + } + userFormModel['pwdExpirationDate'] = mom.unix(); + } + return userFormModel; + } + + createAction() { + const userFormModel = this.getRequest(); + this.userService.create(userFormModel).subscribe( + () => { + this.notificationService.show( + NotificationType.success, + $localize`Created user '${userFormModel.username}'` + ); + this.router.navigate(['/user-management/users']); + }, + () => { + this.userForm.setErrors({ cdSubmitButton: true }); + } + ); + } + + editAction() { + if (this.isUserRemovingNeededRolePermissions()) { + const initialState = { + titleText: $localize`Update user`, + buttonText: $localize`Continue`, + bodyTpl: this.removeSelfUserReadUpdatePermissionTpl, + onSubmit: () => { + this.modalRef.close(); + 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(); + } + } + + public 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<string> = []) { + 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, + $localize`You were automatically logged out because your roles have been changed.` + ); + }); + } else { + this.notificationService.show( + NotificationType.success, + $localize`Updated user '${userFormModel.username}'` + ); + this.router.navigate(['/user-management/users']); + } + }, + () => { + this.userForm.setErrors({ cdSubmitButton: true }); + } + ); + } + + clearExpirationDate() { + this.userForm.get('pwdExpirationDate').setValue(undefined); + } + + 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 000000000..2dc88ab5a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts @@ -0,0 +1,10 @@ +export class UserFormModel { + username: string; + password: string; + pwdExpirationDate: number; + name: string; + email: string; + roles: Array<string>; + enabled: boolean; + pwdUpdateRequired: boolean; +} 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 000000000..89ed21be8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html @@ -0,0 +1,22 @@ +<cd-user-tabs></cd-user-tabs> + +<cd-table [data]="users" + columnMode="flex" + [columns]="columns" + identifier="username" + selectionType="single" + (fetchData)="getUsers()" + (updateSelection)="updateSelection($event)"> + <cd-table-actions class="table-actions" + [permission]="permission" + [selection]="selection" + [tableActions]="tableActions"> + </cd-table-actions> +</cd-table> + +<ng-template #userRolesTpl + let-value="value"> + <span *ngFor="let role of value; last as isLast"> + {{ role }}{{ !isLast ? ", " : "" }} + </span> +</ng-template> 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 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.scss 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 000000000..a1b9cfd14 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts @@ -0,0 +1,82 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; + +import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper'; +import { UserTabsComponent } from '../user-tabs/user-tabs.component'; +import { UserListComponent } from './user-list.component'; + +describe('UserListComponent', () => { + let component: UserListComponent; + let fixture: ComponentFixture<UserListComponent>; + + configureTestBed({ + imports: [ + BrowserAnimationsModule, + SharedModule, + ToastrModule.forRoot(), + NgbNavModule, + RouterTestingModule, + HttpClientTestingModule + ], + declarations: [UserListComponent, UserTabsComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UserListComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should test all TableActions combinations', () => { + const permissionHelper: PermissionHelper = new PermissionHelper(component.permission); + const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions( + component.tableActions + ); + + expect(tableActions).toEqual({ + 'create,update,delete': { + actions: ['Create', 'Edit', 'Delete'], + primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' } + }, + 'create,update': { + actions: ['Create', 'Edit'], + primary: { multiple: 'Create', executing: 'Edit', single: 'Edit', no: 'Create' } + }, + 'create,delete': { + actions: ['Create', 'Delete'], + primary: { multiple: 'Create', executing: 'Delete', single: 'Delete', no: 'Create' } + }, + create: { + actions: ['Create'], + primary: { multiple: 'Create', executing: 'Create', single: 'Create', no: 'Create' } + }, + 'update,delete': { + actions: ['Edit', 'Delete'], + primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' } + }, + update: { + actions: ['Edit'], + primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' } + }, + delete: { + actions: ['Delete'], + primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' } + }, + 'no-permissions': { + actions: [], + primary: { multiple: '', executing: '', single: '', no: '' } + } + }); + }); +}); 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 000000000..09c0d82fc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts @@ -0,0 +1,164 @@ +import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; + +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +import { UserService } from '~/app/shared/api/user.service'; +import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { Permission } from '~/app/shared/models/permissions'; +import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe'; +import { EmptyPipe } from '~/app/shared/pipes/empty.pipe'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { URLBuilderService } from '~/app/shared/services/url-builder.service'; + +const BASE_URL = 'user-management/users'; + +@Component({ + selector: 'cd-user-list', + templateUrl: './user-list.component.html', + styleUrls: ['./user-list.component.scss'], + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }] +}) +export class UserListComponent implements OnInit { + @ViewChild('userRolesTpl', { static: true }) + userRolesTpl: TemplateRef<any>; + + permission: Permission; + tableActions: CdTableAction[]; + columns: CdTableColumn[]; + users: Array<any>; + selection = new CdTableSelection(); + + modalRef: NgbModalRef; + + constructor( + private userService: UserService, + private emptyPipe: EmptyPipe, + private modalService: ModalService, + private notificationService: NotificationService, + private authStorageService: AuthStorageService, + private urlBuilder: URLBuilderService, + private cdDatePipe: CdDatePipe, + public actionLabels: ActionLabelsI18n + ) { + this.permission = this.authStorageService.getPermissions().user; + const addAction: CdTableAction = { + permission: 'create', + icon: Icons.add, + routerLink: () => this.urlBuilder.getCreate(), + name: this.actionLabels.CREATE + }; + const editAction: CdTableAction = { + permission: 'update', + icon: Icons.edit, + routerLink: () => + this.selection.first() && this.urlBuilder.getEdit(this.selection.first().username), + name: this.actionLabels.EDIT + }; + const deleteAction: CdTableAction = { + permission: 'delete', + icon: Icons.destroy, + click: () => this.deleteUserModal(), + name: this.actionLabels.DELETE + }; + this.tableActions = [addAction, editAction, deleteAction]; + } + + ngOnInit() { + this.columns = [ + { + name: $localize`Username`, + prop: 'username', + flexGrow: 1 + }, + { + name: $localize`Name`, + prop: 'name', + flexGrow: 1, + pipe: this.emptyPipe + }, + { + name: $localize`Email`, + prop: 'email', + flexGrow: 1, + pipe: this.emptyPipe + }, + { + name: $localize`Roles`, + prop: 'roles', + flexGrow: 1, + cellTemplate: this.userRolesTpl + }, + { + name: $localize`Enabled`, + prop: 'enabled', + flexGrow: 1, + cellTransformation: CellTemplate.checkIcon + }, + { + name: $localize`Password expiration date`, + prop: 'pwdExpirationDate', + flexGrow: 1, + pipe: this.cdDatePipe + } + ]; + } + + getUsers() { + this.userService.list().subscribe((users: Array<any>) => { + users.forEach((user) => { + if (user['pwdExpirationDate'] && user['pwdExpirationDate'] > 0) { + user['pwdExpirationDate'] = user['pwdExpirationDate'] * 1000; + } + }); + this.users = users; + }); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + deleteUser(username: string) { + this.userService.delete(username).subscribe( + () => { + this.getUsers(); + this.modalRef.close(); + this.notificationService.show( + NotificationType.success, + $localize`Deleted user '${username}'` + ); + }, + () => { + this.modalRef.componentInstance.stopLoadingSpinner(); + } + ); + } + + deleteUserModal() { + const sessionUsername = this.authStorageService.getUsername(); + const username = this.selection.first().username; + if (sessionUsername === username) { + this.notificationService.show( + NotificationType.error, + $localize`Failed to delete user '${username}'`, + $localize`You are currently logged in as '${username}'.` + ); + return; + } + + this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { + itemDescription: 'User', + itemNames: [username], + submitAction: () => this.deleteUser(username) + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html new file mode 100644 index 000000000..83eb40944 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html @@ -0,0 +1,121 @@ +<div class="cd-col-form"> + <form #frm="ngForm" + [formGroup]="userForm" + novalidate> + <div class="card"> + <div i18n="form title" + class="card-header">{{ action | titlecase }} {{ resource | upperFirst }}</div> + + <div class="card-body"> + <!-- Old password --> + <div class="form-group row"> + <label class="cd-col-form-label required" + for="oldpassword" + i18n>Old password</label> + <div class="cd-col-form-input"> + <div class="input-group"> + <input class="form-control" + type="password" + placeholder="Old password..." + id="oldpassword" + formControlName="oldpassword" + autocomplete="new-password" + autofocus> + <span class="input-group-append"> + <button class="btn btn-light" + cdPasswordButton="oldpassword"> + </button> + </span> + </div> + <span class="invalid-feedback" + *ngIf="userForm.showError('oldpassword', frm, 'required')" + i18n>This field is required.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('oldpassword', frm, 'notmatch')" + i18n>The old and new passwords must be different.</span> + </div> + </div> + + <!-- New password --> + <div class="form-group row"> + <label class="cd-col-form-label" + for="newpassword"> + <span class="required" + i18n>New password</span> + <cd-helper *ngIf="passwordPolicyHelpText.length > 0" + class="text-pre-wrap" + html="{{ passwordPolicyHelpText }}"> + </cd-helper> + </label> + <div class="cd-col-form-input"> + <div class="input-group"> + <input class="form-control" + type="password" + placeholder="Password..." + id="newpassword" + autocomplete="new-password" + formControlName="newpassword"> + <span class="input-group-append"> + <button type="button" + class="btn btn-light" + cdPasswordButton="newpassword"> + </button> + </span> + </div> + <div class="password-strength-level"> + <div class="{{ passwordStrengthLevelClass }}" + data-toggle="tooltip" + title="{{ passwordValuation }}"> + </div> + </div> + <span class="invalid-feedback" + *ngIf="userForm.showError('newpassword', frm, 'required')" + i18n>This field is required.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('newpassword', frm, 'notmatch')" + i18n>The old and new passwords must be different.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('newpassword', frm, 'passwordPolicy')"> + {{ passwordValuation }} + </span> + </div> + </div> + + <!-- Confirm new password --> + <div class="form-group row"> + <label class="cd-col-form-label required" + for="confirmnewpassword" + i18n>Confirm new password</label> + <div class="cd-col-form-input"> + <div class="input-group"> + <input class="form-control" + type="password" + autocomplete="new-password" + placeholder="Confirm new password..." + id="confirmnewpassword" + formControlName="confirmnewpassword"> + <span class="input-group-append"> + <button class="btn btn-light" + cdPasswordButton="confirmnewpassword"> + </button> + </span> + </div> + <span class="invalid-feedback" + *ngIf="userForm.showError('confirmnewpassword', frm, 'required')" + i18n>This field is required.</span> + <span class="invalid-feedback" + *ngIf="userForm.showError('confirmnewpassword', frm, 'match')" + i18n>Password confirmation doesn't match the new password.</span> + </div> + </div> + </div> + + <div class="card-footer"> + <cd-form-button-panel (submitActionEvent)="onSubmit()" + [form]="userForm" + [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)" + wrappingClass="text-right"></cd-form-button-panel> + </div> + </div> + </form> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.spec.ts new file mode 100644 index 000000000..b1df8cf42 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.spec.ts @@ -0,0 +1,83 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastrModule } from 'ngx-toastr'; + +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed, FormHelper } from '~/testing/unit-test-helper'; +import { UserPasswordFormComponent } from './user-password-form.component'; + +describe('UserPasswordFormComponent', () => { + let component: UserPasswordFormComponent; + let fixture: ComponentFixture<UserPasswordFormComponent>; + let form: CdFormGroup; + let formHelper: FormHelper; + let httpTesting: HttpTestingController; + let router: Router; + let authStorageService: AuthStorageService; + + configureTestBed({ + imports: [ + HttpClientTestingModule, + RouterTestingModule, + ReactiveFormsModule, + ComponentsModule, + ToastrModule.forRoot(), + SharedModule + ], + declarations: [UserPasswordFormComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UserPasswordFormComponent); + component = fixture.componentInstance; + form = component.userForm; + httpTesting = TestBed.inject(HttpTestingController); + router = TestBed.inject(Router); + authStorageService = TestBed.inject(AuthStorageService); + spyOn(router, 'navigate'); + fixture.detectChanges(); + formHelper = new FormHelper(form); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should validate old password required', () => { + formHelper.expectErrorChange('oldpassword', '', 'required'); + formHelper.expectValidChange('oldpassword', 'foo'); + }); + + it('should validate password match', () => { + formHelper.setValue('newpassword', 'aaa'); + formHelper.expectErrorChange('confirmnewpassword', 'bbb', 'match'); + formHelper.expectValidChange('confirmnewpassword', 'aaa'); + }); + + it('should submit', () => { + spyOn(component, 'onPasswordChange').and.callThrough(); + spyOn(authStorageService, 'getUsername').and.returnValue('xyz'); + formHelper.setMultipleValues({ + oldpassword: 'foo', + newpassword: 'bar' + }); + formHelper.setValue('confirmnewpassword', 'bar', true); + component.onSubmit(); + const request = httpTesting.expectOne('api/user/xyz/change_password'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({ + old_password: 'foo', + new_password: 'bar' + }); + request.flush({}); + expect(component.onPasswordChange).toHaveBeenCalled(); + expect(router.navigate).toHaveBeenCalledWith(['/login']); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.ts new file mode 100644 index 000000000..dffb927ac --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.ts @@ -0,0 +1,119 @@ +import { Component } from '@angular/core'; +import { Validators } from '@angular/forms'; +import { Router } from '@angular/router'; + +import _ from 'lodash'; + +import { UserService } from '~/app/shared/api/user.service'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { PasswordPolicyService } from '~/app/shared/services/password-policy.service'; + +@Component({ + selector: 'cd-user-password-form', + templateUrl: './user-password-form.component.html', + styleUrls: ['./user-password-form.component.scss'] +}) +export class UserPasswordFormComponent { + userForm: CdFormGroup; + action: string; + resource: string; + passwordPolicyHelpText = ''; + passwordStrengthLevelClass: string; + passwordValuation: string; + icons = Icons; + + constructor( + public actionLabels: ActionLabelsI18n, + public notificationService: NotificationService, + public userService: UserService, + public authStorageService: AuthStorageService, + public formBuilder: CdFormBuilder, + public router: Router, + public passwordPolicyService: PasswordPolicyService + ) { + this.action = this.actionLabels.CHANGE; + this.resource = $localize`password`; + this.createForm(); + } + + createForm() { + this.passwordPolicyService.getHelpText().subscribe((helpText: string) => { + this.passwordPolicyHelpText = helpText; + }); + this.userForm = this.formBuilder.group( + { + oldpassword: [ + null, + [ + Validators.required, + CdValidators.custom('notmatch', () => { + return ( + this.userForm && + this.userForm.getValue('newpassword') === this.userForm.getValue('oldpassword') + ); + }) + ] + ], + newpassword: [ + null, + [ + Validators.required, + CdValidators.custom('notmatch', () => { + return ( + this.userForm && + this.userForm.getValue('oldpassword') === this.userForm.getValue('newpassword') + ); + }) + ], + [ + CdValidators.passwordPolicy( + this.userService, + () => this.authStorageService.getUsername(), + (_valid: boolean, credits: number, valuation: string) => { + this.passwordStrengthLevelClass = this.passwordPolicyService.mapCreditsToCssClass( + credits + ); + this.passwordValuation = _.defaultTo(valuation, ''); + } + ) + ] + ], + confirmnewpassword: [null, [Validators.required]] + }, + { + validators: [CdValidators.match('newpassword', 'confirmnewpassword')] + } + ); + } + + onSubmit() { + if (this.userForm.pristine) { + return; + } + const username = this.authStorageService.getUsername(); + const oldPassword = this.userForm.getValue('oldpassword'); + const newPassword = this.userForm.getValue('newpassword'); + this.userService.changePassword(username, oldPassword, newPassword).subscribe( + () => this.onPasswordChange(), + () => { + this.userForm.setErrors({ cdSubmitButton: true }); + } + ); + } + + /** + * The function that is called after the password has been changed. + * Override this in derived classes to change the behaviour. + */ + onPasswordChange() { + this.notificationService.show(NotificationType.success, $localize`Updated user password"`); + this.router.navigate(['/login']); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.html new file mode 100644 index 000000000..3c102cf66 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.html @@ -0,0 +1,14 @@ +<ul ngbNav + #nav="ngbNav" + [activeId]="router.url" + (navChange)="router.navigate([$event.nextId])" + class="nav-tabs"> + <li ngbNavItem="/user-management/users"> + <a ngbNavLink + i18n>Users</a> + </li> + <li ngbNavItem="/user-management/roles"> + <a ngbNavLink + i18n>Roles</a> + </li> +</ul> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.spec.ts new file mode 100644 index 000000000..f9b8081db --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.spec.ts @@ -0,0 +1,29 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { UserTabsComponent } from './user-tabs.component'; + +describe('UserTabsComponent', () => { + let component: UserTabsComponent; + let fixture: ComponentFixture<UserTabsComponent>; + + configureTestBed({ + imports: [SharedModule, RouterTestingModule, HttpClientTestingModule, NgbNavModule], + declarations: [UserTabsComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UserTabsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.ts new file mode 100644 index 000000000..06626ec3e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'cd-user-tabs', + templateUrl: './user-tabs.component.html', + styleUrls: ['./user-tabs.component.scss'] +}) +export class UserTabsComponent { + url: string; + + constructor(public router: Router) {} +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html new file mode 100644 index 000000000..63af29e13 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html @@ -0,0 +1,27 @@ +<ng-container *ngIf="{ ftMap: featureToggleMap$ | async, daemons: rgwDaemonService.daemons$ | async, selectedDaemon: rgwDaemonService.selectedDaemon$ | async } as data"> + <ng-container *ngIf="data.ftMap && data.ftMap.rgw && permissions.rgw.read && isRgwRoute && data.daemons.length > 1"> + <div class="cd-context-bar pt-3 pb-3"> + <span class="mr-1" + i18n>Selected Object Gateway:</span> + <div ngbDropdown + placement="bottom-left" + class="d-inline-block ml-2"> + <button ngbDropdownToggle + class="btn btn-outline-info ctx-bar-selected-rgw-daemon" + i18n-title + title="Select Object Gateway"> + {{ data.selectedDaemon.id }} ( {{ data.selectedDaemon.zonegroup_name }} ) + </button> + <div ngbDropdownMenu> + <ng-container *ngFor="let daemon of data.daemons"> + <button ngbDropdownItem + class="ctx-bar-available-rgw-daemon" + (click)="onDaemonSelection(daemon)"> + {{ daemon.id }} ( {{ daemon.zonegroup_name }} ) + </button> + </ng-container> + </div> + </div> + </div> + </ng-container> +</ng-container> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss new file mode 100644 index 000000000..0cd44f150 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss @@ -0,0 +1,5 @@ +@use './src/styles/vendor/variables' as vv; + +.cd-context-bar { + border-bottom: 1px solid vv.$gray-300; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts new file mode 100644 index 000000000..9512e3183 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts @@ -0,0 +1,100 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of } from 'rxjs'; + +import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { + FeatureTogglesMap, + FeatureTogglesService +} from '~/app/shared/services/feature-toggles.service'; +import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper'; +import { ContextComponent } from './context.component'; + +describe('ContextComponent', () => { + let component: ContextComponent; + let fixture: ComponentFixture<ContextComponent>; + let router: Router; + let routerNavigateByUrlSpy: jasmine.Spy; + let routerNavigateSpy: jasmine.Spy; + let getPermissionsSpy: jasmine.Spy; + let getFeatureTogglesSpy: jasmine.Spy; + let ftMap: FeatureTogglesMap; + let httpTesting: HttpTestingController; + + const daemonList = RgwHelper.getDaemonList(); + + configureTestBed({ + declarations: [ContextComponent], + imports: [HttpClientTestingModule, RouterTestingModule] + }); + + beforeEach(() => { + httpTesting = TestBed.inject(HttpTestingController); + router = TestBed.inject(Router); + routerNavigateByUrlSpy = spyOn(router, 'navigateByUrl'); + routerNavigateByUrlSpy.and.returnValue(Promise.resolve(undefined)); + routerNavigateSpy = spyOn(router, 'navigate'); + getPermissionsSpy = spyOn(TestBed.inject(AuthStorageService), 'getPermissions'); + getPermissionsSpy.and.returnValue( + new Permissions({ rgw: ['read', 'update', 'create', 'delete'] }) + ); + getFeatureTogglesSpy = spyOn(TestBed.inject(FeatureTogglesService), 'get'); + ftMap = new FeatureTogglesMap(); + ftMap.rgw = true; + getFeatureTogglesSpy.and.returnValue(of(ftMap)); + fixture = TestBed.createComponent(ContextComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not show any info if not in RGW route', () => { + component.isRgwRoute = false; + expect(fixture.debugElement.nativeElement.textContent).toEqual(''); + }); + + it('should select the default daemon', fakeAsync(() => { + component.isRgwRoute = true; + fixture.detectChanges(); + tick(); + const req = httpTesting.expectOne('api/rgw/daemon'); + req.flush(daemonList); + fixture.detectChanges(); + const selectedDaemon = fixture.debugElement.nativeElement.querySelector( + '.ctx-bar-selected-rgw-daemon' + ); + expect(selectedDaemon.textContent).toEqual(' daemon2 ( zonegroup2 ) '); + + const availableDaemons = fixture.debugElement.nativeElement.querySelectorAll( + '.ctx-bar-available-rgw-daemon' + ); + expect(availableDaemons.length).toEqual(daemonList.length); + expect(availableDaemons[0].textContent).toEqual(' daemon1 ( zonegroup1 ) '); + component.ngOnDestroy(); + })); + + it('should select the chosen daemon', fakeAsync(() => { + component.isRgwRoute = true; + fixture.detectChanges(); + tick(); + const req = httpTesting.expectOne('api/rgw/daemon'); + req.flush(daemonList); + fixture.detectChanges(); + component.onDaemonSelection(daemonList[2]); + expect(routerNavigateByUrlSpy).toHaveBeenCalledTimes(1); + fixture.detectChanges(); + tick(); + expect(routerNavigateSpy).toHaveBeenCalledTimes(1); + const selectedDaemon = fixture.debugElement.nativeElement.querySelector( + '.ctx-bar-selected-rgw-daemon' + ); + expect(selectedDaemon.textContent).toEqual(' daemon3 ( zonegroup3 ) '); + component.ngOnDestroy(); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts new file mode 100644 index 000000000..8de611307 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts @@ -0,0 +1,70 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Event, NavigationEnd, Router } from '@angular/router'; + +import { NEVER, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon'; +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { + FeatureTogglesMap$, + FeatureTogglesService +} from '~/app/shared/services/feature-toggles.service'; +import { TimerService } from '~/app/shared/services/timer.service'; + +@Component({ + selector: 'cd-context', + templateUrl: './context.component.html', + styleUrls: ['./context.component.scss'] +}) +export class ContextComponent implements OnInit, OnDestroy { + readonly REFRESH_INTERVAL = 5000; + private subs = new Subscription(); + private rgwUrlPrefix = '/rgw'; + permissions: Permissions; + featureToggleMap$: FeatureTogglesMap$; + isRgwRoute = document.location.href.includes(this.rgwUrlPrefix); + + constructor( + private authStorageService: AuthStorageService, + private featureToggles: FeatureTogglesService, + private router: Router, + private timerService: TimerService, + public rgwDaemonService: RgwDaemonService + ) {} + + ngOnInit() { + this.permissions = this.authStorageService.getPermissions(); + this.featureToggleMap$ = this.featureToggles.get(); + // Check if route belongs to RGW: + this.subs.add( + this.router.events + .pipe(filter((event: Event) => event instanceof NavigationEnd)) + .subscribe(() => (this.isRgwRoute = this.router.url.startsWith(this.rgwUrlPrefix))) + ); + // Set daemon list polling only when in RGW route: + this.subs.add( + this.timerService + .get(() => (this.isRgwRoute ? this.rgwDaemonService.list() : NEVER), this.REFRESH_INTERVAL) + .subscribe() + ); + } + + ngOnDestroy() { + this.subs.unsubscribe(); + } + + onDaemonSelection(daemon: RgwDaemon) { + this.rgwDaemonService.selectDaemon(daemon); + this.reloadData(); + } + + private reloadData() { + const currentUrl = this.router.url; + this.router.navigateByUrl(this.rgwUrlPrefix, { skipLocationChange: true }).finally(() => { + this.router.navigate([currentUrl]); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts new file mode 100644 index 000000000..005c82778 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts @@ -0,0 +1,34 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { BlockUIModule } from 'ng-block-ui'; + +import { ContextComponent } from '~/app/core/context/context.component'; +import { SharedModule } from '~/app/shared/shared.module'; +import { ErrorComponent } from './error/error.component'; +import { BlankLayoutComponent } from './layouts/blank-layout/blank-layout.component'; +import { LoginLayoutComponent } from './layouts/login-layout/login-layout.component'; +import { WorkbenchLayoutComponent } from './layouts/workbench-layout/workbench-layout.component'; +import { NavigationModule } from './navigation/navigation.module'; + +@NgModule({ + imports: [ + BlockUIModule.forRoot(), + CommonModule, + NavigationModule, + NgbDropdownModule, + RouterModule, + SharedModule + ], + exports: [NavigationModule], + declarations: [ + ContextComponent, + WorkbenchLayoutComponent, + BlankLayoutComponent, + LoginLayoutComponent, + ErrorComponent + ] +}) +export class CoreModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.html new file mode 100644 index 000000000..164c181da --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.html @@ -0,0 +1,63 @@ +<head> + <title>Error Page</title> + <base target="_blank"> +</head> +<div class="container h-75"> + <div class="row h-100 justify-content-center align-items-center"> + <div class="blank-page"> + <div *ngIf="header && message; else elseBlock"> + <i [ngClass]="icon" + class="mx-auto d-block"></i> + + <div class="mt-4 text-center"> + <h3><b>{{ header }}</b></h3> + <h4 class="mt-3" + *ngIf="header !== message">{{ message }}</h4> + <h4 *ngIf="section" + i18n>Please consult the <a href="{{ docUrl }}">documentation</a> on how to configure and enable + the {{ sectionInfo }} management functionality. + </h4> + </div> + </div> + + <div class="mt-4"> + <div class="text-center" + *ngIf="(buttonName && buttonRoute) || uiConfig; else dashboardButton"> + <button class="btn btn-primary" + [routerLink]="buttonRoute" + *ngIf="!uiConfig; else configureButtonTpl" + i18n>{{ buttonName }}</button> + </div> + </div> + </div> + </div> +</div> + +<ng-template #configureButtonTpl> + <button class="btn btn-primary" + (click)="doConfigure()" + [attr.title]="buttonTitle" + *ngIf="uiConfig" + i18n>{{ buttonName }}</button> +</ng-template> + + +<ng-template #elseBlock> + <i class="fa fa-exclamation-triangle mx-auto d-block text-danger"></i> + + <div class="mt-4 text-center"> + <h3 i18n><b>Page not Found</b></h3> + + <h4 class="mt-4" + i18n>Sorry, we couldn’t find what you were looking for. + The page you requested may have been changed or moved.</h4> + </div> +</ng-template> + +<ng-template #dashboardButton> + <div class="mt-4 text-center"> + <button class="btn btn-primary" + [routerLink]="'/dashboard'" + i18n>Go To Dashboard</button> + </div> +</ng-template> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.scss new file mode 100644 index 000000000..feb4e0f95 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.scss @@ -0,0 +1,18 @@ +@use './src/styles/vendor/variables' as vv; + +h4 { + color: vv.$gray-700; +} + +i { + font-size: 6em; + margin-top: 120px; +} + +.fa-lock { + color: vv.$danger; +} + +.fa-wrench { + color: vv.$info; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.spec.ts new file mode 100644 index 000000000..5763d4d97 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.spec.ts @@ -0,0 +1,49 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastrModule } from 'ngx-toastr'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ErrorComponent } from './error.component'; + +describe('ErrorComponent', () => { + let component: ErrorComponent; + let fixture: ComponentFixture<ErrorComponent>; + + configureTestBed({ + declarations: [ErrorComponent], + imports: [HttpClientTestingModule, RouterTestingModule, SharedModule, ToastrModule.forRoot()] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ErrorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show error message and header', () => { + window.history.pushState({ message: 'Access Forbidden', header: 'User Denied' }, 'Errors'); + component.fetchData(); + fixture.detectChanges(); + const header = fixture.debugElement.nativeElement.querySelector('h3'); + expect(header.innerHTML).toContain('User Denied'); + const message = fixture.debugElement.nativeElement.querySelector('h4'); + expect(message.innerHTML).toContain('Access Forbidden'); + }); + + it('should show 404 Page not Found if message and header are blank', () => { + window.history.pushState({ message: '', header: '' }, 'Errors'); + component.fetchData(); + fixture.detectChanges(); + const header = fixture.debugElement.nativeElement.querySelector('h3'); + expect(header.innerHTML).toContain('Page not Found'); + const message = fixture.debugElement.nativeElement.querySelector('h4'); + expect(message.innerHTML).toContain('Sorry, we couldn’t find what you were looking for.'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts new file mode 100644 index 000000000..d26bc6db4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts @@ -0,0 +1,96 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { NavigationEnd, Router, RouterEvent } from '@angular/router'; + +import { Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { DocService } from '~/app/shared/services/doc.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; + +@Component({ + selector: 'cd-error', + templateUrl: './error.component.html', + styleUrls: ['./error.component.scss'] +}) +export class ErrorComponent implements OnDestroy, OnInit { + header: string; + message: string; + section: string; + sectionInfo: string; + icon: string; + docUrl: string; + source: string; + routerSubscription: Subscription; + uiConfig: string; + uiApiPath: string; + buttonRoute: string; + buttonName: string; + buttonTitle: string; + component: string; + + constructor( + private router: Router, + private docService: DocService, + private http: HttpClient, + private notificationService: NotificationService + ) {} + + ngOnInit() { + this.fetchData(); + this.routerSubscription = this.router.events + .pipe(filter((event: RouterEvent) => event instanceof NavigationEnd)) + .subscribe(() => { + this.fetchData(); + }); + } + + doConfigure() { + this.http.post(`ui-api/${this.uiApiPath}/configure`, {}).subscribe({ + next: () => { + this.notificationService.show(NotificationType.info, `Configuring ${this.component}`); + }, + error: (error: any) => { + this.notificationService.show(NotificationType.error, error); + }, + complete: () => { + setTimeout(() => { + this.router.navigate([this.uiApiPath]); + this.notificationService.show(NotificationType.success, `Configured ${this.component}`); + }, 3000); + } + }); + } + + @HostListener('window:beforeunload', ['$event']) unloadHandler(event: Event) { + event.returnValue = false; + } + + fetchData() { + try { + this.router.onSameUrlNavigation = 'reload'; + this.message = history.state.message; + this.header = history.state.header; + this.section = history.state.section; + this.sectionInfo = history.state.section_info; + this.icon = history.state.icon; + this.source = history.state.source; + this.uiConfig = history.state.uiConfig; + this.uiApiPath = history.state.uiApiPath; + this.buttonRoute = history.state.button_route; + this.buttonName = history.state.button_name; + this.buttonTitle = history.state.button_title; + this.component = history.state.component; + this.docUrl = this.docService.urlGenerator(this.section); + } catch (error) { + this.router.navigate(['/error']); + } + } + + ngOnDestroy() { + if (this.routerSubscription) { + this.routerSubscription.unsubscribe(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.ts new file mode 100644 index 000000000..0270a4587 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.ts @@ -0,0 +1,27 @@ +import { Icons } from '~/app/shared/enum/icons.enum'; + +export class DashboardError extends Error { + header: string; + message: string; + icon: string; +} + +export class DashboardNotFoundError extends DashboardError { + header = $localize`Page Not Found`; + message = $localize`Sorry, we couldn’t find what you were looking for. + The page you requested may have been changed or moved.`; + icon = Icons.warning; +} + +export class DashboardForbiddenError extends DashboardError { + header = $localize`Access Denied`; + message = $localize`Sorry, you don’t have permission to view this page or resource.`; + icon = Icons.lock; +} + +export class DashboardUserDeniedError extends DashboardError { + header = $localize`User Denied`; + message = $localize`Sorry, the user does not exist in Ceph. + You'll be logged out from the Identity Provider when you retry logging in.`; + icon = Icons.warning; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.html new file mode 100644 index 000000000..0680b43f9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.html @@ -0,0 +1 @@ +<router-outlet></router-outlet> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.spec.ts new file mode 100644 index 000000000..faee6aa9b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { BlankLayoutComponent } from './blank-layout.component'; + +describe('DefaultLayoutComponent', () => { + let component: BlankLayoutComponent; + let fixture: ComponentFixture<BlankLayoutComponent>; + + configureTestBed({ + declarations: [BlankLayoutComponent], + imports: [RouterTestingModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BlankLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.ts new file mode 100644 index 000000000..761bb3b87 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'cd-blank-layout', + templateUrl: './blank-layout.component.html', + styleUrls: ['./blank-layout.component.scss'] +}) +export class BlankLayoutComponent {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.html new file mode 100644 index 000000000..1222fcc2a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.html @@ -0,0 +1,34 @@ +<main class="login full-height"> + <header> + <nav class="navbar p-4"> + <a class="navbar-brand"></a> + <div class="form-inline"> + <cd-language-selector></cd-language-selector> + </div> + </nav> + </header> + <section> + <div class="container"> + <div class="row full-height"> + <div class="col-sm-12 col-md-6 d-sm-block login-form"> + <router-outlet></router-outlet> + </div> + <div class="col-sm-12 col-md-6 d-sm-block branding-info"> + <img src="assets/Ceph_Ceph_Logo_with_text_white.svg" + alt="Ceph" + class="img-fluid pb-3"> + <ul class="list-inline"> + <li class="list-inline-item p-3" + *ngFor="let docItem of docItems"> + <cd-doc section="{{ docItem.section }}" + docText="{{ docItem.text }}" + noSubscribe="true" + i18n-docText></cd-doc> + </li> + </ul> + <cd-custom-login-banner></cd-custom-login-banner> + </div> + </div> + </div> + </section> +</main> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.scss new file mode 100644 index 000000000..d5c9f73ec --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.scss @@ -0,0 +1,61 @@ +@use './src/styles/vendor/variables' as vv; + +::ng-deep cd-login-layout .login { + background-color: vv.$secondary; + background-image: url('../../../../assets/ceph_background.gif'); + background-position: right bottom; + background-repeat: no-repeat; + color: vv.$body-color-bright; + + header { + position: absolute; + width: 100vw; + + .navbar { + .dropdown-menu { + margin-top: 0.2rem; + + li a { + &:hover { + background-color: vv.$primary; + } + } + } + } + } + + section { + display: inline-flex; + min-height: 100vh; + width: 100vw; + } + + .list-inline { + margin-bottom: 0; + margin-left: 20%; + } + + a { + color: vv.$fg-color-over-dark-bg; + + &:hover { + color: vv.$fg-hover-color-over-dark-bg; + } + } + + @media screen and (min-width: vv.$screen-sm-min) { + .login-form, + .branding-info { + padding-top: 30vh; + } + } + @media screen and (max-width: vv.$screen-sm-max) { + .login-form { + padding-top: 10vh; + } + + .branding-info { + padding-top: 0; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.spec.ts new file mode 100644 index 000000000..b57e9a36e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.spec.ts @@ -0,0 +1,28 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { LoginLayoutComponent } from './login-layout.component'; + +describe('LoginLayoutComponent', () => { + let component: LoginLayoutComponent; + let fixture: ComponentFixture<LoginLayoutComponent>; + + configureTestBed({ + declarations: [LoginLayoutComponent], + imports: [BrowserAnimationsModule, HttpClientTestingModule, RouterTestingModule, SharedModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.ts new file mode 100644 index 000000000..69d591cd1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'cd-login-layout', + templateUrl: './login-layout.component.html', + styleUrls: ['./login-layout.component.scss'] +}) +export class LoginLayoutComponent { + docItems: any[] = [ + { section: 'help', text: $localize`Help` }, + { section: 'security', text: $localize`Security` }, + { section: 'trademarks', text: $localize`Trademarks` } + ]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html new file mode 100644 index 000000000..3979ad7a4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html @@ -0,0 +1,10 @@ +<block-ui> + <cd-navigation> + <div class="container-fluid h-100" + [ngClass]="{'dashboard':isDashboardPage()} "> + <cd-context></cd-context> + <cd-breadcrumbs></cd-breadcrumbs> + <router-outlet></router-outlet> + </div> + </cd-navigation> +</block-ui> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss new file mode 100644 index 000000000..7ec90d43e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss @@ -0,0 +1,12 @@ +@use './src/styles/vendor/variables' as vv; + +.dashboard { + background-color: vv.$body-bg-alt; + margin: 0; + padding: 0; +} + +.container-fluid { + overflow: auto; + position: absolute; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.spec.ts new file mode 100644 index 000000000..faf8c9cdf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.spec.ts @@ -0,0 +1,35 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastrModule } from 'ngx-toastr'; + +import { RbdService } from '~/app/shared/api/rbd.service'; +import { CssHelper } from '~/app/shared/classes/css-helper'; +import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { WorkbenchLayoutComponent } from './workbench-layout.component'; + +describe('WorkbenchLayoutComponent', () => { + let component: WorkbenchLayoutComponent; + let fixture: ComponentFixture<WorkbenchLayoutComponent>; + + configureTestBed({ + imports: [RouterTestingModule, ToastrModule.forRoot(), PipesModule, HttpClientTestingModule], + declarations: [WorkbenchLayoutComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [AuthStorageService, CssHelper, RbdService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WorkbenchLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts new file mode 100644 index 000000000..f2070be5f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts @@ -0,0 +1,39 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Subscription } from 'rxjs'; + +import { FaviconService } from '~/app/shared/services/favicon.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; +import { TaskManagerService } from '~/app/shared/services/task-manager.service'; + +@Component({ + selector: 'cd-workbench-layout', + templateUrl: './workbench-layout.component.html', + styleUrls: ['./workbench-layout.component.scss'], + providers: [FaviconService] +}) +export class WorkbenchLayoutComponent implements OnInit, OnDestroy { + private subs = new Subscription(); + + constructor( + private router: Router, + private summaryService: SummaryService, + private taskManagerService: TaskManagerService, + private faviconService: FaviconService + ) {} + + ngOnInit() { + this.subs.add(this.summaryService.startPolling()); + this.subs.add(this.taskManagerService.init(this.summaryService)); + this.faviconService.init(); + } + + ngOnDestroy() { + this.subs.unsubscribe(); + } + + isDashboardPage() { + return this.router.url === '/dashboard'; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.html new file mode 100644 index 000000000..fdf4d95cf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.html @@ -0,0 +1,47 @@ +<div class="about-container"> + <div class="modal-header"> + <button type="button" + class="close float-right" + aria-label="Close" + (click)="activeModal.close()"> + <span aria-hidden="true">×</span> + </button> + </div> + <div class="modal-body"> + <img src="assets/Ceph_Ceph_Logo_with_text_red_white.svg" + class="ceph-logo" + alt="{{ projectConstants.organization }}"> + <h3> + <strong>{{ projectConstants.projectName }}</strong> + </h3> + <div class="product-versions"> + <strong>Version</strong> + <br> + {{ versionNumber }} + {{ versionHash }} + <br> + {{ versionName }} + </div> + <br> + <dl> + <dt>Ceph Manager</dt> + <dd>{{ hostAddr }}</dd> + <dt>User</dt> + <dd>{{ modalVariables.user }}</dd> + <dt>User Role</dt> + <dd>{{ modalVariables.role }}</dd> + <dt>Browser</dt> + <dd>{{ modalVariables.browserName }}</dd> + <dt>Browser Version</dt> + <dd>{{ modalVariables.browserVersion }}</dd> + <dt>Browser OS</dt> + <dd>{{ modalVariables.browserOS }}</dd> + </dl> + </div> + <div class="modal-footer"> + <div class="text-left"> + {{ projectConstants.copyright }} + {{ projectConstants.license }} + </div> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.scss new file mode 100644 index 000000000..78c7fe550 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.scss @@ -0,0 +1,43 @@ +@use './src/styles/vendor/variables' as vv; + +.about-container { + background-color: vv.$secondary; + background-image: url('../../../../assets/ceph_background.gif'); + background-position: right bottom; + background-repeat: no-repeat; + color: vv.$white; + text-shadow: 1px 1px vv.$secondary; +} + +.product-versions { + margin-top: 30px; +} + +.product-versions strong { + margin-right: 10px; +} + +.modal-header { + border-bottom: 0; +} + +.modal-header .close { + color: vv.$white; + font-size: 2em; +} + +.modal-body { + padding-left: 80px; + padding-right: 80px; +} + +.ceph-logo { + margin-bottom: 30px; + width: 25%; +} + +.modal-footer { + border-top: 0; + display: block; + padding: 15px 80px 35px; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.spec.ts new file mode 100644 index 000000000..74ca78434 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.spec.ts @@ -0,0 +1,60 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { BehaviorSubject } from 'rxjs'; + +import { SummaryService } from '~/app/shared/services/summary.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { environment } from '~/environments/environment'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AboutComponent } from './about.component'; + +export class SummaryServiceMock { + summaryDataSource = new BehaviorSubject({ + version: + 'ceph version 14.0.0-855-gb8193bb4cd ' + + '(b8193bb4cda16ccc5b028c3e1df62bc72350a15d) nautilus (dev)', + mgr_host: 'http://localhost:11000/' + }); + summaryData$ = this.summaryDataSource.asObservable(); + + subscribe(call: any) { + return this.summaryData$.subscribe(call); + } +} + +describe('AboutComponent', () => { + let component: AboutComponent; + let fixture: ComponentFixture<AboutComponent>; + + configureTestBed({ + imports: [SharedModule, HttpClientTestingModule], + declarations: [AboutComponent], + providers: [NgbActiveModal, { provide: SummaryService, useClass: SummaryServiceMock }] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AboutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should parse version', () => { + expect(component.versionNumber).toBe('14.0.0-855-gb8193bb4cd'); + expect(component.versionHash).toBe('(b8193bb4cda16ccc5b028c3e1df62bc72350a15d)'); + expect(component.versionName).toBe('nautilus (dev)'); + }); + + it('should get host', () => { + expect(component.hostAddr).toBe('localhost:11000'); + }); + + it('should display copyright', () => { + expect(component.projectConstants.copyright).toContain(environment.year); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.ts new file mode 100644 index 000000000..64b26bfc6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.ts @@ -0,0 +1,70 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { detect } from 'detect-browser'; +import { Subscription } from 'rxjs'; + +import { UserService } from '~/app/shared/api/user.service'; +import { AppConstants } from '~/app/shared/constants/app.constants'; +import { Permission } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; + +@Component({ + selector: 'cd-about', + templateUrl: './about.component.html', + styleUrls: ['./about.component.scss'] +}) +export class AboutComponent implements OnInit, OnDestroy { + modalVariables: any; + versionNumber: string; + versionHash: string; + versionName: string; + subs: Subscription; + userPermission: Permission; + projectConstants: typeof AppConstants; + hostAddr: string; + copyright: string; + + constructor( + public activeModal: NgbActiveModal, + private summaryService: SummaryService, + private userService: UserService, + private authStorageService: AuthStorageService + ) { + this.userPermission = this.authStorageService.getPermissions().user; + } + + ngOnInit() { + this.projectConstants = AppConstants; + this.hostAddr = window.location.hostname; + this.modalVariables = this.setVariables(); + this.subs = this.summaryService.subscribe((summary) => { + const version = summary.version.replace('ceph version ', '').split(' '); + this.hostAddr = summary.mgr_host.replace(/(^\w+:|^)\/\//, '').replace(/\/$/, ''); + this.versionNumber = version[0]; + this.versionHash = version[1]; + this.versionName = version.slice(2, version.length).join(' '); + }); + } + + ngOnDestroy(): void { + this.subs.unsubscribe(); + } + + setVariables() { + const project = {} as any; + project.user = localStorage.getItem('dashboard_username'); + project.role = 'user'; + if (this.userPermission.read) { + this.userService.get(project.user).subscribe((data: any) => { + project.role = data.roles; + }); + } + const browser = detect(); + project.browserName = browser && browser.name ? browser.name : 'Not detected'; + project.browserVersion = browser && browser.version ? browser.version : 'Not detected'; + project.browserOS = browser && browser.os ? browser.os : 'Not detected'; + return project; + } +} 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 000000000..fa4b38d16 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html @@ -0,0 +1,22 @@ +<div ngbDropdown + placement="bottom-right" + *ngIf="userPermission.read"> + <a ngbDropdownToggle + class="dropdown-toggle" + i18n-title + title="Dashboard Settings"> + <i [ngClass]="[icons.deepCheck]"></i> + <span i18n + class="d-md-none">Dashboard Settings</span> + </a> + <div ngbDropdownMenu> + <button ngbDropdownItem + *ngIf="userPermission.read" + routerLink="/user-management" + i18n>User management</button> + <button ngbDropdownItem + *ngIf="configOptPermission.read" + routerLink="/telemetry" + i18n>Telemetry configuration</button> + </div> +</div> 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 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.scss 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 000000000..29392785b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AdministrationComponent } from './administration.component'; + +describe('AdministrationComponent', () => { + let component: AdministrationComponent; + let fixture: ComponentFixture<AdministrationComponent>; + + configureTestBed({ + imports: [SharedModule], + declarations: [AdministrationComponent] + }); + + 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 000000000..60cd17ec6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; + +import { Icons } from '~/app/shared/enum/icons.enum'; +import { Permission } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; + +@Component({ + selector: 'cd-administration', + templateUrl: './administration.component.html', + styleUrls: ['./administration.component.scss'] +}) +export class AdministrationComponent { + userPermission: Permission; + configOptPermission: Permission; + icons = Icons; + + constructor(private authStorageService: AuthStorageService) { + const permissions = this.authStorageService.getPermissions(); + this.userPermission = permissions.user; + this.configOptPermission = permissions.configOpt; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.html new file mode 100644 index 000000000..2dd0ff424 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.html @@ -0,0 +1,3 @@ + +<div id="swagger-ui" + class="apiDocs"></div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.scss new file mode 100644 index 000000000..889286488 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.scss @@ -0,0 +1,7 @@ +@use './src/styles/vendor/variables' as vv; + +.apiDocs { + background: vv.$gray-100; + font-size: 18px !important; + margin-top: -48px !important; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.ts new file mode 100644 index 000000000..7d9ea86eb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.ts @@ -0,0 +1,18 @@ +import { Component, OnInit } from '@angular/core'; + +import SwaggerUI from 'swagger-ui'; + +@Component({ + selector: 'cd-api-docs', + templateUrl: './api-docs.component.html', + styleUrls: ['./api-docs.component.scss'] +}) +export class ApiDocsComponent implements OnInit { + ngOnInit(): void { + SwaggerUI({ + url: window.location.origin + '/docs/openapi.json', + dom_id: '#swagger-ui', + layout: 'BaseLayout' + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.html new file mode 100644 index 000000000..05232b7fa --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.html @@ -0,0 +1,11 @@ +<ol *ngIf="crumbs.length" + class="breadcrumb"> + <li *ngFor="let crumb of crumbs; let last = last" + [ngClass]="{ 'active': last && finished }" + class="breadcrumb-item"> + <a *ngIf="!last && crumb.path !== null" + [routerLink]="crumb.path" + preserveFragment>{{ crumb.text }}</a> + <span *ngIf="last || crumb.path === null">{{ crumb.text }}</span> + </li> +</ol> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss new file mode 100644 index 000000000..733f7e677 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss @@ -0,0 +1,12 @@ +.breadcrumb { + background-color: transparent; + border-radius: 0; + margin-top: 8px; + padding: 8px 0; +} + +.breadcrumb > li + li::before { + content: '\f101'; + font-family: 'ForkAwesome'; + padding: 0 5px 0 7px; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts new file mode 100644 index 000000000..b6551f780 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts @@ -0,0 +1,131 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { PerformanceCounterBreadcrumbsResolver } from '~/app/app-routing.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { BreadcrumbsComponent } from './breadcrumbs.component'; + +describe('BreadcrumbsComponent', () => { + let component: BreadcrumbsComponent; + let fixture: ComponentFixture<BreadcrumbsComponent>; + let router: Router; + + @Component({ selector: 'cd-fake', template: '' }) + class FakeComponent {} + + const routes: Routes = [ + { + path: 'hosts', + component: FakeComponent, + data: { breadcrumbs: 'Cluster/Hosts' } + }, + { + path: 'perf_counters', + component: FakeComponent, + data: { + breadcrumbs: PerformanceCounterBreadcrumbsResolver + } + }, + { + path: 'block', + data: { breadcrumbs: true, text: 'Block', path: null }, + children: [ + { + path: 'rbd', + data: { breadcrumbs: 'Images' }, + children: [ + { path: '', component: FakeComponent }, + { path: 'add', component: FakeComponent, data: { breadcrumbs: 'Add' } } + ] + } + ] + } + ]; + + configureTestBed({ + declarations: [BreadcrumbsComponent, FakeComponent], + imports: [CommonModule, RouterTestingModule.withRoutes(routes)], + providers: [PerformanceCounterBreadcrumbsResolver] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BreadcrumbsComponent); + router = TestBed.inject(Router); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.crumbs).toEqual([]); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.subscription).toBeDefined(); + }); + + it('should run postProcess and split the breadcrumbs when navigating to hosts', fakeAsync(() => { + fixture.ngZone.run(() => { + router.navigateByUrl('/hosts'); + }); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Cluster' }, + { path: '/hosts', text: 'Hosts' } + ]); + })); + + it('should display empty breadcrumb when navigating to perf_counters from unknown path', fakeAsync(() => { + fixture.ngZone.run(() => { + router.navigateByUrl('/perf_counters'); + }); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Cluster' }, + { path: null, text: '' }, + { path: '', text: 'Performance Counters' } + ]); + })); + + it('should display Monitor breadcrumb when navigating to perf_counters from Monitors', fakeAsync(() => { + fixture.ngZone.run(() => { + router.navigate(['/perf_counters'], { queryParams: { fromLink: '/monitor' } }); + }); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Cluster' }, + { path: '/monitor', text: 'Monitors' }, + { path: '', text: 'Performance Counters' } + ]); + })); + + it('should display Hosts breadcrumb when navigating to perf_counters from Hosts', fakeAsync(() => { + fixture.ngZone.run(() => { + router.navigate(['/perf_counters'], { queryParams: { fromLink: '/hosts' } }); + }); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Cluster' }, + { path: '/hosts', text: 'Hosts' }, + { path: '', text: 'Performance Counters' } + ]); + })); + + it('should show all 3 breadcrumbs when navigating to RBD Add', fakeAsync(() => { + fixture.ngZone.run(() => { + router.navigateByUrl('/block/rbd/add'); + }); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Block' }, + { path: '/block/rbd', text: 'Images' }, + { path: '/block/rbd/add', text: 'Add' } + ]); + })); + + it('should unsubscribe on ngOnDestroy', () => { + expect(component.subscription.closed).toBeFalsy(); + component.ngOnDestroy(); + expect(component.subscription.closed).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts new file mode 100644 index 000000000..d933081ab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts @@ -0,0 +1,141 @@ +/* +The MIT License + +Copyright (c) 2017 (null) McNull https://github.com/McNull + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + */ + +import { Component, Injector, OnDestroy } from '@angular/core'; +import { ActivatedRouteSnapshot, NavigationEnd, NavigationStart, Router } from '@angular/router'; + +import { concat, from, Observable, of, Subscription } from 'rxjs'; +import { distinct, filter, first, mergeMap, toArray } from 'rxjs/operators'; + +import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs'; + +@Component({ + selector: 'cd-breadcrumbs', + templateUrl: './breadcrumbs.component.html', + styleUrls: ['./breadcrumbs.component.scss'] +}) +export class BreadcrumbsComponent implements OnDestroy { + crumbs: IBreadcrumb[] = []; + /** + * Useful for e2e tests. + * This allow us to mark the breadcrumb as pending during the navigation from + * one page to another. + * This resolves the problem of validating the breadcrumb of a new page and + * still get the value from the previous + */ + finished = false; + subscription: Subscription; + private defaultResolver = new BreadcrumbsResolver(); + + constructor(private router: Router, private injector: Injector) { + this.subscription = this.router.events + .pipe(filter((x) => x instanceof NavigationStart)) + .subscribe(() => { + this.finished = false; + }); + + this.subscription = this.router.events + .pipe(filter((x) => x instanceof NavigationEnd)) + .subscribe(() => { + const currentRoot = router.routerState.snapshot.root; + + this._resolveCrumbs(currentRoot) + .pipe( + mergeMap((x) => x), + distinct((x) => x.text), + toArray(), + mergeMap((x) => { + const y = this.postProcess(x); + return this.wrapIntoObservable<IBreadcrumb[]>(y).pipe(first()); + }) + ) + .subscribe((x) => { + this.finished = true; + this.crumbs = x; + }); + }); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + private _resolveCrumbs(route: ActivatedRouteSnapshot): Observable<IBreadcrumb[]> { + let crumbs$: Observable<IBreadcrumb[]>; + + const data = route.routeConfig && route.routeConfig.data; + + if (data && data.breadcrumbs) { + let resolver: BreadcrumbsResolver; + + if (data.breadcrumbs.prototype instanceof BreadcrumbsResolver) { + resolver = this.injector.get<BreadcrumbsResolver>(data.breadcrumbs); + } else { + resolver = this.defaultResolver; + } + + const result = resolver.resolve(route); + crumbs$ = this.wrapIntoObservable<IBreadcrumb[]>(result).pipe(first()); + } else { + crumbs$ = of([]); + } + + if (route.firstChild) { + crumbs$ = concat<IBreadcrumb[]>(crumbs$, this._resolveCrumbs(route.firstChild)); + } + + return crumbs$; + } + + postProcess(breadcrumbs: IBreadcrumb[]) { + const result: IBreadcrumb[] = []; + breadcrumbs.forEach((element) => { + const split = element.text.split('/'); + if (split.length > 1) { + element.text = split[split.length - 1]; + for (let i = 0; i < split.length - 1; i++) { + result.push({ text: split[i], path: null }); + } + } + result.push(element); + }); + return result; + } + + isPromise(value: any): boolean { + return value && typeof value.then === 'function'; + } + + wrapIntoObservable<T>(value: T | Promise<T> | Observable<T>): Observable<T> { + if (value instanceof Observable) { + return value; + } + + if (this.isPromise(value)) { + return from(Promise.resolve(value)); + } + + return of(value as T); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html new file mode 100644 index 000000000..274ec71df --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html @@ -0,0 +1,25 @@ +<div ngbDropdown + placement="bottom-right"> + <a ngbDropdownToggle + i18n-title + title="Help"> + <i [ngClass]="[icons.questionCircle]"></i> + <span i18n + class="d-md-none">Help</span> + </a> + <div ngbDropdownMenu> + <a ngbDropdownItem + [ngClass]="{'disabled': !docsUrl}" + class="text-capitalize" + href="{{ docsUrl }}" + target="_blank" + i18n>documentation</a> + <a ngbDropdownItem + routerLink="/api-docs" + target="_blank" + i18n>API</a> + <button ngbDropdownItem + (click)="openAboutModal()" + i18n>About</button> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.spec.ts new file mode 100644 index 000000000..1c9e0a5f7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.spec.ts @@ -0,0 +1,27 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { DashboardHelpComponent } from './dashboard-help.component'; + +describe('DashboardHelpComponent', () => { + let component: DashboardHelpComponent; + let fixture: ComponentFixture<DashboardHelpComponent>; + + configureTestBed({ + imports: [HttpClientTestingModule, SharedModule, RouterTestingModule], + declarations: [DashboardHelpComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DashboardHelpComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts new file mode 100644 index 000000000..910a61333 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; + +import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +import { Icons } from '~/app/shared/enum/icons.enum'; +import { DocService } from '~/app/shared/services/doc.service'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { AboutComponent } from '../about/about.component'; + +@Component({ + selector: 'cd-dashboard-help', + templateUrl: './dashboard-help.component.html', + styleUrls: ['./dashboard-help.component.scss'] +}) +export class DashboardHelpComponent implements OnInit { + docsUrl: string; + modalRef: NgbModalRef; + icons = Icons; + + constructor(private modalService: ModalService, private docService: DocService) {} + + ngOnInit() { + this.docService.subscribeOnce('dashboard', (url: string) => { + this.docsUrl = url; + }); + } + + openAboutModal() { + this.modalRef = this.modalService.show(AboutComponent, null, { size: 'lg' }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.html new file mode 100644 index 000000000..bf0f22fbb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.html @@ -0,0 +1,27 @@ +<div ngbDropdown + placement="bottom-right"> + <a ngbDropdownToggle + i18n-title + title="Logged in user"> + <i [ngClass]="[icons.user]"></i> + <span i18n + class="d-md-none">Logged in user</span> + </a> + <div ngbDropdownMenu> + <button ngbDropdownItem + disabled + i18n>Signed in as <strong>{{ username }}</strong></button> + <li class="dropdown-divider"></li> + <button ngbDropdownItem + *ngIf="!sso" + routerLink="/user-profile/edit"> + <i [ngClass]="[icons.lock]"></i> + <span i18n>Change password</span> + </button> + <button ngbDropdownItem + (click)="logout()"> + <i [ngClass]="[icons.signOut]"></i> + <span i18n>Sign out</span> + </button> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.spec.ts new file mode 100644 index 000000000..23f2f97ca --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.spec.ts @@ -0,0 +1,27 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { IdentityComponent } from './identity.component'; + +describe('IdentityComponent', () => { + let component: IdentityComponent; + let fixture: ComponentFixture<IdentityComponent>; + + configureTestBed({ + imports: [HttpClientTestingModule, SharedModule, RouterTestingModule], + declarations: [IdentityComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(IdentityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts new file mode 100644 index 000000000..c1d33b938 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts @@ -0,0 +1,27 @@ +import { Component, OnInit } from '@angular/core'; + +import { AuthService } from '~/app/shared/api/auth.service'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; + +@Component({ + selector: 'cd-identity', + templateUrl: './identity.component.html', + styleUrls: ['./identity.component.scss'] +}) +export class IdentityComponent implements OnInit { + sso: boolean; + username: string; + icons = Icons; + + constructor(private authStorageService: AuthStorageService, private authService: AuthService) {} + + ngOnInit() { + this.username = this.authStorageService.getUsername(); + this.sso = this.authStorageService.isSSO(); + } + + logout() { + this.authService.logout(); + } +} 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 new file mode 100644 index 000000000..c8d2a9d9c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts @@ -0,0 +1,43 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +import { NgbCollapseModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; +import { SimplebarAngularModule } from 'simplebar-angular'; + +import { AppRoutingModule } from '~/app/app-routing.module'; +import { SharedModule } from '~/app/shared/shared.module'; +import { AuthModule } from '../auth/auth.module'; +import { AboutComponent } from './about/about.component'; +import { AdministrationComponent } from './administration/administration.component'; +import { ApiDocsComponent } from './api-docs/api-docs.component'; +import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component'; +import { DashboardHelpComponent } from './dashboard-help/dashboard-help.component'; +import { IdentityComponent } from './identity/identity.component'; +import { NavigationComponent } from './navigation/navigation.component'; +import { NotificationsComponent } from './notifications/notifications.component'; + +@NgModule({ + imports: [ + CommonModule, + AuthModule, + NgbCollapseModule, + NgbDropdownModule, + AppRoutingModule, + SharedModule, + SimplebarAngularModule, + RouterModule + ], + declarations: [ + AboutComponent, + ApiDocsComponent, + BreadcrumbsComponent, + NavigationComponent, + NotificationsComponent, + DashboardHelpComponent, + AdministrationComponent, + IdentityComponent + ], + exports: [NavigationComponent, BreadcrumbsComponent] +}) +export class NavigationModule {} 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 new file mode 100644 index 000000000..bdb35a610 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -0,0 +1,272 @@ +<div class="cd-navbar-main"> + <cd-pwd-expiration-notification></cd-pwd-expiration-notification> + <cd-telemetry-notification></cd-telemetry-notification> + <cd-motd></cd-motd> + <cd-notifications-sidebar></cd-notifications-sidebar> + <div class="cd-navbar-top"> + <nav class="navbar navbar-expand-md navbar-dark cd-navbar-brand"> + <button class="btn btn-link py-0" + (click)="showMenuSidebar = !showMenuSidebar" + aria-label="toggle sidebar visibility"> + <i class="fa fa-bars fa-2x" + aria-hidden="true"></i> + </button> + + <a class="navbar-brand ml-2" + href="#"> + <img src="assets/Ceph_Ceph_Logo_with_text_white.svg" + alt="Ceph" /> + </a> + + <button type="button" + class="navbar-toggler" + (click)="toggleRightSidebar()"> + <span i18n + class="sr-only">Toggle navigation</span> + <span class=""> + <i class="fa fa-navicon fa-lg"></i> + </span> + </button> + + <div class="collapse navbar-collapse" + [ngClass]="{'show': rightSidebarOpen}"> + <ul class="nav navbar-nav cd-navbar-utility my-2 my-md-0"> + <ng-container *ngTemplateOutlet="cd_utilities"> </ng-container> + </ul> + </div> + </nav> + </div> + + <div class="wrapper"> + <!-- Content --> + <nav id="sidebar" + [ngClass]="{'active': !showMenuSidebar}"> + <ngx-simplebar [options]="simplebar"> + <ul class="list-unstyled components cd-navbar-primary"> + <ng-container *ngTemplateOutlet="cd_menu"> </ng-container> + </ul> + </ngx-simplebar> + </nav> + + <!-- Page Content --> + <div id="content" + [ngClass]="{'active': !showMenuSidebar}"> + <ng-content></ng-content> + </div> + </div> + + <ng-template #cd_utilities> + <li class="nav-item"> + <cd-language-selector class="cd-navbar"></cd-language-selector> + </li> + <li class="nav-item"> + <cd-notifications class="cd-navbar" + (click)="toggleRightSidebar()"></cd-notifications> + </li> + <li class="nav-item"> + <cd-dashboard-help class="cd-navbar"></cd-dashboard-help> + </li> + <li class="nav-item"> + <cd-administration class="cd-navbar"></cd-administration> + </li> + <li class="nav-item"> + <cd-identity class="cd-navbar"></cd-identity> + </li> + </ng-template> + + <ng-template #cd_menu> + <ng-container *ngIf="enabledFeature$ | async as enabledFeature"> + <!-- Dashboard --> + <li routerLinkActive="active" + class="nav-item tc_menuitem_dashboard"> + <a routerLink="/dashboard" + class="nav-link"> + <span i18n>Dashboard</span> + <i [ngClass]="[icons.health]" + [ngStyle]="summaryData?.health_status | healthColor"></i> + </a> + </li> + + <!-- Cluster --> + <li routerLinkActive="active" + class="nav-item tc_menuitem_cluster" + *ngIf="permissions.hosts.read || permissions.monitor.read || + permissions.osd.read || permissions.configOpt.read || + permissions.log.read || permissions.prometheus.read"> + <a (click)="toggleSubMenu('cluster')" + class="nav-link dropdown-toggle" + [attr.aria-expanded]="displayedSubMenu == 'cluster'" + aria-controls="collapseBasic"> + <ng-container i18n>Cluster</ng-container> + </a> + <ul class="list-unstyled" + [ngbCollapse]="displayedSubMenu !== 'cluster'"> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_hosts" + *ngIf="permissions.hosts.read"> + <a i18n + routerLink="/hosts">Hosts</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_cluster_inventory" + *ngIf="permissions.hosts.read"> + <a i18n + routerLink="/inventory">Physical Disks</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_cluster_monitor" + *ngIf="permissions.monitor.read"> + <a i18n + routerLink="/monitor/">Monitors</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_cluster_services" + *ngIf="permissions.hosts.read"> + <a i18n + routerLink="/services/">Services</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_osds" + *ngIf="permissions.osd.read"> + <a i18n + routerLink="/osd">OSDs</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_configuration" + *ngIf="permissions.configOpt.read"> + <a i18n + routerLink="/configuration">Configuration</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_crush" + *ngIf="permissions.osd.read"> + <a i18n + routerLink="/crush-map">CRUSH map</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_modules" + *ngIf="permissions.configOpt.read"> + <a i18n + routerLink="/mgr-modules">Manager Modules</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_log" + *ngIf="permissions.log.read"> + <a i18n + routerLink="/logs">Logs</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_monitoring" + *ngIf="permissions.prometheus.read"> + <a routerLink="/monitoring"> + <ng-container i18n>Monitoring</ng-container> + <small *ngIf="prometheusAlertService.activeAlerts > 0" + class="badge badge-danger">{{ prometheusAlertService.activeAlerts }}</small> + </a> + </li> + </ul> + </li> + + <!-- Pools --> + <li routerLinkActive="active" + class="nav-item tc_menuitem_pool" + *ngIf="permissions.pool.read"> + <a class="nav-link" + i18n + routerLink="/pool">Pools</a> + </li> + + <!-- Block --> + <li routerLinkActive="active" + class="nav-item tc_menuitem_block" + *ngIf="(permissions.rbdImage.read || permissions.rbdMirroring.read || permissions.iscsi.read) && + (enabledFeature.rbd || enabledFeature.mirroring || enabledFeature.iscsi)"> + <a class="nav-link dropdown-toggle" + (click)="toggleSubMenu('block')" + [attr.aria-expanded]="displayedSubMenu == 'block'" + aria-controls="collapseBasic" + [ngStyle]="blockHealthColor()"> + <ng-container i18n>Block</ng-container> + </a> + + <ul class="list-unstyled" + [ngbCollapse]="displayedSubMenu !== 'block'"> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_block_images" + *ngIf="permissions.rbdImage.read && enabledFeature.rbd"> + <a i18n + routerLink="/block/rbd">Images</a> + </li> + + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_block_mirroring" + *ngIf="permissions.rbdMirroring.read && enabledFeature.mirroring"> + <a routerLink="/block/mirroring"> + <ng-container i18n>Mirroring</ng-container> + <small *ngIf="summaryData?.rbd_mirroring?.warnings !== 0" + class="badge badge-warning">{{ summaryData?.rbd_mirroring?.warnings }}</small> + <small *ngIf="summaryData?.rbd_mirroring?.errors !== 0" + class="badge badge-danger">{{ summaryData?.rbd_mirroring?.errors }}</small> + </a> + </li> + + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_block_iscsi" + *ngIf="permissions.iscsi.read && enabledFeature.iscsi"> + <a i18n + routerLink="/block/iscsi">iSCSI</a> + </li> + </ul> + </li> + + <!-- NFS --> + <li routerLinkActive="active" + class="nav-item tc_menuitem_nfs" + *ngIf="permissions.nfs.read && enabledFeature.nfs"> + <a i18n + class="nav-link" + routerLink="/nfs">NFS</a> + </li> + + <!-- Filesystem --> + <li routerLinkActive="active" + class="nav-item tc_menuitem_cephfs" + *ngIf="permissions.cephfs.read && enabledFeature.cephfs"> + <a i18n + class="nav-link" + routerLink="/cephfs">File Systems</a> + </li> + + <!-- Object Gateway --> + <li routerLinkActive="active" + class="nav-item tc_menuitem_rgw" + *ngIf="permissions.rgw.read && enabledFeature.rgw"> + <a class="nav-link dropdown-toggle" + (click)="toggleSubMenu('rgw')" + [attr.aria-expanded]="displayedSubMenu == 'rgw'" + aria-controls="collapseBasic"> + <ng-container i18n>Object Gateway</ng-container> + </a> + <ul class="list-unstyled" + [ngbCollapse]="displayedSubMenu !== 'rgw'"> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_rgw_daemons"> + <a i18n + routerLink="/rgw/daemon">Daemons</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_rgw_users"> + <a i18n + routerLink="/rgw/user">Users</a> + </li> + <li routerLinkActive="active" + class="tc_submenuitem tc_submenuitem_rgw_buckets"> + <a i18n + routerLink="/rgw/bucket">Buckets</a> + </li> + </ul> + </li> + </ng-container> + </ng-template> + +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss new file mode 100644 index 000000000..f0ce4cd92 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss @@ -0,0 +1,263 @@ +@use './src/styles/vendor/variables' as vv; + +/* -------------------------------------------------- + MAIN NAVBAR STYLE +--------------------------------------------------- */ + +.cd-navbar-main { + display: flex; + flex: 1; + flex-direction: column; + height: 100%; +} + +/* --------------------------------------------------- + NAVBAR STYLE +--------------------------------------------------- */ + +::ng-deep cd-navigation .cd-navbar-top { + .cd-navbar-brand { + background: vv.$secondary; + border-top: 4px solid vv.$primary; + + .navbar-brand, + .navbar-brand:hover { + color: vv.$gray-200; + height: auto; + padding: 0; + } + + .navbar-brand > img { + height: 25px; + } + + .navbar-toggler { + border: 0; + + &:focus, + &:hover { + outline: 0; + } + + .fa-navicon { + color: vv.$gray-200; + } + } + + .navbar-collapse { + padding: 0; + } + + .cd-navbar-utility > .active > a { + background-color: vv.$primary; + color: vv.$gray-200; + } + + .cd-navbar-utility > li > .open > a, + .cd-navbar-utility > li > .open > a:focus, + .cd-navbar-utility > li > .open > a:hover { + background-color: transparent; + border-color: transparent; + color: vv.$gray-200; + } + } + + .navbar-nav > li > .cd-navbar > [ngbDropdown] > a, + .navbar-nav > li > .cd-navbar > a, + .navbar-nav > li > a { + color: vv.$gray-200; + display: block; + line-height: 1; + padding: 13.5px 18px !important; + position: relative; + text-decoration: none; + } + + .navbar-nav .nav-link, + .navbar-nav .nav-link:hover { + color: vv.$gray-200; + } + + .navbar-nav > li > .cd-navbar > [ngbDropdown] > a:hover, + .navbar-nav > li > .cd-navbar > [ngbDropdown].open > a, + .navbar-nav > li > .cd-navbar > a:hover, + .navbar-nav > li > a:hover, + .navbar-nav > li:hover { + background-color: vv.$primary; + } + + .navbar-nav > .open > .cd-navbar > [ngbDropdown] > a, + .navbar-nav > .open > .cd-navbar > [ngbDropdown] > a:hover, + .navbar-nav > .open > .cd-navbar > a, + .navbar-nav > .open > .cd-navbar > a:focus, + .navbar-nav > .open > .cd-navbar > a:hover, + .navbar-nav > .open > .cd-navbar > li > a:focus, + .navbar-nav > .open > a, + .navbar-nav > .open > a:focus, + .navbar-nav > .open > a:hover { + background-color: transparent; + border-color: transparent; + color: vv.$gray-200; + } + + @media (min-width: vv.$screen-md-min) { + .cd-navbar-utility { + border-bottom: 0; + font-size: 1.1rem; + position: absolute; + right: 0; + top: 0; + } + } + + @media (max-width: vv.$screen-sm-max) { + .navbar-nav { + margin: 0; + + .fa { + margin-right: 0.5em; + } + + .open .dropdown-menu { + background-color: vv.$primary; + border: 0; + padding-bottom: 0; + padding-top: 0; + } + + .open .dropdown-menu > li > a { + color: vv.$gray-200; + padding: 5px 15px 5px 35px; + } + + .open .dropdown-menu > .active > a { + background-color: vv.$primary; + } + } + + .navbar-nav > li > a:hover { + background-color: vv.$primary; + } + } +} + +/* --------------------------------------------------- + SIDEBAR STYLE +--------------------------------------------------- */ + +$sidebar-width: 200px; + +.cd-navbar-primary .active > a, +.cd-navbar-primary > .active > a:focus, +.cd-navbar-primary > .active > a:hover { + background-color: vv.$primary !important; + border: 0 !important; + color: vv.$gray-200 !important; +} + +.wrapper { + display: flex; + height: 100%; + width: 100%; + + #sidebar { + background: vv.$secondary; + bottom: 0; + color: vv.$white; + height: auto; + left: 0; + overflow-y: auto; + position: relative; + transition: all 0.3s; + width: $sidebar-width; + z-index: 999; + + &.active { + margin-left: -$sidebar-width; + } + + ul { + &.component { + margin: 0; + padding: 20px 0; + } + + p { + color: vv.$white; + padding: 10px; + } + + li a { + color: vv.$white; + display: block; + font-size: 1.1em; + padding: 10px; + padding-left: 27px; + + text-decoration: none; + + &:hover { + background: vv.$primary; + color: vv.$white; + } + + > .badge { + margin-left: 5px; + } + } + + li.active > a, + li > a a[aria-expanded='true'] { + color: vv.$white; + } + } + } + + a.dropdown-toggle { + position: relative; + + &::after { + border: 0; + content: '\f054'; + font-family: 'ForkAwesome'; + font-size: 1rem; + position: absolute; + right: 20px; + transition: transform 0.3s ease-in-out; + } + + &[aria-expanded='true']::after { + transform: rotate(90deg); + } + } + + ul ul a { + background: lighten(vv.$secondary, 10); + font-size: 0.9em !important; + padding-left: 40px !important; + } + + .cd-navbar-primary a:focus { + outline: none; + } + + ngx-simplebar { + height: 100%; + } +} + +/* --------------------------------------------------- + CONTENT STYLE +--------------------------------------------------- */ + +#content { + bottom: 0; + position: relative; + right: 0; + transition: all 0.3s; + width: calc(100% - #{$sidebar-width}); + + &.active { + width: 100vw; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts new file mode 100644 index 000000000..241910f2b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts @@ -0,0 +1,237 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { MockModule } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { Permission, Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { + Features, + FeatureTogglesMap, + FeatureTogglesService +} from '~/app/shared/services/feature-toggles.service'; +import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { NavigationModule } from '../navigation.module'; +import { NavigationComponent } from './navigation.component'; + +function everythingPermittedExcept(disabledPermissions: string[] = []): any { + const permissions: Permissions = new Permissions({}); + Object.keys(permissions).forEach( + (key) => (permissions[key] = new Permission(disabledPermissions.includes(key) ? [] : ['read'])) + ); + return permissions; +} + +function onlyPermitted(enabledPermissions: string[] = []): any { + const permissions: Permissions = new Permissions({}); + enabledPermissions.forEach((key) => (permissions[key] = new Permission(['read']))); + return permissions; +} + +function everythingEnabledExcept(features: Features[] = []): FeatureTogglesMap { + const featureTogglesMap: FeatureTogglesMap = new FeatureTogglesMap(); + features.forEach((key) => (featureTogglesMap[key] = false)); + return featureTogglesMap; +} + +function onlyEnabled(features: Features[] = []): FeatureTogglesMap { + const featureTogglesMap: FeatureTogglesMap = new FeatureTogglesMap(); + Object.keys(featureTogglesMap).forEach( + (key) => (featureTogglesMap[key] = features.includes(<Features>key)) + ); + return featureTogglesMap; +} + +describe('NavigationComponent', () => { + let component: NavigationComponent; + let fixture: ComponentFixture<NavigationComponent>; + + configureTestBed({ + declarations: [NavigationComponent], + imports: [HttpClientTestingModule, MockModule(NavigationModule)], + providers: [ + { + provide: AuthStorageService, + useValue: { + getPermissions: jest.fn(), + isPwdDisplayed$: { subscribe: jest.fn() }, + telemetryNotification$: { subscribe: jest.fn() } + } + }, + { provide: SummaryService, useValue: { subscribe: jest.fn() } }, + { provide: FeatureTogglesService, useValue: { get: jest.fn() } }, + { provide: PrometheusAlertService, useValue: { alerts: [] } } + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NavigationComponent); + component = fixture.componentInstance; + }); + + describe('Test Permissions', () => { + const testCases: [string[], string[]][] = [ + [ + ['hosts'], + [ + '.tc_submenuitem_hosts', + '.tc_submenuitem_cluster_inventory', + '.tc_submenuitem_cluster_services' + ] + ], + [['monitor'], ['.tc_submenuitem_cluster_monitor']], + [['osd'], ['.tc_submenuitem_osds', '.tc_submenuitem_crush']], + [['configOpt'], ['.tc_submenuitem_configuration', '.tc_submenuitem_modules']], + [['log'], ['.tc_submenuitem_log']], + [['prometheus'], ['.tc_submenuitem_monitoring']], + [['pool'], ['.tc_menuitem_pool']], + [['rbdImage'], ['.tc_submenuitem_block_images']], + [['rbdMirroring'], ['.tc_submenuitem_block_mirroring']], + [['iscsi'], ['.tc_submenuitem_block_iscsi']], + [['rbdImage', 'rbdMirroring', 'iscsi'], ['.tc_menuitem_block']], + [['nfs'], ['.tc_menuitem_nfs']], + [['cephfs'], ['.tc_menuitem_cephfs']], + [ + ['rgw'], + [ + '.tc_menuitem_rgw', + '.tc_submenuitem_rgw_daemons', + '.tc_submenuitem_rgw_buckets', + '.tc_submenuitem_rgw_users' + ] + ] + ]; + + for (const [disabledPermissions, selectors] of testCases) { + it(`When disabled permissions: ${JSON.stringify( + disabledPermissions + )} => hidden: "${selectors}"`, () => { + component.permissions = everythingPermittedExcept(disabledPermissions); + component.enabledFeature$ = of(everythingEnabledExcept()); + + fixture.detectChanges(); + for (const selector of selectors) { + expect(fixture.debugElement.query(By.css(selector))).toBeFalsy(); + } + }); + } + + for (const [enabledPermissions, selectors] of testCases) { + it(`When enabled permissions: ${JSON.stringify( + enabledPermissions + )} => visible: "${selectors}"`, () => { + component.permissions = onlyPermitted(enabledPermissions); + component.enabledFeature$ = of(everythingEnabledExcept()); + + fixture.detectChanges(); + for (const selector of selectors) { + expect(fixture.debugElement.query(By.css(selector))).toBeTruthy(); + } + }); + } + }); + + describe('Test FeatureToggles', () => { + const testCases: [Features[], string[]][] = [ + [['rbd'], ['.tc_submenuitem_block_images']], + [['mirroring'], ['.tc_submenuitem_block_mirroring']], + [['iscsi'], ['.tc_submenuitem_block_iscsi']], + [['rbd', 'mirroring', 'iscsi'], ['.tc_menuitem_block']], + [['nfs'], ['.tc_menuitem_nfs']], + [['cephfs'], ['.tc_menuitem_cephfs']], + [ + ['rgw'], + [ + '.tc_menuitem_rgw', + '.tc_submenuitem_rgw_daemons', + '.tc_submenuitem_rgw_buckets', + '.tc_submenuitem_rgw_users' + ] + ] + ]; + + for (const [disabledFeatures, selectors] of testCases) { + it(`When disabled features: ${JSON.stringify( + disabledFeatures + )} => hidden: "${selectors}"`, () => { + component.enabledFeature$ = of(everythingEnabledExcept(disabledFeatures)); + component.permissions = everythingPermittedExcept(); + + fixture.detectChanges(); + for (const selector of selectors) { + expect(fixture.debugElement.query(By.css(selector))).toBeFalsy(); + } + }); + } + + for (const [enabledFeatures, selectors] of testCases) { + it(`When enabled features: ${JSON.stringify( + enabledFeatures + )} => visible: "${selectors}"`, () => { + component.enabledFeature$ = of(onlyEnabled(enabledFeatures)); + component.permissions = everythingPermittedExcept(); + + fixture.detectChanges(); + for (const selector of selectors) { + expect(fixture.debugElement.query(By.css(selector))).toBeTruthy(); + } + }); + } + }); + + describe('showTopNotification', () => { + const notification1 = 'notificationName1'; + const notification2 = 'notificationName2'; + + beforeEach(() => { + component.notifications = []; + }); + + it('should show notification', () => { + component.showTopNotification(notification1, true); + expect(component.notifications.includes(notification1)).toBeTruthy(); + expect(component.notifications.length).toBe(1); + }); + + it('should not add a second notification if it is already shown', () => { + component.showTopNotification(notification1, true); + component.showTopNotification(notification1, true); + expect(component.notifications.includes(notification1)).toBeTruthy(); + expect(component.notifications.length).toBe(1); + }); + + it('should add a second notification if the first one is different', () => { + component.showTopNotification(notification1, true); + component.showTopNotification(notification2, true); + expect(component.notifications.includes(notification1)).toBeTruthy(); + expect(component.notifications.includes(notification2)).toBeTruthy(); + expect(component.notifications.length).toBe(2); + }); + + it('should hide an active notification', () => { + component.showTopNotification(notification1, true); + expect(component.notifications.includes(notification1)).toBeTruthy(); + expect(component.notifications.length).toBe(1); + component.showTopNotification(notification1, false); + expect(component.notifications.length).toBe(0); + }); + + it('should not fail if it tries to hide an inactive notification', () => { + expect(() => component.showTopNotification(notification1, false)).not.toThrow(); + expect(component.notifications.length).toBe(0); + }); + + it('should keep other notifications if it hides one', () => { + component.showTopNotification(notification1, true); + component.showTopNotification(notification2, true); + expect(component.notifications.length).toBe(2); + component.showTopNotification(notification2, false); + expect(component.notifications.length).toBe(1); + expect(component.notifications.includes(notification1)).toBeTruthy(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts new file mode 100644 index 000000000..512feecef --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts @@ -0,0 +1,123 @@ +import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core'; + +import * as _ from 'lodash'; +import { Subscription } from 'rxjs'; + +import { Icons } from '~/app/shared/enum/icons.enum'; +import { Permissions } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { + FeatureTogglesMap$, + FeatureTogglesService +} from '~/app/shared/services/feature-toggles.service'; +import { MotdNotificationService } from '~/app/shared/services/motd-notification.service'; +import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; +import { TelemetryNotificationService } from '~/app/shared/services/telemetry-notification.service'; + +@Component({ + selector: 'cd-navigation', + templateUrl: './navigation.component.html', + styleUrls: ['./navigation.component.scss'] +}) +export class NavigationComponent implements OnInit, OnDestroy { + notifications: string[] = []; + @HostBinding('class') get class(): string { + return 'top-notification-' + this.notifications.length; + } + + permissions: Permissions; + enabledFeature$: FeatureTogglesMap$; + summaryData: any; + icons = Icons; + + rightSidebarOpen = false; // rightSidebar only opens when width is less than 768px + showMenuSidebar = true; + displayedSubMenu = ''; + + simplebar = { + autoHide: false + }; + private subs = new Subscription(); + + constructor( + private authStorageService: AuthStorageService, + private summaryService: SummaryService, + private featureToggles: FeatureTogglesService, + private telemetryNotificationService: TelemetryNotificationService, + public prometheusAlertService: PrometheusAlertService, + private motdNotificationService: MotdNotificationService + ) { + this.permissions = this.authStorageService.getPermissions(); + this.enabledFeature$ = this.featureToggles.get(); + } + + ngOnInit() { + this.subs.add( + this.summaryService.subscribe((summary) => { + this.summaryData = summary; + }) + ); + /* + Note: If you're going to add more top notifications please do not forget to increase + the number of generated css-classes in section topNotification settings in the scss + file. + */ + this.subs.add( + this.authStorageService.isPwdDisplayed$.subscribe((isDisplayed) => { + this.showTopNotification('isPwdDisplayed', isDisplayed); + }) + ); + this.subs.add( + this.telemetryNotificationService.update.subscribe((visible: boolean) => { + this.showTopNotification('telemetryNotificationEnabled', visible); + }) + ); + this.subs.add( + this.motdNotificationService.motd$.subscribe((motd: any) => { + this.showTopNotification('motdNotificationEnabled', _.isPlainObject(motd)); + }) + ); + } + + ngOnDestroy(): void { + this.subs.unsubscribe(); + } + + blockHealthColor() { + if (this.summaryData && this.summaryData.rbd_mirroring) { + if (this.summaryData.rbd_mirroring.errors > 0) { + return { color: '#d9534f' }; + } else if (this.summaryData.rbd_mirroring.warnings > 0) { + return { color: '#f0ad4e' }; + } + } + + return undefined; + } + + toggleSubMenu(menu: string) { + if (this.displayedSubMenu === menu) { + this.displayedSubMenu = ''; + } else { + this.displayedSubMenu = menu; + } + } + + toggleRightSidebar() { + this.rightSidebarOpen = !this.rightSidebarOpen; + } + + showTopNotification(name: string, isDisplayed: boolean) { + if (isDisplayed) { + if (!this.notifications.includes(name)) { + this.notifications.push(name); + } + } else { + const index = this.notifications.indexOf(name); + if (index >= 0) { + this.notifications.splice(index, 1); + } + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html new file mode 100644 index 000000000..f5eae4f89 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html @@ -0,0 +1,11 @@ +<a i18n-title + title="Tasks and Notifications" + [ngClass]="{ 'running': hasRunningTasks }" + (click)="toggleSidebar()"> + <i [ngClass]="[icons.bell]"></i> + <span class="dot" + *ngIf="hasNotifications"> + </span> + <span class="d-md-none" + i18n>Tasks and Notifications</span> +</a> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss new file mode 100644 index 000000000..5729f7625 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss @@ -0,0 +1,27 @@ +@use './src/styles/vendor/variables' as vv; + +.running i { + color: vv.$primary; +} + +.running:hover i { + color: vv.$white; +} + +a { + .dot { + background-color: vv.$primary; + border: 2px solid vv.$secondary; + border-radius: 50%; + height: 11px; + position: absolute; + right: 17px; + top: 10px; + width: 10px; + } + + &:hover .dot { + background-color: vv.$white; + border-color: vv.$primary; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts new file mode 100644 index 000000000..8fea818cf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts @@ -0,0 +1,58 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastrModule } from 'ngx-toastr'; + +import { CdNotification, CdNotificationConfig } from '~/app/shared/models/cd-notification'; +import { ExecutingTask } from '~/app/shared/models/executing-task'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { NotificationsComponent } from './notifications.component'; + +describe('NotificationsComponent', () => { + let component: NotificationsComponent; + let fixture: ComponentFixture<NotificationsComponent>; + let summaryService: SummaryService; + let notificationService: NotificationService; + + configureTestBed({ + imports: [HttpClientTestingModule, SharedModule, ToastrModule.forRoot(), RouterTestingModule], + declarations: [NotificationsComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotificationsComponent); + component = fixture.componentInstance; + summaryService = TestBed.inject(SummaryService); + notificationService = TestBed.inject(NotificationService); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should subscribe and check if there are running tasks', () => { + expect(component.hasRunningTasks).toBeFalsy(); + + const task = new ExecutingTask('task', { name: 'name' }); + summaryService['summaryDataSource'].next({ executing_tasks: [task] }); + + expect(component.hasRunningTasks).toBeTruthy(); + }); + + it('should create a dot if there are running notifications', () => { + const notification = new CdNotification(new CdNotificationConfig()); + const recent = notificationService['dataSource'].getValue(); + recent.push(notification); + notificationService['dataSource'].next(recent); + expect(component.hasNotifications).toBeTruthy(); + fixture.detectChanges(); + const dot = fixture.debugElement.nativeElement.querySelector('.dot'); + expect(dot).not.toBe(''); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts new file mode 100644 index 000000000..89c6c4037 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts @@ -0,0 +1,47 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import { Subscription } from 'rxjs'; + +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdNotification } from '~/app/shared/models/cd-notification'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; + +@Component({ + selector: 'cd-notifications', + templateUrl: './notifications.component.html', + styleUrls: ['./notifications.component.scss'] +}) +export class NotificationsComponent implements OnInit, OnDestroy { + icons = Icons; + hasRunningTasks = false; + hasNotifications = false; + private subs = new Subscription(); + + constructor( + public notificationService: NotificationService, + private summaryService: SummaryService + ) {} + + ngOnInit() { + this.subs.add( + this.summaryService.subscribe((summary) => { + this.hasRunningTasks = summary.executing_tasks.length > 0; + }) + ); + + this.subs.add( + this.notificationService.data$.subscribe((notifications: CdNotification[]) => { + this.hasNotifications = notifications.length > 0; + }) + ); + } + + ngOnDestroy(): void { + this.subs.unsubscribe(); + } + + toggleSidebar() { + this.notificationService.toggleSidebar(); + } +} |