[SM-455] add projects and secrets to dashboard page (#4722)

* add projects and secrets to dashboard

* add header title

* add section component

* only show latest projects and secrets

* reorganize view model; all view all link

* fix i18n; update table styles; add bitSortable to secrets table

* apply code reviews

* remove basePath input; add viewProjectEvent output

* fix style and merge issue

* fix route

* use absolute route with org id
This commit is contained in:
Will Martin 2023-02-16 09:18:17 -05:00 committed by GitHub
parent 55741445ec
commit 375c552623
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 298 additions and 34 deletions

View File

@ -6361,6 +6361,22 @@
"userAccessSecretsManager": { "userAccessSecretsManager": {
"message": "This user can access the Secrets Manager Beta" "message": "This user can access the Secrets Manager Beta"
}, },
"viewAll": {
"message": "View all"
},
"showingPortionOfTotal": {
"message": "Showing $PORTION$ of $TOTAL$",
"placeholders": {
"portion": {
"content": "$1",
"example": "2"
},
"total": {
"content": "$2",
"example": "5"
}
}
},
"resolveTheErrorsBelowAndTryAgain": { "resolveTheErrorsBelowAndTryAgain": {
"message": "Resolve the errors below and try again." "message": "Resolve the errors below and try again."
}, },

View File

@ -1,3 +1,41 @@
<sm-header> <sm-header [title]="organizationName">
<sm-new-menu></sm-new-menu> <sm-new-menu></sm-new-menu>
</sm-header> </sm-header>
<div
*ngIf="view$ | async as view; else spinner"
class="tw-flex tw-flex-col tw-gap-6 [&_sm-header]:tw-hidden"
>
<sm-section>
<h2 slot="summary" class="tw-mb-0 tw-text-2xl tw-font-semibold">{{ "projects" | i18n }}</h2>
<sm-projects-list
(newProjectEvent)="openNewProjectDialog()"
(editProjectEvent)="openEditProject($event)"
(deleteProjectEvent)="openDeleteProjectDialog($event)"
[projects]="view.latestProjects"
></sm-projects-list>
<div *ngIf="view.allProjects.length > 0" class="tw-ml-auto tw-mt-4 tw-max-w-max">
{{ "showingPortionOfTotal" | i18n: view.latestProjects.length:view.allProjects.length }}
<a bitLink routerLink="projects" class="tw-ml-2">{{ "viewAll" | i18n }}</a>
</div>
</sm-section>
<sm-section>
<h2 slot="summary" class="tw-mb-0 tw-text-2xl tw-font-semibold">{{ "secrets" | i18n }}</h2>
<sm-secrets-list
baseRoute="secrets"
(deleteSecretsEvent)="openDeleteSecret($event)"
(newSecretEvent)="openNewSecretDialog()"
(editSecretEvent)="openEditSecret($event)"
[secrets]="view.latestSecrets"
></sm-secrets-list>
<div *ngIf="view.allSecrets.length > 0" class="tw-ml-auto tw-mt-4 tw-max-w-max">
{{ "showingPortionOfTotal" | i18n: view.latestSecrets.length:view.allSecrets.length }}
<a bitLink routerLink="secrets" class="tw-ml-2">{{ "viewAll" | i18n }}</a>
</div>
</sm-section>
</div>
<ng-template #spinner>
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>

View File

@ -1,7 +1,177 @@
import { Component } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import {
combineLatest,
combineLatestWith,
map,
Observable,
startWith,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { DialogService } from "@bitwarden/components";
import { ProjectListView } from "../models/view/project-list.view";
import { SecretListView } from "../models/view/secret-list.view";
import {
ProjectDeleteDialogComponent,
ProjectDeleteOperation,
} from "../projects/dialog/project-delete-dialog.component";
import {
OperationType,
ProjectDialogComponent,
ProjectOperation,
} from "../projects/dialog/project-dialog.component";
import { ProjectService } from "../projects/project.service";
import {
SecretDeleteDialogComponent,
SecretDeleteOperation,
} from "../secrets/dialog/secret-delete.component";
import { SecretDialogComponent, SecretOperation } from "../secrets/dialog/secret-dialog.component";
import { SecretService } from "../secrets/secret.service";
@Component({ @Component({
selector: "sm-overview", selector: "sm-overview",
templateUrl: "./overview.component.html", templateUrl: "./overview.component.html",
}) })
export class OverviewComponent {} export class OverviewComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
/**
* Number of items to show in tables
*/
private tableSize = 10;
private organizationId: string;
protected organizationName: string;
protected view$: Observable<{
allProjects: ProjectListView[];
allSecrets: SecretListView[];
latestProjects: ProjectListView[];
latestSecrets: SecretListView[];
}>;
constructor(
private route: ActivatedRoute,
private projectService: ProjectService,
private dialogService: DialogService,
private organizationService: OrganizationService,
private secretService: SecretService
) {}
ngOnInit() {
this.route.params
.pipe(
map((params) => this.organizationService.get(params.organizationId)),
takeUntil(this.destroy$)
)
.subscribe((org) => {
this.organizationId = org.id;
this.organizationName = org.name;
});
const projects$ = this.projectService.project$.pipe(
startWith(null),
combineLatestWith(this.route.params),
switchMap(() => this.getProjects())
);
const secrets$ = this.secretService.secret$.pipe(
startWith(null),
combineLatestWith(this.route.params),
switchMap(() => this.getSecrets())
);
this.view$ = combineLatest([projects$, secrets$]).pipe(
map(([projects, secrets]) => {
return {
allProjects: projects,
allSecrets: secrets,
latestProjects: this.getRecentItems(projects, this.tableSize),
latestSecrets: this.getRecentItems(secrets, this.tableSize),
};
})
);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private getRecentItems<T extends { revisionDate: string }[]>(items: T, length: number): T {
return items
.sort((a, b) => {
return new Date(b.revisionDate).getTime() - new Date(a.revisionDate).getTime();
})
.slice(0, length) as T;
}
// Projects ---
private async getProjects(): Promise<ProjectListView[]> {
return await this.projectService.getProjects(this.organizationId);
}
openEditProject(projectId: string) {
this.dialogService.open<unknown, ProjectOperation>(ProjectDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Edit,
projectId: projectId,
},
});
}
openNewProjectDialog() {
this.dialogService.open<unknown, ProjectOperation>(ProjectDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Add,
},
});
}
openDeleteProjectDialog(event: ProjectListView[]) {
this.dialogService.open<unknown, ProjectDeleteOperation>(ProjectDeleteDialogComponent, {
data: {
projects: event,
},
});
}
// Secrets ---
private async getSecrets(): Promise<SecretListView[]> {
return await this.secretService.getSecrets(this.organizationId);
}
openEditSecret(secretId: string) {
this.dialogService.open<unknown, SecretOperation>(SecretDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Edit,
secretId: secretId,
},
});
}
openDeleteSecret(secretIds: string[]) {
this.dialogService.open<unknown, SecretDeleteOperation>(SecretDeleteDialogComponent, {
data: {
secretIds: secretIds,
},
});
}
openNewSecretDialog() {
this.dialogService.open<unknown, SecretOperation>(SecretDialogComponent, {
data: {
organizationId: this.organizationId,
operation: OperationType.Add,
},
});
}
}

