feat: rehauled console (#3525)

* new console

* move npm ci to angular build

* rel path for assets

* local grpc copy

* login policy, rm clear views, features rel path

* lock

Co-authored-by: Livio Amstutz <[email protected]>
This commit is contained in:
Max Peintner
2022-04-28 10:35:02 +00:00
committed by GitHub
co-authored by Livio Amstutz
parent 00f7dbe875
commit 08ae39ae19
668 changed files with 47746 additions and 19117 deletions
@@ -0,0 +1,72 @@
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 { Membership } from 'src/app/proto/generated/zitadel/user_pb';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ManagementService } from 'src/app/services/mgmt.service';
export class MembershipsDataSource extends DataSource<Membership.AsObject> {
public totalResult: number = 0;
public viewTimestamp!: Timestamp.AsObject;
public membershipsSubject: BehaviorSubject<Membership.AsObject[]> = new BehaviorSubject<Membership.AsObject[]>([]);
private loadingSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
public loading$: Observable<boolean> = this.loadingSubject.asObservable();
constructor(private auth: GrpcAuthService, private service: ManagementService) {
super();
}
public loadMemberships(userId: string, pageIndex: number, pageSize: number): void {
const offset = pageIndex * pageSize;
this.loadingSubject.next(true);
from(this.service.listUserMemberships(userId, pageSize, offset))
.pipe(
map((resp) => {
this.totalResult = resp.details?.totalResult || 0;
if (resp.details?.viewTimestamp) {
this.viewTimestamp = resp.details?.viewTimestamp;
}
return resp.resultList;
}),
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false)),
)
.subscribe((members) => {
this.membershipsSubject.next(members);
});
}
public loadMyMemberships(pageIndex: number, pageSize: number): void {
const offset = pageIndex * pageSize;
this.loadingSubject.next(true);
from(this.auth.listMyMemberships(pageSize, offset))
.pipe(
map((resp) => {
this.totalResult = resp.details?.totalResult || 0;
if (resp.details?.viewTimestamp) {
this.viewTimestamp = resp.details?.viewTimestamp;
}
return resp.resultList;
}),
catchError(() => of([])),
finalize(() => this.loadingSubject.next(false)),
)
.subscribe((members) => {
this.membershipsSubject.next(members);
});
}
public connect(): Observable<Membership.AsObject[]> {
return this.membershipsSubject.asObservable();
}
public disconnect(): void {
this.membershipsSubject.complete();
this.loadingSubject.complete();
}
}
@@ -0,0 +1,80 @@
<cnsl-refresh-table *ngIf="dataSource" (refreshed)="changePage()" [dataSize]="dataSource.totalResult"
[timestamp]="dataSource.viewTimestamp" [hideRefresh]="true" [selection]="selection"
[loading]="dataSource?.loading$ | async">
<ng-container actions *ngIf="selection.hasValue()">
<ng-content select="[selectactions]"></ng-content>
</ng-container>
<div actions>
<ng-content select="[writeactions]"></ng-content>
</div>
<div class="table-wrapper">
<table mat-table class="table" aria-label="Elements" [dataSource]="dataSource">
<ng-container matColumnDef="select">
<th class="selection" mat-header-cell *matHeaderCellDef>
<mat-checkbox [disabled]="false" color="primary" (change)="$event ? masterToggle() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
</th>
<td class="selection" mat-cell *matCellDef="let row">
<mat-checkbox [disabled]="false" color="primary" (click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null" [checked]="selection.isSelected(row)">
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="displayName">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MEMBERSHIPS.DISPLAYNAME' | translate }} </th>
<td mat-cell *matCellDef="let membership">
{{membership.displayName}} </td>
</ng-container>
<ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MEMBERSHIPS.TYPE' | translate }} </th>
<td mat-cell *matCellDef="let membership">
<span class="state">{{getType(membership)}}</span>
</td>
</ng-container>
<ng-container matColumnDef="orgId">
<th mat-header-cell *matHeaderCellDef> {{ 'USER.MEMBERSHIPS.ORGID' | translate }} </th>
<td mat-cell *matCellDef="let membership">
{{membership.orgId}} </td>
</ng-container>
<ng-container matColumnDef="rolesList">
<th mat-header-cell *matHeaderCellDef class="role-row"> {{ 'ROLESLABEL' | translate }} </th>
<td mat-cell *matCellDef="let membership">
<div class="membership-line">
<mat-chip-list class="cnsl-chip-list" aria-label="role selection">
<mat-chip class="cnsl-chip" *ngFor="let role of membership.rolesList" [removable]="!!userId"
[selectable]="false" (removed)="removeRole(membership, role)">
<div class="cnsl-chip-dot" [style.background]="getColor(role)"></div>
<span>{{role | roletransform}}</span>
<button *ngIf="!!userId" matChipRemove>
<mat-icon>cancel</mat-icon>
</button>
</mat-chip>
</mat-chip-list>
<button matTooltip="{{'ACTIONS.EDIT' | translate}}" (click)="goto(membership)" mat-icon-button
class="goto-btn">
<i class="las la-angle-right"></i>
</button>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr class="highlight" mat-row *matRowDef="let row; columns: displayedColumns;">
</tr>
</table>
</div>
<cnsl-paginator *ngIf="dataSource" class="paginator" #paginator [timestamp]="dataSource?.viewTimestamp"
[pageSize]="INITIALPAGESIZE" [length]="dataSource.totalResult" [pageSizeOptions]="[25, 50, 100, 250]"
(page)="changePage($event)">
</cnsl-paginator>
</cnsl-refresh-table>
@@ -0,0 +1,93 @@
.icon-button {
margin-right: 0.5rem;
}
.action {
width: 40px;
}
.form-field {
flex: 1;
}
.roles-sp-wrapper {
padding: 0.5rem;
display: flex;
justify-content: center;
box-sizing: border-box;
}
// .membership-auth-line {
// display: flex;
// flex-direction: row;
// align-items: center;
// justify-content: space-between;
// .membership-role-list {
// display: flex;
// flex-direction: column;
// margin: 0.25rem;
// font-size: 14px;
// text-overflow: ellipsis;
// overflow: hidden;
// justify-items: center;
// align-self: center;
// }
// .goto-btn {
// margin-left: 0.5rem;
// i {
// height: 1rem;
// line-height: 1rem;
// font-size: 1rem;
// }
// }
// }
.membership-line {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
.membership-role-list {
display: flex;
flex-direction: column;
margin: 0.25rem;
font-size: 14px;
text-overflow: ellipsis;
overflow: hidden;
justify-items: center;
align-self: center;
}
.goto-btn {
margin-left: 0.5rem;
i {
height: 1rem;
line-height: 1rem;
font-size: 1rem;
}
}
}
.sa-icon {
display: block;
width: 32px;
margin: 0 0.5rem;
i {
margin: auto;
}
}
.pointer {
outline: none;
cursor: pointer;
}
.role-row {
min-width: 200px;
}
@@ -0,0 +1,30 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MembershipsTableComponent } from './memberships-table.component';
describe('MembershipsTableComponent', () => {
let component: MembershipsTableComponent;
let fixture: ComponentFixture<MembershipsTableComponent>;
beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [MembershipsTableComponent],
imports: [NoopAnimationsModule, MatSortModule, MatTableModule],
}).compileComponents();
}),
);
beforeEach(() => {
fixture = TestBed.createComponent(MembershipsTableComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should compile', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,295 @@
import { SelectionModel } from '@angular/cdk/collections';
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { MatTable } from '@angular/material/table';
import { Router } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { Org } from 'src/app/proto/generated/zitadel/org_pb';
import { Membership } from 'src/app/proto/generated/zitadel/user_pb';
import { AdminService } from 'src/app/services/admin.service';
import { GrpcAuthService } from 'src/app/services/grpc-auth.service';
import { ManagementService } from 'src/app/services/mgmt.service';
import { OverlayWorkflowService } from 'src/app/services/overlay/overlay-workflow.service';
import { OrgContextChangedWorkflowOverlays } from 'src/app/services/overlay/workflows';
import { StorageLocation, StorageService } from 'src/app/services/storage.service';
import { ToastService } from 'src/app/services/toast.service';
import { getMembershipColor } from 'src/app/utils/color';
import { PageEvent, PaginatorComponent } from '../paginator/paginator.component';
import { MembershipsDataSource } from './memberships-datasource';
@Component({
selector: 'cnsl-memberships-table',
templateUrl: './memberships-table.component.html',
styleUrls: ['./memberships-table.component.scss'],
})
export class MembershipsTableComponent implements OnInit, OnDestroy {
public INITIALPAGESIZE: number = 25;
@ViewChild(PaginatorComponent) public paginator!: PaginatorComponent;
@ViewChild(MatTable) public table!: MatTable<Membership.AsObject>;
@Input() public userId: string = '';
public dataSource!: MembershipsDataSource;
public selection: SelectionModel<any> = new SelectionModel<any>(true, []);
@Output() public changedSelection: EventEmitter<any[]> = new EventEmitter();
@Output() public deleteMembership: EventEmitter<Membership.AsObject> = new EventEmitter();
private destroyed: Subject<void> = new Subject();
public membershipRoleOptions: string[] = [];
public displayedColumns: string[] = ['displayName', 'type', 'rolesList'];
public membershipToEdit: string = '';
public loadingRoles: boolean = false;
constructor(
private authService: GrpcAuthService,
private toastService: ToastService,
private mgmtService: ManagementService,
private adminService: AdminService,
private toast: ToastService,
private router: Router,
private workflowService: OverlayWorkflowService,
private storageService: StorageService,
) {
this.dataSource = new MembershipsDataSource(this.authService, this.mgmtService);
this.selection.changed.pipe(takeUntil(this.destroyed)).subscribe((_) => {
this.changedSelection.emit(this.selection.selected);
});
}
public ngOnInit(): void {
this.changePage(this.paginator);
}
public loadRoles(membership: Membership.AsObject, opened: boolean): void {
if (opened) {
this.loadingRoles = true;
if (membership.orgId && !membership.projectId && !membership.projectGrantId) {
this.membershipToEdit = `${membership.orgId}${membership.projectId}${membership.projectGrantId}`;
this.mgmtService
.listOrgMemberRoles()
.then((resp) => {
this.membershipRoleOptions = resp.resultList;
this.loadingRoles = false;
})
.catch((error) => {
this.toastService.showError(error);
this.loadingRoles = false;
});
} else if (membership.projectGrantId) {
this.membershipToEdit = `${membership.orgId}${membership.projectId}${membership.projectGrantId}`;
this.mgmtService
.listProjectGrantMemberRoles()
.then((resp) => {
this.membershipRoleOptions = resp.resultList;
this.loadingRoles = false;
})
.catch((error) => {
this.toastService.showError(error);
this.loadingRoles = false;
});
} else if (membership.projectId) {
this.membershipToEdit = `${membership.orgId}${membership.projectId}${membership.projectGrantId}`;
this.mgmtService
.listProjectMemberRoles()
.then((resp) => {
this.membershipRoleOptions = resp.resultList;
this.loadingRoles = false;
})
.catch((error) => {
this.toastService.showError(error);
this.loadingRoles = false;
});
} else if (membership.iam) {
this.membershipToEdit = `IAM`;
this.adminService
.listIAMMemberRoles()
.then((resp) => {
console.log(resp);
this.membershipRoleOptions = resp.rolesList;
this.loadingRoles = false;
})
.catch((error) => {
this.toastService.showError(error);
this.loadingRoles = false;
});
}
}
}
public goto(membership: Membership.AsObject): void {
const org: Org.AsObject | null = this.storageService.getItem('organization', StorageLocation.session);
if (membership.orgId && !membership.projectId && !membership.projectGrantId) {
// only shown on auth user, or if currentOrg === resourceOwner
this.authService
.getActiveOrg(membership.orgId)
.then((membershipOrg) => {
this.router.navigate(['/org/members']).then(() => {
this.startOrgContextWorkflow(membershipOrg, org);
});
})
.catch(() => {
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
});
} else if (membership.projectGrantId && membership.details?.resourceOwner) {
// only shown on auth user
this.authService
.getActiveOrg(membership.details?.resourceOwner)
.then((membershipOrg) => {
this.router.navigate(['/granted-projects', membership.projectId, 'grants', membership.projectGrantId]).then(() => {
this.startOrgContextWorkflow(membershipOrg, org);
});
})
.catch(() => {
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
});
} else if (membership.projectId && membership.details?.resourceOwner) {
// only shown on auth user, or if currentOrg === resourceOwner
this.authService
.getActiveOrg(membership.details?.resourceOwner)
.then((membershipOrg) => {
this.router.navigate(['/projects', membership.projectId, 'members']).then(() => {
this.startOrgContextWorkflow(membershipOrg, org);
});
})
.catch(() => {
this.toast.showInfo('USER.MEMBERSHIPS.NOPERMISSIONTOEDIT', true);
});
} else if (membership.iam) {
// only shown on auth user
this.router.navigate(['/system/members']);
}
}
// public canNavigateToEdit(membership: Membership.AsObject): Observable<boolean> {
// if (membership.orgId && !membership.projectId && !membership.projectGrantId) {
// return this.authService.isAllowed(['org.member.read:' + membership.orgId]);
// } else if (membership.projectGrantId && membership.details?.resourceOwner) {
// return from(this.authService.getActiveOrg(membership.details?.resourceOwner)).pipe(
// switchMap(() => this.authService.isAllowed(['project.grant.member.read:' + membership.projectId])),
// catchError(() => of(false)),
// );
// } else if (membership.projectId && membership.details?.resourceOwner) {
// return from(this.authService.getActiveOrg(membership.details?.resourceOwner)).pipe(
// take(1),
// switchMap(() => this.authService.isAllowed(['project.member.read:' + membership.projectId])),
// catchError(() => of(false)),
// );
// } else if (membership.iam) {
// return this.authService.isAllowed(['iam.member.read']);
// } else {
// return of(false);
// }
// }
private startOrgContextWorkflow(membershipOrg: Org.AsObject, currentOrg?: Org.AsObject | null): void {
if (!currentOrg || (membershipOrg.id && currentOrg.id && currentOrg.id !== membershipOrg.id)) {
setTimeout(() => {
this.workflowService.startWorkflow(OrgContextChangedWorkflowOverlays, null);
}, 1000);
}
}
public getType(membership: Membership.AsObject): string {
if (membership.orgId && !membership.projectId && !membership.projectGrantId) {
return 'Organization';
} else if (membership.projectGrantId) {
return 'Project Grant';
} else if (membership.projectId) {
return 'Project';
} else if (membership.iam) {
return 'IAM';
} else {
return '';
}
}
public ngOnDestroy(): void {
this.destroyed.next();
}
public isAllSelected(): boolean {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource.membershipsSubject.value.length;
return numSelected === numRows;
}
public masterToggle(): void {
this.isAllSelected()
? this.selection.clear()
: this.dataSource.membershipsSubject.value.forEach((row) => this.selection.select(row));
}
public changePage(event?: PageEvent): any {
this.selection.clear();
return this.userId
? this.dataSource.loadMemberships(this.userId, event?.pageIndex ?? 0, event?.pageSize ?? this.INITIALPAGESIZE)
: this.dataSource.loadMyMemberships(event?.pageIndex ?? 0, event?.pageSize ?? this.INITIALPAGESIZE);
}
public isCurrentMembership(membership: Membership.AsObject): boolean {
return (
this.membershipToEdit ===
(membership.iam ? 'IAM' : `${membership.orgId}${membership.projectId}${membership.projectGrantId}`)
);
}
public getColor(role: string): string {
const color = getMembershipColor(role);
return color[500];
}
public removeRole(membership: Membership.AsObject, role: string): void {
const newRoles = Object.assign([], membership.rolesList);
const index = newRoles.findIndex((r) => r === role);
if (index > -1) {
newRoles.splice(index);
if (membership.orgId) {
console.log('org member', membership.userId, newRoles);
this.mgmtService
.updateOrgMember(membership.userId, newRoles)
.then(() => {
this.toast.showInfo('USER.MEMBERSHIPS.UPDATED', true);
this.changePage(this.paginator);
})
.catch((error) => {
this.toastService.showError(error);
});
} else if (membership.projectGrantId) {
this.mgmtService
.updateProjectGrantMember(membership.projectId, membership.projectGrantId, membership.userId, newRoles)
.then(() => {
this.toast.showInfo('USER.MEMBERSHIPS.UPDATED', true);
this.changePage(this.paginator);
})
.catch((error) => {
this.toastService.showError(error);
});
} else if (membership.projectId) {
console.log(membership.projectId, membership.userId, newRoles);
this.mgmtService
.updateProjectMember(membership.projectId, membership.userId, newRoles)
.then(() => {
this.toast.showInfo('USER.MEMBERSHIPS.UPDATED', true);
this.changePage(this.paginator);
})
.catch((error) => {
this.toastService.showError(error);
});
} else if (membership.iam) {
this.adminService
.updateIAMMember(membership.userId, newRoles)
.then(() => {
this.toast.showInfo('USER.MEMBERSHIPS.UPDATED', true);
this.changePage(this.paginator);
})
.catch((error) => {
this.toastService.showError(error);
});
}
}
}
}
@@ -0,0 +1,49 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { InputModule } from 'src/app/modules/input/input.module';
import { HasRolePipeModule } from 'src/app/pipes/has-role-pipe/has-role-pipe.module';
import { RoleTransformPipeModule } from 'src/app/pipes/role-transform/role-transform.module';
import { AvatarModule } from '../avatar/avatar.module';
import { PaginatorModule } from '../paginator/paginator.module';
import { RefreshTableModule } from '../refresh-table/refresh-table.module';
import { MembershipsTableComponent } from './memberships-table.component';
@NgModule({
declarations: [MembershipsTableComponent],
imports: [
CommonModule,
InputModule,
MatSelectModule,
MatCheckboxModule,
MatIconModule,
PaginatorModule,
MatChipsModule,
MatTooltipModule,
RoleTransformPipeModule,
FormsModule,
TranslateModule,
RefreshTableModule,
RouterModule,
AvatarModule,
MatTableModule,
MatSortModule,
MatProgressSpinnerModule,
MatButtonModule,
HasRolePipeModule,
],
exports: [MembershipsTableComponent],
})
export class MembershipsTableModule {}