1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-23 19:53:43 +00:00

replace provider clients components with vNext implementation (#12934)

This commit is contained in:
Brandon Treston
2025-01-21 09:50:58 -05:00
committed by GitHub
parent 833f88f9d9
commit b92a98110e
14 changed files with 277 additions and 817 deletions

View File

@@ -1,5 +1,9 @@
<app-header>
<bit-search [placeholder]="'search' | i18n" [(ngModel)]="searchText"></bit-search>
<bit-search
class="tw-grow"
[formControl]="searchControl"
[placeholder]="'search' | i18n"
></bit-search>
<a bitButton routerLink="create" *ngIf="manageOrganizations" buttonType="primary">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newClient" | i18n }}
@@ -24,63 +28,55 @@
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container
*ngIf="!loading && (clients | search: searchText : 'organizationName' : 'id') as searchedClients"
>
<p *ngIf="!searchedClients.length">{{ "noClientsInList" | i18n }}</p>
<ng-container *ngIf="searchedClients.length">
<table
class="table table-hover table-list"
infiniteScroll
[infiniteScrollDistance]="1"
[infiniteScrollDisabled]="!isPaging()"
(scrolled)="loadMore()"
<ng-container *ngIf="!loading">
<p *ngIf="dataSource.data.length < 1">{{ "noClientsInList" | i18n }}</p>
<ng-container *ngIf="dataSource.data.length >= 1">
<bit-table-scroll
[dataSource]="dataSource"
[rowSize]="53"
class="tw-table tw-w-full table-hover table-list"
>
<thead>
<tr>
<th colspan="2">{{ "name" | i18n }}</th>
<th>{{ "numberOfUsers" | i18n }}</th>
<th>{{ "billingPlan" | i18n }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let o of searchedClients">
<td width="30">
<bit-avatar [text]="o.organizationName" [id]="o.id" size="small"></bit-avatar>
</td>
<td>
<a [routerLink]="['/organizations', o.organizationId]">{{ o.organizationName }}</a>
</td>
<td>
<span>{{ o.userCount }}</span>
<span *ngIf="o.seats != null"> / {{ o.seats }}</span>
</td>
<td>
<span>{{ o.plan }}</span>
</td>
<td class="table-list-options" *ngIf="manageOrganizations">
<div class="dropdown" appListDropdown>
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(o)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</a>
</div>
<ng-container header>
<th bitCell colspan="2" bitSortable="organizationName">{{ "name" | i18n }}</th>
<th bitCell bitSortable="seats">{{ "numberOfUsers" | i18n }}</th>
<th bitCell bitSortable="plan">{{ "billingPlan" | i18n }}</th>
<th bitCell></th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell width="30">
<bit-avatar [text]="row.organizationName" [id]="row.id" size="small"></bit-avatar>
</td>
<td bitCell width="325">
<a [routerLink]="['/organizations', row.organizationId]">{{ row.organizationName }}</a>
</td>
<td bitCell>
<span>{{ row.userCount }}</span>
<span *ngIf="row.seats != null"> / {{ row.seats }}</span>
</td>
<td bitCell width="150">
<span>{{ row.plan }}</span>
</td>
<td class="table-list-options" *ngIf="manageOrganizations">
<div class="dropdown" appListDropdown>
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(row)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</ng-template>
</bit-table-scroll>
</ng-container>
</ng-container>

View File

@@ -1,26 +1,35 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom, from, map } from "rxjs";
import { switchMap, takeUntil } from "rxjs/operators";
import { debounceTime, first, switchMap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
import { PlanType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { DialogService, ToastService } from "@bitwarden/components";
import {
AvatarModule,
DialogService,
TableDataSource,
TableModule,
ToastService,
} from "@bitwarden/components";
import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { WebProviderService } from "../services/web-provider.service";
import { AddOrganizationComponent } from "./add-organization.component";
import { BaseClientsComponent } from "./base-clients.component";
const DisallowedPlanTypes = [
PlanType.Free,
@@ -32,13 +41,26 @@ const DisallowedPlanTypes = [
@Component({
templateUrl: "clients.component.html",
standalone: true,
imports: [
SharedOrganizationModule,
HeaderModule,
CommonModule,
JslibModule,
AvatarModule,
RouterModule,
TableModule,
],
})
export class ClientsComponent extends BaseClientsComponent implements OnInit, OnDestroy {
providerId: string;
addableOrganizations: Organization[];
export class ClientsComponent {
providerId: string = "";
addableOrganizations: Organization[] = [];
loading = true;
manageOrganizations = false;
showAddExisting = false;
dataSource: TableDataSource<ProviderOrganizationOrganizationDetailsResponse> =
new TableDataSource();
protected searchControl = new FormControl("", { nonNullable: true });
constructor(
private router: Router,
@@ -46,28 +68,19 @@ export class ClientsComponent extends BaseClientsComponent implements OnInit, On
private apiService: ApiService,
private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction,
activatedRoute: ActivatedRoute,
dialogService: DialogService,
i18nService: I18nService,
searchService: SearchService,
toastService: ToastService,
validationService: ValidationService,
webProviderService: WebProviderService,
private activatedRoute: ActivatedRoute,
private dialogService: DialogService,
private i18nService: I18nService,
private toastService: ToastService,
private validationService: ValidationService,
private webProviderService: WebProviderService,
) {
super(
activatedRoute,
dialogService,
i18nService,
searchService,
toastService,
validationService,
webProviderService,
);
}
this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => {
this.searchControl.setValue(queryParams.search);
});
ngOnInit() {
this.activatedRoute.parent.params
.pipe(
this.activatedRoute.parent?.params
?.pipe(
switchMap((params) => {
this.providerId = params.providerId;
return this.providerService.get$(this.providerId).pipe(
@@ -85,18 +98,46 @@ export class ClientsComponent extends BaseClientsComponent implements OnInit, On
}),
);
}),
takeUntil(this.destroy$),
takeUntilDestroyed(),
)
.subscribe();
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
.subscribe((searchText) => {
this.dataSource.filter = (data) =>
data.organizationName.toLowerCase().indexOf(searchText.toLowerCase()) > -1;
});
}
ngOnDestroy() {
super.ngOnDestroy();
async remove(organization: ProviderOrganizationOrganizationDetailsResponse) {
const confirmed = await this.dialogService.openSimpleDialog({
title: organization.organizationName,
content: { key: "detachOrganizationConfirmation" },
type: "warning",
});
if (!confirmed) {
return;
}
try {
await this.webProviderService.detachOrganization(this.providerId, organization.id);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("detachedOrganization", organization.organizationName),
});
await this.load();
} catch (e) {
this.validationService.showError(e);
}
}
async load() {
const response = await this.apiService.getProviderClients(this.providerId);
this.clients = response.data != null && response.data.length > 0 ? response.data : [];
const clients = response.data != null && response.data.length > 0 ? response.data : [];
this.dataSource.data = clients;
this.manageOrganizations =
(await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin;
const candidateOrgs = (await this.organizationService.getAll()).filter(

View File

@@ -1,82 +0,0 @@
<app-header>
<bit-search
class="tw-grow"
[formControl]="searchControl"
[placeholder]="'search' | i18n"
></bit-search>
<a bitButton routerLink="create" *ngIf="manageOrganizations" buttonType="primary">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "newClient" | i18n }}
</a>
<button
type="button"
bitButton
(click)="addExistingOrganization()"
*ngIf="manageOrganizations && showAddExisting"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addExistingOrganization" | i18n }}
</button>
</app-header>
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<ng-container *ngIf="!loading">
<p *ngIf="dataSource.data.length < 1">{{ "noClientsInList" | i18n }}</p>
<ng-container *ngIf="dataSource.data.length >= 1">
<bit-table-scroll
[dataSource]="dataSource"
[rowSize]="53"
class="tw-table tw-w-full table-hover table-list"
>
<ng-container header>
<th bitCell colspan="2" bitSortable="organizationName">{{ "name" | i18n }}</th>
<th bitCell bitSortable="seats">{{ "numberOfUsers" | i18n }}</th>
<th bitCell bitSortable="plan">{{ "billingPlan" | i18n }}</th>
<th bitCell></th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell width="30">
<bit-avatar [text]="row.organizationName" [id]="row.id" size="small"></bit-avatar>
</td>
<td bitCell width="325">
<a [routerLink]="['/organizations', row.organizationId]">{{ row.organizationName }}</a>
</td>
<td bitCell>
<span>{{ row.userCount }}</span>
<span *ngIf="row.seats != null"> / {{ row.seats }}</span>
</td>
<td bitCell width="150">
<span>{{ row.plan }}</span>
</td>
<td class="table-list-options" *ngIf="manageOrganizations">
<div class="dropdown" appListDropdown>
<button
class="btn btn-outline-secondary dropdown-toggle"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
appA11yTitle="{{ 'options' | i18n }}"
>
<i class="bwi bwi-cog bwi-lg" aria-hidden="true"></i>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item text-danger" href="#" appStopClick (click)="remove(row)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "remove" | i18n }}
</a>
</div>
</div>
</td>
</ng-template>
</bit-table-scroll>
</ng-container>
</ng-container>

View File

@@ -1,167 +0,0 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl } from "@angular/forms";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom, from, map } from "rxjs";
import { debounceTime, first, switchMap } from "rxjs/operators";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
import { PlanType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import {
AvatarModule,
DialogService,
TableDataSource,
TableModule,
ToastService,
} from "@bitwarden/components";
import { SharedOrganizationModule } from "@bitwarden/web-vault/app/admin-console/organizations/shared";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { WebProviderService } from "../services/web-provider.service";
import { AddOrganizationComponent } from "./add-organization.component";
const DisallowedPlanTypes = [
PlanType.Free,
PlanType.FamiliesAnnually2019,
PlanType.FamiliesAnnually,
PlanType.TeamsStarter2023,
PlanType.TeamsStarter,
];
@Component({
templateUrl: "vnext-clients.component.html",
standalone: true,
imports: [
SharedOrganizationModule,
HeaderModule,
CommonModule,
JslibModule,
AvatarModule,
RouterModule,
TableModule,
],
})
export class vNextClientsComponent {
providerId: string = "";
addableOrganizations: Organization[] = [];
loading = true;
manageOrganizations = false;
showAddExisting = false;
dataSource: TableDataSource<ProviderOrganizationOrganizationDetailsResponse> =
new TableDataSource();
protected searchControl = new FormControl("", { nonNullable: true });
constructor(
private router: Router,
private providerService: ProviderService,
private apiService: ApiService,
private organizationService: OrganizationService,
private organizationApiService: OrganizationApiServiceAbstraction,
private activatedRoute: ActivatedRoute,
private dialogService: DialogService,
private i18nService: I18nService,
private toastService: ToastService,
private validationService: ValidationService,
private webProviderService: WebProviderService,
) {
this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => {
this.searchControl.setValue(queryParams.search);
});
this.activatedRoute.parent?.params
?.pipe(
switchMap((params) => {
this.providerId = params.providerId;
return this.providerService.get$(this.providerId).pipe(
map((provider) => provider?.providerStatus === ProviderStatusType.Billable),
map((isBillable) => {
if (isBillable) {
return from(
this.router.navigate(["../manage-client-organizations"], {
relativeTo: this.activatedRoute,
}),
);
} else {
return from(this.load());
}
}),
);
}),
takeUntilDestroyed(),
)
.subscribe();
this.searchControl.valueChanges
.pipe(debounceTime(200), takeUntilDestroyed())
.subscribe((searchText) => {
this.dataSource.filter = (data) =>
data.organizationName.toLowerCase().indexOf(searchText.toLowerCase()) > -1;
});
}
async remove(organization: ProviderOrganizationOrganizationDetailsResponse) {
const confirmed = await this.dialogService.openSimpleDialog({
title: organization.organizationName,
content: { key: "detachOrganizationConfirmation" },
type: "warning",
});
if (!confirmed) {
return;
}
try {
await this.webProviderService.detachOrganization(this.providerId, organization.id);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("detachedOrganization", organization.organizationName),
});
await this.load();
} catch (e) {
this.validationService.showError(e);
}
}
async load() {
const response = await this.apiService.getProviderClients(this.providerId);
const clients = response.data != null && response.data.length > 0 ? response.data : [];
this.dataSource.data = clients;
this.manageOrganizations =
(await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin;
const candidateOrgs = (await this.organizationService.getAll()).filter(
(o) => o.isOwner && o.providerId == null,
);
const allowedOrgsIds = await Promise.all(
candidateOrgs.map((o) => this.organizationApiService.get(o.id)),
).then((orgs) =>
orgs.filter((o) => !DisallowedPlanTypes.includes(o.planType)).map((o) => o.id),
);
this.addableOrganizations = candidateOrgs.filter((o) => allowedOrgsIds.includes(o.id));
this.showAddExisting = this.addableOrganizations.length !== 0;
this.loading = false;
}
async addExistingOrganization() {
const dialogRef = AddOrganizationComponent.open(this.dialogService, {
providerId: this.providerId,
organizations: this.addableOrganizations,
});
if (await firstValueFrom(dialogRef.closed)) {
await this.load();
}
}
}

View File

@@ -2,10 +2,8 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { authGuard } from "@bitwarden/angular/auth/guards";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { AnonLayoutWrapperComponent } from "@bitwarden/auth/angular";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { FrontendLayoutComponent } from "@bitwarden/web-vault/app/layouts/frontend-layout.component";
import { UserLayoutComponent } from "@bitwarden/web-vault/app/layouts/user-layout.component";
@@ -14,12 +12,10 @@ import {
ProviderSubscriptionComponent,
hasConsolidatedBilling,
ProviderBillingHistoryComponent,
vNextManageClientsComponent,
} from "../../billing/providers";
import { ClientsComponent } from "./clients/clients.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component";
import { vNextClientsComponent } from "./clients/vnext-clients.component";
import { providerPermissionsGuard } from "./guards/provider-permissions.guard";
import { AcceptProviderComponent } from "./manage/accept-provider.component";
import { EventsComponent } from "./manage/events.component";
@@ -86,25 +82,13 @@ const routes: Routes = [
children: [
{ path: "", pathMatch: "full", redirectTo: "clients" },
{ path: "clients/create", component: CreateOrganizationComponent },
...featureFlaggedRoute({
defaultComponent: ClientsComponent,
flaggedComponent: vNextClientsComponent,
featureFlag: FeatureFlag.PM12443RemovePagingLogic,
routeOptions: {
path: "clients",
data: { titleId: "clients" },
},
}),
...featureFlaggedRoute({
defaultComponent: ManageClientsComponent,
flaggedComponent: vNextManageClientsComponent,
featureFlag: FeatureFlag.PM12443RemovePagingLogic,
routeOptions: {
path: "manage-client-organizations",
data: { titleId: "clients" },
canActivate: [hasConsolidatedBilling],
},
}),
{ path: "clients", component: ClientsComponent, data: { titleId: "clients" } },
{
path: "manage-client-organizations",
canActivate: [hasConsolidatedBilling],
component: ManageClientsComponent,
data: { titleId: "clients" },
},
{
path: "manage",
children: [

View File

@@ -11,9 +11,7 @@ import { OssModule } from "@bitwarden/web-vault/app/oss.module";
import {
CreateClientDialogComponent,
NoClientsComponent,
ManageClientNameDialogComponent,
ManageClientsComponent,
ManageClientSubscriptionDialogComponent,
ProviderBillingHistoryComponent,
ProviderSubscriptionComponent,
@@ -21,7 +19,6 @@ import {
} from "../../billing/providers";
import { AddOrganizationComponent } from "./clients/add-organization.component";
import { ClientsComponent } from "./clients/clients.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component";
import { AcceptProviderComponent } from "./manage/accept-provider.component";
import { AddEditMemberDialogComponent } from "./manage/dialogs/add-edit-member-dialog.component";
@@ -59,7 +56,6 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
AddOrganizationComponent,
BulkConfirmDialogComponent,
BulkRemoveDialogComponent,
ClientsComponent,
CreateOrganizationComponent,
EventsComponent,
MembersComponent,
@@ -68,8 +64,6 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
UserAddEditComponent,
AddEditMemberDialogComponent,
CreateClientDialogComponent,
NoClientsComponent,
ManageClientsComponent,
ManageClientNameDialogComponent,
ManageClientSubscriptionDialogComponent,
ProviderBillingHistoryComponent,