View File

@ -4,10 +4,11 @@ import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
import { OverviewRoutingModule } from "./overview-routing.module"; import { OverviewRoutingModule } from "./overview-routing.module";
import { OverviewComponent } from "./overview.component"; import { OverviewComponent } from "./overview.component";
import { SectionComponent } from "./section.component";
@NgModule({ @NgModule({
imports: [SecretsManagerSharedModule, OverviewRoutingModule], imports: [SecretsManagerSharedModule, OverviewRoutingModule],
declarations: [OverviewComponent], declarations: [OverviewComponent, SectionComponent],
providers: [], providers: [],
}) })
export class OverviewModule {} export class OverviewModule {}

View File

@ -0,0 +1,15 @@
<section>
<header class="tw-flex tw-max-w-min tw-items-center tw-gap-2">
<div><ng-content select="[slot=summary]"></ng-content></div>
<button
type="button"
[bitIconButton]="open ? 'bwi-angle-up' : 'bwi-angle-down'"
(click)="toggle()"
[attr.aria-expanded]="open"
[attr.aria-controls]="contentId"
></button>
</header>
<div *ngIf="open" [attr.id]="contentId" class="tw-mt-4">
<ng-content></ng-content>
</div>
</section>

View File

@ -0,0 +1,18 @@
import { Component, Input } from "@angular/core";
@Component({
selector: "sm-section",
templateUrl: "./section.component.html",
})
export class SectionComponent {
@Input() open = true;
/**
* UID for `[attr.aria-controls]`
*/
protected contentId = Math.random().toString(36).substring(2);
protected toggle() {
this.open = !this.open;
}
}

