summaryrefslogtreecommitdiffstats
path: root/src/pybind/mgr/dashboard/frontend/src/app/core
diff options
context:
space:
mode:
Diffstat (limited to 'src/pybind/mgr/dashboard/frontend/src/app/core')
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/auth.module.ts87
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.html95
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.scss68
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.spec.ts77
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/core/auth/login-password-form/login-password-form.component.ts51
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.html64
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.scss54
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts58
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts76
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.html11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.scss9
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.spec.ts67
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-details/role-details.component.ts79
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form-mode.enum.ts3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.html121
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.scss4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.spec.ts222
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.component.ts315
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-form/role-form.model.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.html21
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.spec.ts83
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/role-list/role-list.component.ts169
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-mode.enum.ts3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form-role.model.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.html263
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.spec.ts258
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.component.ts305
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-form/user-form.model.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.html22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.spec.ts82
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-list/user-list.component.ts164
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.html121
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.spec.ts83
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-password-form/user-password-form.component.ts119
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.html14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.spec.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/auth/user-tabs/user-tabs.component.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.html27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.scss5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.spec.ts100
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/context/context.component.ts70
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.html63
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.scss18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.spec.ts49
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts96
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/error/error.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.html1
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/blank-layout/blank-layout.component.ts8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.html34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.scss61
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.spec.ts28
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/login-layout/login-layout.component.ts14
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.scss12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.spec.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.html47
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.scss43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.spec.ts60
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/about/about.component.ts70
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.html22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.spec.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/administration/administration.component.ts22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.html3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.scss7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/api-docs/api-docs.component.ts18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.html11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss12
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts131
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts141
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.html25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/dashboard-help/dashboard-help.component.ts31
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.html27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.spec.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/identity/identity.component.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html272
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss263
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts237
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts123
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts58
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts47
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">&times;</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>&nbsp;
+ <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();
+ }
+}