feat(console): user memberships, generic member create dialog, fix user autocomplete emitter (#606)

* load manager mgmtservice, user service

* add org memberships

* membership component, generic member creation

* refactor member create dialog

* project autocomplete context

* create batch managers in user component

* project context wrapper

* emit on user removal, preselect user on init

* membership avatar style, service

* auth user memberships, navigate to target

* cursor fix, avatar gen

* lint

* i18n fix

* remove role translations

* membership detail page, i18n

* fix role label i18n, after view init loader

* remove projectid from grant remove

* fix iam race condition

* refresh table ts, fix no permission project search

* change membership colors

* refresh table everywhere, replace assets, routing

* fix logo header size

* lint, fix project grant removal

* timestmp for p mem, user list, grants, p list (#615)

* npm audit

* update deps, resolve vulnerability

* fix tslint config

* update lock

* load 20 changes at once

* Update console/src/assets/i18n/de.json

Co-authored-by: Florian Forster <[email protected]>

* Update console/src/assets/i18n/de.json

Co-authored-by: Florian Forster <[email protected]>

* Update console/src/assets/i18n/en.json

Co-authored-by: Florian Forster <[email protected]>

* membership i18n

Co-authored-by: Florian Forster <[email protected]>
This commit is contained in:
Max Peintner
2020-08-24 08:48:47 +02:00
committed by GitHub
co-authored by Florian Forster
parent d49fee23bf
commit 193cfb45f6
84 changed files with 1972 additions and 2911 deletions
@@ -2,15 +2,39 @@
<span class="title">{{'MEMBER.ADD' | translate}}</span>
</h1>
<p class="desc"> {{'ORG_DETAIL.MEMBER.ADDDESCRIPTION' | translate}}</p>
<div mat-dialog-content>
<app-search-user-autocomplete (selectionChanged)="users = $event"></app-search-user-autocomplete>
<!-- if no context -->
<ng-container *ngIf="showCreationTypeSelector">
<mat-form-field class="full-width" appearance="outline">
<mat-label>{{ 'MEMBER.CREATIONTYPE' | translate }}</mat-label>
<mat-select [(ngModel)]="creationType" (selectionChange)="loadRoles()">
<mat-option *ngFor="let type of creationTypes" [value]="type">
{{ 'MEMBER.CREATIONTYPES.'+type | translate}}
</mat-option>
</mat-select>
</mat-form-field>
<ng-container
*ngIf="creationType === CreationType.PROJECT_OWNED || creationType === CreationType.PROJECT_GRANTED">
<p>{{'PROJECT.GRANT.CREATE.SEL_PROJECT' | translate}}</p>
<app-search-project-autocomplete class="block" singleOutput="true"
(selectionChanged)="selectProject($event)"
[autocompleteType]="creationType === CreationType.PROJECT_OWNED ? ProjectAutocompleteType.PROJECT_OWNED : creationType === CreationType.PROJECT_GRANTED ? ProjectAutocompleteType.PROJECT_GRANTED : undefined">
</app-search-project-autocomplete>
</ng-container>
</ng-container>
<!-- if no context end -->
<app-search-user-autocomplete [users]="preselectedUsers" (selectionChanged)="users = $event">
</app-search-user-autocomplete>
<mat-form-field class="full-width" appearance="outline"
*ngIf="creationType === CreationType.PROJECT_OWNED || creationType === CreationType.PROJECT_GRANTED || creationType === CreationType.IAM">
<mat-label>{{ 'PROJECT.GRANT.TITLE' | translate }}</mat-label>
<mat-label>{{ 'ROLESLABEL' | translate }}</mat-label>
<mat-select [(ngModel)]="roles" multiple>
<mat-option *ngFor="let role of memberRoleOptions" [value]="role">
{{ 'ROLES.'+role | translate }}
{{ role }}
</mat-option>
</mat-select>
</mat-form-field>
@@ -1,10 +1,12 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ProjectRole, User } from 'src/app/proto/generated/management_pb';
import { ProjectGrantView, ProjectRole, ProjectView, User } from 'src/app/proto/generated/management_pb';
import { AdminService } from 'src/app/services/admin.service';
import { ProjectService } from 'src/app/services/project.service';
import { ToastService } from 'src/app/services/toast.service';
import { ProjectAutocompleteType } from '../search-project-autocomplete/search-project-autocomplete.component';
export enum CreationType {
PROJECT_OWNED = 0,
PROJECT_GRANTED = 1,
@@ -17,40 +19,78 @@ export enum CreationType {
styleUrls: ['./member-create-dialog.component.scss'],
})
export class MemberCreateDialogComponent {
public projectId: string = '';
private projectId: string = '';
private grantId: string = '';
public preselectedUsers: Array<User.AsObject> = [];
public creationType!: CreationType;
public creationTypes: CreationType[] = [
CreationType.IAM,
CreationType.ORG,
CreationType.PROJECT_OWNED,
CreationType.PROJECT_GRANTED,
];
public users: Array<User.AsObject> = [];
public roles: Array<ProjectRole.AsObject> | string[] = [];
public CreationType: any = CreationType;
public ProjectAutocompleteType: any = ProjectAutocompleteType;
public memberRoleOptions: string[] = [];
public showCreationTypeSelector: boolean = false;
constructor(
private projectService: ProjectService,
private adminService: AdminService,
public dialogRef: MatDialogRef<MemberCreateDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
toastService: ToastService,
private toastService: ToastService,
) {
this.creationType = data.creationType;
this.projectId = data.projectId;
if (data?.projectId) {
this.projectId = data.projectId;
}
if (data?.user) {
this.preselectedUsers = [data.user];
this.users = [data.user];
}
if (this.creationType === CreationType.PROJECT_GRANTED) {
this.projectService.GetProjectGrantMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
}).catch(error => {
toastService.showError(error);
});
} else if (this.creationType === CreationType.PROJECT_OWNED) {
this.projectService.GetProjectMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
}).catch(error => {
toastService.showError(error);
});
} else if (this.creationType === CreationType.IAM) {
this.adminService.GetIamMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
}).catch(error => {
toastService.showError(error);
});
if (data?.creationType !== undefined) {
this.creationType = data.creationType;
this.loadRoles();
} else {
this.showCreationTypeSelector = true;
}
}
public loadRoles(): void {
switch (this.creationType) {
case CreationType.PROJECT_GRANTED:
this.projectService.GetProjectGrantMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
}).catch(error => {
this.toastService.showError(error);
});
break;
case CreationType.PROJECT_OWNED:
this.projectService.GetProjectMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
}).catch(error => {
this.toastService.showError(error);
});
break;
case CreationType.IAM:
this.adminService.GetIamMemberRoles().then(resp => {
this.memberRoleOptions = resp.toObject().rolesList;
}).catch(error => {
this.toastService.showError(error);
});
break;
}
}
public selectProject(project: ProjectView.AsObject | ProjectGrantView.AsObject | any): void {
this.projectId = project.projectId;
if (project.id) {
this.grantId = project.id;
}
}
@@ -59,7 +99,13 @@ export class MemberCreateDialogComponent {
}
public closeDialogWithSuccess(): void {
this.dialogRef.close({ users: this.users, roles: this.roles });
this.dialogRef.close({
users: this.users,
roles: this.roles,
creationType: this.creationType,
projectId: this.projectId,
grantId: this.grantId,
});
}
public setOrgMemberRoles(roles: string[]): void {
@@ -11,6 +11,7 @@ import { SearchUserAutocompleteModule } from 'src/app/modules/search-user-autoco
import {
OrgMemberRolesAutocompleteModule,
} from '../../pages/orgs/org-member-roles-autocomplete/org-member-roles-autocomplete.module';
import { SearchProjectAutocompleteModule } from '../search-project-autocomplete/search-project-autocomplete.module';
import { SearchRolesAutocompleteModule } from '../search-roles-autocomplete/search-roles-autocomplete.module';
import { MemberCreateDialogComponent } from './member-create-dialog.component';
@@ -26,6 +27,7 @@ import { MemberCreateDialogComponent } from './member-create-dialog.component';
FormsModule,
SearchUserAutocompleteModule,
SearchRolesAutocompleteModule,
SearchProjectAutocompleteModule,
OrgMemberRolesAutocompleteModule,
],
})
@@ -9,7 +9,7 @@
@mixin changes-theme($theme) {
.scroll-container {
max-height: 60vh;
max-height: 50vh;
overflow-y: scroll;
.item {
@@ -45,13 +45,13 @@ export class ChangesComponent implements OnInit {
private init(): void {
let first: Promise<Changes>;
switch (this.changeType) {
case ChangeType.MYUSER: first = this.authUserService.GetMyUserChanges(10, 0);
case ChangeType.MYUSER: first = this.authUserService.GetMyUserChanges(20, 0);
break;
case ChangeType.USER: first = this.mgmtUserService.UserChanges(this.id, 10, 0);
case ChangeType.USER: first = this.mgmtUserService.UserChanges(this.id, 20, 0);
break;
case ChangeType.PROJECT: first = this.mgmtUserService.ProjectChanges(this.id, 20, 0);
break;
case ChangeType.ORG: first = this.mgmtUserService.OrgChanges(this.id, 10, 0);
case ChangeType.ORG: first = this.mgmtUserService.OrgChanges(this.id, 20, 0);
break;
}
@@ -70,13 +70,13 @@ export class ChangesComponent implements OnInit {
let more: Promise<Changes>;
switch (this.changeType) {
case ChangeType.MYUSER: more = this.authUserService.GetMyUserChanges(10, cursor);
case ChangeType.MYUSER: more = this.authUserService.GetMyUserChanges(20, cursor);
break;
case ChangeType.USER: more = this.mgmtUserService.UserChanges(this.id, 10, cursor);
case ChangeType.USER: more = this.mgmtUserService.UserChanges(this.id, 20, cursor);
break;
case ChangeType.PROJECT: more = this.mgmtUserService.ProjectChanges(this.id, 10, cursor);
case ChangeType.PROJECT: more = this.mgmtUserService.ProjectChanges(this.id, 20, cursor);
break;
case ChangeType.ORG: more = this.mgmtUserService.OrgChanges(this.id, 10, cursor);
case ChangeType.ORG: more = this.mgmtUserService.OrgChanges(this.id, 20, cursor);
break;
}
@@ -52,6 +52,10 @@
display: flex;
align-items: center;
justify-content: center;
.avatar {
pointer-events: none;
}
}
.margin-neg {
@@ -1,4 +1,5 @@
import { DataSource } from '@angular/cdk/collections';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import { ProjectMember, ProjectMemberSearchResponse, ProjectType } from 'src/app/proto/generated/management_pb';
@@ -11,6 +12,8 @@ import { ProjectService } from 'src/app/services/project.service';
*/
export class ProjectMembersDataSource extends DataSource<ProjectMember.AsObject> {
public totalResult: number = 0;
public viewTimestamp!: Timestamp.AsObject;
public membersSubject: BehaviorSubject<ProjectMember.AsObject[]> = new BehaviorSubject<ProjectMember.AsObject[]>([]);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
@@ -35,8 +38,12 @@ export class ProjectMembersDataSource extends DataSource<ProjectMember.AsObject>
if (promise) {
from(promise).pipe(
map(resp => {
this.totalResult = resp.toObject().totalResult;
return resp.toObject().resultList;
const response = resp.toObject();
this.totalResult = response.totalResult;
if (response.viewTimestamp) {
this.viewTimestamp = response.viewTimestamp;
}
return response.resultList;
}),
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false)),
@@ -2,7 +2,7 @@
title="{{projectName}} {{ 'PROJECT.MEMBER.TITLE' | translate }}"
description="{{ 'PROJECT.MEMBER.DESCRIPTION' | translate }}">
<app-refresh-table *ngIf="project" (refreshed)="changePage()" [dataSize]="dataSource.totalResult"
[selection]="selection" [loading]="dataSource?.loading$ | async">
[timestamp]="dataSource.viewTimestamp" [selection]="selection" [loading]="dataSource?.loading$ | async">
<ng-template appHasRole actions
[appHasRole]="['project.member.delete:' + project.projectId, 'project.member.delete']">
<button (click)="removeProjectMemberSelection()" color="warn"
@@ -68,15 +68,15 @@
</ng-container>
<ng-container matColumnDef="roles">
<th mat-header-cell *matHeaderCellDef> {{ 'PROJECT.MEMBER.ROLES' | translate }} </th>
<th mat-header-cell *matHeaderCellDef> {{ 'ROLESLABEL' | translate }} </th>
<td mat-cell *matCellDef="let member">
<mat-form-field class="form-field" appearance="outline" *ngIf="project">
<mat-label>{{ 'PROJECT.GRANT.TITLE' | translate }}</mat-label>
<mat-label>{{ 'ROLESLABEL' | translate }}</mat-label>
<mat-select [(ngModel)]="member.rolesList" multiple
[disabled]="([('project.member.write:' + project.projectId), 'project.member.write'] | hasRole | async) == false"
(selectionChange)="updateRoles(member, $event)">
<mat-option *ngFor="let role of memberRoleOptions" [value]="role">
{{ 'ROLES.'+role | translate }}
{{ role }}
</mat-option>
</mat-select>
</mat-form-field>
@@ -122,7 +122,6 @@ export class ProjectMembersComponent {
const dialogRef = this.dialog.open(MemberCreateDialogComponent, {
data: {
creationType: CreationType.PROJECT_OWNED,
projectId: this.project.projectId,
},
width: '400px',
});
@@ -1,4 +1,5 @@
import { DataSource } from '@angular/cdk/collections';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import { ProjectRole } from 'src/app/proto/generated/management_pb';
@@ -11,6 +12,8 @@ import { ProjectService } from 'src/app/services/project.service';
*/
export class ProjectRolesDataSource extends DataSource<ProjectRole.AsObject> {
public totalResult: number = 0;
public viewTimestamp!: Timestamp.AsObject;
public rolesSubject: BehaviorSubject<ProjectRole.AsObject[]> = new BehaviorSubject<ProjectRole.AsObject[]>([]);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
@@ -25,7 +28,11 @@ export class ProjectRolesDataSource extends DataSource<ProjectRole.AsObject> {
this.loadingSubject.next(true);
from(this.projectService.SearchProjectRoles(projectId, pageSize, offset)).pipe(
map(resp => {
this.totalResult = resp.toObject().totalResult;
const response = resp.toObject();
this.totalResult = response.totalResult;
if (response.viewTimestamp) {
this.viewTimestamp = response.viewTimestamp;
}
return resp.toObject().resultList;
}),
catchError(() => of([])),
@@ -1,5 +1,5 @@
<app-refresh-table *ngIf="projectId" (refreshed)="refreshPage()" [dataSize]="dataSource.totalResult"
[selection]="selection" [loading]="dataSource.loading$ | async">
<app-refresh-table *ngIf="projectId" (refreshed)="refreshPage()" [dataSize]="dataSource?.totalResult"
[selection]="selection" [loading]="dataSource?.loading$ | async" [timestamp]="dataSource?.viewTimestamp">
<ng-template appHasRole [appHasRole]="['project.role.delete', 'project.role.delete:' + projectId]" actions>
<button color="warn" class="icon-button" [disabled]="disabled"
matTooltip="{{'PROJECT.ROLE.DELETE' | translate}}" (click)="deleteSelectedRoles()" mat-icon-button
@@ -1,5 +1,7 @@
<div class="table-header-row">
<div class="col">
<span class="desc"
*ngIf="timestamp">{{timestamp | timestampToDate | localizedDate: 'EEE dd. MMM, HH:mm'}}</span>
<ng-container *ngIf="!selection.hasValue()">
<span class="desc">{{'ORG_DETAIL.TABLE.TOTAL' | translate}}</span>
<span class="count">{{dataSize}}</span>
@@ -1,6 +1,7 @@
import { animate, animation, keyframes, style, transition, trigger, useAnimation } from '@angular/animations';
import { SelectionModel } from '@angular/cdk/collections';
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
const rotate = animation([
animate(
@@ -27,6 +28,7 @@ const rotate = animation([
})
export class RefreshTableComponent implements OnInit {
@Input() public selection: SelectionModel<any> = new SelectionModel<any>(true, []);
@Input() public timestamp!: Timestamp.AsObject;
@Input() public dataSize: number = 0;
@Input() public emitRefreshAfterTimeoutInMs: number = 0;
@Input() public loading: boolean = false;
@@ -6,6 +6,8 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { LocalizedDatePipeModule } from 'src/app/pipes/localized-date-pipe.module';
import { TimestampToDatePipeModule } from 'src/app/pipes/timestamp-to-date-pipe.module';
import { RefreshTableComponent } from './refresh-table.component';
@@ -21,6 +23,8 @@ import { RefreshTableComponent } from './refresh-table.component';
FormsModule,
MatTooltipModule,
MatProgressSpinnerModule,
TimestampToDatePipeModule,
LocalizedDatePipeModule,
],
exports: [
RefreshTableComponent,
@@ -6,14 +6,22 @@ import { MatChipInputEvent } from '@angular/material/chips';
import { forkJoin, from } from 'rxjs';
import { debounceTime, switchMap, tap } from 'rxjs/operators';
import {
ProjectGrantSearchResponse,
ProjectGrantView,
ProjectSearchKey,
ProjectSearchQuery,
ProjectSearchResponse,
ProjectView,
SearchMethod,
} from 'src/app/proto/generated/management_pb';
import { ProjectService } from 'src/app/services/project.service';
export enum ProjectAutocompleteType {
PROJECT_OWNED = 0,
PROJECT_GRANTED = 1,
}
@Component({
selector: 'app-search-project-autocomplete',
templateUrl: './search-project-autocomplete.component.html',
@@ -32,6 +40,7 @@ export class SearchProjectAutocompleteComponent {
@ViewChild('nameInput') public nameInput!: ElementRef<HTMLInputElement>;
@ViewChild('auto') public matAutocomplete!: MatAutocomplete;
@Input() public singleOutput: boolean = false;
@Input() public autocompleteType!: ProjectAutocompleteType;
@Output() public selectionChanged: EventEmitter<
ProjectGrantView.AsObject[]
| ProjectGrantView.AsObject
@@ -48,14 +57,39 @@ export class SearchProjectAutocompleteComponent {
query.setKey(ProjectSearchKey.PROJECTSEARCHKEY_PROJECT_NAME);
query.setValue(value);
query.setMethod(SearchMethod.SEARCHMETHOD_CONTAINS_IGNORE_CASE);
return forkJoin([
from(this.projectService.SearchGrantedProjects(10, 0, [query])),
from(this.projectService.SearchProjects(10, 0, [query])),
]);
switch (this.autocompleteType) {
case ProjectAutocompleteType.PROJECT_GRANTED:
return from(this.projectService.SearchGrantedProjects(10, 0, [query]));
case ProjectAutocompleteType.PROJECT_OWNED:
return from(this.projectService.SearchProjects(10, 0, [query]));
default:
return forkJoin([
from(this.projectService.SearchGrantedProjects(10, 0, [query])),
from(this.projectService.SearchProjects(10, 0, [query])),
]);
}
}),
).subscribe(([granted, owned]) => {
this.isLoading = false;
this.filteredProjects = [...owned.toObject().resultList, ...granted.toObject().resultList];
).subscribe((returnValue) => {
switch (this.autocompleteType) {
case ProjectAutocompleteType.PROJECT_GRANTED:
this.isLoading = false;
this.filteredProjects = [...(returnValue as ProjectGrantSearchResponse).toObject().resultList];
break;
case ProjectAutocompleteType.PROJECT_OWNED:
this.isLoading = false;
this.filteredProjects = [...(returnValue as ProjectSearchResponse).toObject().resultList];
break;
default:
this.isLoading = false;
this.filteredProjects = [
...(returnValue as (ProjectSearchResponse | ProjectGrantSearchResponse)[])[0]
.toObject().resultList,
...(returnValue as (ProjectSearchResponse | ProjectGrantSearchResponse)[])[1]
.toObject().resultList,
];
break;
}
});
}
@@ -29,7 +29,7 @@ export class SearchUserAutocompleteComponent {
public globalEmailControl: FormControl = new FormControl();
public emails: string[] = [];
public users: Array<User.AsObject> = [];
@Input() public users: Array<User.AsObject> = [];
public filteredUsers: Array<User.AsObject> = [];
public isLoading: boolean = false;
public target: UserTarget = UserTarget.SELF;
@@ -39,6 +39,7 @@ export class SearchUserAutocompleteComponent {
@ViewChild('auto') public matAutocomplete!: MatAutocomplete;
@Output() public selectionChanged: EventEmitter<User.AsObject | User.AsObject[]> = new EventEmitter();
@Input() public singleOutput: boolean = false;
private unsubscribed$: Subject<void> = new Subject();
constructor(private userService: MgmtUserService, private toast: ToastService) {
this.getFilteredResults();
@@ -102,6 +103,7 @@ export class SearchUserAutocompleteComponent {
if (index >= 0) {
this.users.splice(index, 1);
this.selectionChanged.emit(this.users);
}
}
@@ -1,4 +1,5 @@
import { DataSource } from '@angular/cdk/collections';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import {
@@ -20,6 +21,8 @@ export enum UserGrantContext {
export class UserGrantsDataSource extends DataSource<UserGrant.AsObject> {
public totalResult: number = 0;
public viewTimestamp!: Timestamp.AsObject;
public grantsSubject: BehaviorSubject<UserGrantView.AsObject[]> = new BehaviorSubject<UserGrantView.AsObject[]>([]);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
@@ -104,8 +107,12 @@ export class UserGrantsDataSource extends DataSource<UserGrant.AsObject> {
private loadResponse(promise: Promise<UserGrantSearchResponse>): void {
from(promise).pipe(
map(resp => {
this.totalResult = resp.toObject().totalResult;
return resp.toObject().resultList;
const response = resp.toObject();
this.totalResult = response.totalResult;
if (response.viewTimestamp) {
this.viewTimestamp = response.viewTimestamp;
}
return response.resultList;
}),
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false)),
@@ -1,5 +1,5 @@
<app-refresh-table [loading]="dataSource?.loading$ | async" (refreshed)="changePage()"
[dataSize]="dataSource.totalResult" [selection]="selection">
[timestamp]="dataSource?.viewTimestamp" [dataSize]="dataSource?.totalResult" [selection]="selection">
<button color="warn" matTooltip="{{'GRANTS.DELETE' | translate}}" class="icon-button" mat-icon-button actions
(click)="deleteGrantSelection()" *ngIf="selection.hasValue() && allowDelete">
<i class="las la-trash"></i>