View File

@ -11,7 +11,6 @@ import { ProjectPeopleComponent } from "./project/project-people.component";
import { ProjectSecretsComponent } from "./project/project-secrets.component"; import { ProjectSecretsComponent } from "./project/project-secrets.component";
import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component"; import { ProjectServiceAccountsComponent } from "./project/project-service-accounts.component";
import { ProjectComponent } from "./project/project.component"; import { ProjectComponent } from "./project/project.component";
import { ProjectsListComponent } from "./projects-list/projects-list.component";
import { ProjectsRoutingModule } from "./projects-routing.module"; import { ProjectsRoutingModule } from "./projects-routing.module";
import { ProjectsComponent } from "./projects/projects.component"; import { ProjectsComponent } from "./projects/projects.component";
@ -19,7 +18,6 @@ import { ProjectsComponent } from "./projects/projects.component";
imports: [SecretsManagerSharedModule, ProjectsRoutingModule, BreadcrumbsModule], imports: [SecretsManagerSharedModule, ProjectsRoutingModule, BreadcrumbsModule],
declarations: [ declarations: [
ProjectsComponent, ProjectsComponent,
ProjectsListComponent,
ProjectAccessComponent, ProjectAccessComponent,
ProjectDialogComponent, ProjectDialogComponent,
ProjectDeleteDialogComponent, ProjectDeleteDialogComponent,

View File

@ -31,7 +31,7 @@
{{ "all" | i18n }} {{ "all" | i18n }}
</label> </label>
</th> </th>
<th bitCell colspan="2" bitSortable="name" default>{{ "name" | i18n }}</th> <th bitCell class="tw-w-full tw-text-left" bitSortable="name" default>{{ "name" | i18n }}</th>
<th bitCell bitSortable="revisionDate">{{ "lastEdited" | i18n }}</th> <th bitCell bitSortable="revisionDate">{{ "lastEdited" | i18n }}</th>
<th bitCell class="tw-w-0"> <th bitCell class="tw-w-0">
<button <button
@ -54,13 +54,15 @@
[checked]="selection.isSelected(project.id)" [checked]="selection.isSelected(project.id)"
/> />
</td> </td>
<td bitCell class="tw-w-0 tw-pr-0"> <td bitCell>
<i class="bwi bwi-collection tw-text-xl tw-text-muted" aria-hidden="true"></i> <div class="tw-flex tw-items-center tw-gap-4 tw-break-all">
<i class="bwi bwi-collection tw-text-xl tw-text-muted" aria-hidden="true"></i>
<a bitLink [routerLink]="['/sm', project.organizationId, 'projects', project.id]">{{
project.name
}}</a>
</div>
</td> </td>
<td bitCell class="tw-break-all"> <td bitCell class="tw-whitespace-nowrap">{{ project.revisionDate | date: "medium" }}</td>
<a bitLink [routerLink]="[project.id]">{{ project.name }}</a>
</td>
<td bitCell>{{ project.revisionDate | date: "medium" }}</td>
<td bitCell> <td bitCell>
<button <button
type="button" type="button"
@ -76,7 +78,7 @@
<i class="bwi bwi-fw bwi-pencil tw-text-xl" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-pencil tw-text-xl" aria-hidden="true"></i>
{{ "editProject" | i18n }} {{ "editProject" | i18n }}
</button> </button>
<a bitMenuItem [routerLink]="project.id"> <a bitMenuItem [routerLink]="['/sm', project.organizationId, 'projects', project.id]">
<i class="bwi bwi-fw bwi-eye tw-text-xl" aria-hidden="true"></i> <i class="bwi bwi-fw bwi-eye tw-text-xl" aria-hidden="true"></i>
{{ "viewProject" | i18n }} {{ "viewProject" | i18n }}
</a> </a>

View File

@ -4,7 +4,7 @@ import { Subject, takeUntil } from "rxjs";
import { TableDataSource } from "@bitwarden/components"; import { TableDataSource } from "@bitwarden/components";
import { ProjectListView } from "../../models/view/project-list.view"; import { ProjectListView } from "../models/view/project-list.view";
@Component({ @Component({
selector: "sm-projects-list", selector: "sm-projects-list",

View File

@ -17,7 +17,7 @@
</button> </button>
</sm-no-items> </sm-no-items>
<bit-table *ngIf="secrets?.length >= 1"> <bit-table *ngIf="secrets?.length >= 1" [dataSource]="dataSource">
<ng-container header> <ng-container header>
<tr> <tr>
<th bitCell class="tw-w-0"> <th bitCell class="tw-w-0">
@ -31,9 +31,9 @@
{{ "all" | i18n }} {{ "all" | i18n }}
</label> </label>
</th> </th>
<th bitCell colspan="2">{{ "name" | i18n }}</th> <th bitCell class="tw-w-full tw-text-left" bitSortable="name" default>{{ "name" | i18n }}</th>
<th bitCell>{{ "project" | i18n }}</th> <th bitCell>{{ "project" | i18n }}</th>
<th bitCell>{{ "lastEdited" | i18n }}</th> <th bitCell bitSortable="revisionDate">{{ "lastEdited" | i18n }}</th>
<th bitCell class="tw-w-0"> <th bitCell class="tw-w-0">
<button <button
type="button" type="button"
@ -46,8 +46,8 @@
</th> </th>
</tr> </tr>
</ng-container> </ng-container>
<ng-template body> <ng-template body let-rows$>
<tr bitRow *ngFor="let secret of secrets"> <tr bitRow *ngFor="let secret of rows$ | async">
<td bitCell> <td bitCell>
<input <input
type="checkbox" type="checkbox"
@ -55,13 +55,13 @@
[checked]="selection.isSelected(secret.id)" [checked]="selection.isSelected(secret.id)"
/> />
</td> </td>
<td bitCell class="tw-w-0 tw-pr-0"> <td bitCell>
<i class="bwi bwi-key tw-text-xl tw-text-muted" aria-hidden="true"></i> <div class="tw-flex tw-items-center tw-gap-4 tw-break-all">
</td> <i class="bwi bwi-key tw-text-xl tw-text-muted" aria-hidden="true"></i>
<td bitCell class="tw-break-all"> <button type="button" bitLink (click)="editSecretEvent.emit(secret.id)">
<button type="button" bitLink (click)="editSecretEvent.emit(secret.id)"> {{ secret.name }}
{{ secret.name }} </button>
</button> </div>
</td> </td>
<td bitCell> <td bitCell>
<span <span
@ -73,7 +73,7 @@
{{ project.name }} {{ project.name }}
</span> </span>
</td> </td>
<td bitCell>{{ secret.revisionDate | date: "medium" }}</td> <td bitCell class="tw-whitespace-nowrap">{{ secret.revisionDate | date: "medium" }}</td>
<td bitCell> <td bitCell>
<button <button
type="button" type="button"

View File

@ -2,6 +2,8 @@ import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core"; import { Component, EventEmitter, Input, OnDestroy, Output } from "@angular/core";
import { Subject, takeUntil } from "rxjs"; import { Subject, takeUntil } from "rxjs";
import { TableDataSource } from "@bitwarden/components";
import { SecretListView } from "../models/view/secret-list.view"; import { SecretListView } from "../models/view/secret-list.view";
@Component({ @Component({
@ -9,6 +11,8 @@ import { SecretListView } from "../models/view/secret-list.view";
templateUrl: "./secrets-list.component.html", templateUrl: "./secrets-list.component.html",
}) })
export class SecretsListComponent implements OnDestroy { export class SecretsListComponent implements OnDestroy {
protected dataSource = new TableDataSource<SecretListView>();
@Input() @Input()
get secrets(): SecretListView[] { get secrets(): SecretListView[] {
return this._secrets; return this._secrets;
@ -16,6 +20,7 @@ export class SecretsListComponent implements OnDestroy {
set secrets(secrets: SecretListView[]) { set secrets(secrets: SecretListView[]) {
this.selection.clear(); this.selection.clear();
this._secrets = secrets; this._secrets = secrets;
this.dataSource.data = secrets;
} }
private _secrets: SecretListView[]; private _secrets: SecretListView[];

View File

@ -10,6 +10,7 @@ import { BulkStatusDialogComponent } from "./dialogs/bulk-status-dialog.componen
import { HeaderComponent } from "./header.component"; import { HeaderComponent } from "./header.component";
import { NewMenuComponent } from "./new-menu.component"; import { NewMenuComponent } from "./new-menu.component";
import { NoItemsComponent } from "./no-items.component"; import { NoItemsComponent } from "./no-items.component";
import { ProjectsListComponent } from "./projects-list.component";
import { SecretsListComponent } from "./secrets-list.component"; import { SecretsListComponent } from "./secrets-list.component";
@NgModule({ @NgModule({
@ -20,6 +21,7 @@ import { SecretsListComponent } from "./secrets-list.component";
HeaderComponent, HeaderComponent,
NewMenuComponent, NewMenuComponent,
NoItemsComponent, NoItemsComponent,
ProjectsListComponent,
SecretsListComponent, SecretsListComponent,
AccessSelectorComponent, AccessSelectorComponent,
], ],
@ -28,6 +30,7 @@ import { SecretsListComponent } from "./secrets-list.component";
HeaderComponent, HeaderComponent,
NewMenuComponent, NewMenuComponent,
NoItemsComponent, NoItemsComponent,
ProjectsListComponent,
SecretsListComponent, SecretsListComponent,
AccessSelectorComponent, AccessSelectorComponent,
], ],

View File

@ -7,12 +7,7 @@ import { TableComponent } from "./table.component";
@Component({ @Component({
selector: "th[bitSortable]", selector: "th[bitSortable]",
template: ` template: `
<button <button [ngClass]="classList" [attr.aria-pressed]="isActive" (click)="setActive()">
class="tw-group"
[ngClass]="classList"
[attr.aria-pressed]="isActive"
(click)="setActive()"
>
<ng-content></ng-content> <ng-content></ng-content>
<i class="bwi tw-ml-2" [ngClass]="icon"></i> <i class="bwi tw-ml-2" [ngClass]="icon"></i>
</button> </button>
@ -83,6 +78,9 @@ export class SortableComponent implements OnInit {
get classList() { get classList() {
return [ return [
"tw-group",
"tw-min-w-max",
// Offset to border and padding // Offset to border and padding
"-tw-m-1.5", "-tw-m-1.5",
"tw-font-bold", "tw-font-bold",