mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
[PM-18870] Convert Organization to Business Unit (#14131)
* Add setupBusinessUnit to OrganizationBillingApiService * Add setup-business-unit.component * Updated designs and cleanup work * Update existing logos for Provider Portal and Admin Console * Fix broken test
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
<app-layout>
|
||||
<app-side-nav variant="secondary" *ngIf="provider$ | async as provider">
|
||||
<bit-nav-logo [openIcon]="logo" route="." [label]="'providerPortal' | i18n"></bit-nav-logo>
|
||||
<bit-nav-logo
|
||||
[openIcon]="logo$ | async"
|
||||
route="."
|
||||
[label]="'providerPortal' | i18n"
|
||||
></bit-nav-logo>
|
||||
|
||||
<bit-nav-item
|
||||
icon="bwi-provider"
|
||||
[text]="'clients' | i18n"
|
||||
[text]="clientsTranslationKey$ | async | i18n"
|
||||
[route]="(isBillable | async) ? 'manage-client-organizations' : 'clients'"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
|
||||
@@ -8,9 +8,10 @@ import { takeUntil } from "rxjs/operators";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { ProviderStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { ProviderStatusType, ProviderType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { IconModule } from "@bitwarden/components";
|
||||
import { Icon, IconModule } from "@bitwarden/components";
|
||||
import { BusinessUnitPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/business-unit-portal-logo.icon";
|
||||
import { ProviderPortalLogo } from "@bitwarden/web-vault/app/admin-console/icons/provider-portal-logo";
|
||||
import { WebLayoutModule } from "@bitwarden/web-vault/app/layouts/web-layout.module";
|
||||
|
||||
@@ -26,9 +27,13 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
protected provider$: Observable<Provider>;
|
||||
|
||||
protected logo$: Observable<Icon>;
|
||||
|
||||
protected isBillable: Observable<boolean>;
|
||||
protected canAccessBilling$: Observable<boolean>;
|
||||
|
||||
protected clientsTranslationKey$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private providerService: ProviderService,
|
||||
@@ -42,16 +47,28 @@ export class ProvidersLayoutComponent implements OnInit, OnDestroy {
|
||||
takeUntil(this.destroy$),
|
||||
);
|
||||
|
||||
this.logo$ = this.provider$.pipe(
|
||||
map((provider) =>
|
||||
provider.providerType === ProviderType.BusinessUnit
|
||||
? BusinessUnitPortalLogo
|
||||
: ProviderPortalLogo,
|
||||
),
|
||||
);
|
||||
|
||||
this.isBillable = this.provider$.pipe(
|
||||
map((provider) => provider?.providerStatus === ProviderStatusType.Billable),
|
||||
takeUntil(this.destroy$),
|
||||
);
|
||||
|
||||
this.canAccessBilling$ = combineLatest([this.isBillable, this.provider$]).pipe(
|
||||
map(
|
||||
([hasConsolidatedBilling, provider]) => hasConsolidatedBilling && provider.isProviderAdmin,
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
);
|
||||
|
||||
this.clientsTranslationKey$ = this.provider$.pipe(
|
||||
map((provider) =>
|
||||
provider.providerType === ProviderType.BusinessUnit ? "businessUnits" : "clients",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
hasConsolidatedBilling,
|
||||
ProviderBillingHistoryComponent,
|
||||
} from "../../billing/providers";
|
||||
import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component";
|
||||
|
||||
import { ClientsComponent } from "./clients/clients.component";
|
||||
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||
@@ -49,6 +50,11 @@ const routes: Routes = [
|
||||
component: SetupProviderComponent,
|
||||
data: { titleId: "setupProvider" },
|
||||
},
|
||||
{
|
||||
path: "setup-business-unit",
|
||||
component: SetupBusinessUnitComponent,
|
||||
data: { titleId: "setupProvider" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ProviderSubscriptionStatusComponent,
|
||||
} from "../../billing/providers";
|
||||
import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component";
|
||||
import { SetupBusinessUnitComponent } from "../../billing/providers/setup/setup-business-unit.component";
|
||||
|
||||
import { AddOrganizationComponent } from "./clients/add-organization.component";
|
||||
import { CreateOrganizationComponent } from "./clients/create-organization.component";
|
||||
@@ -75,6 +76,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
|
||||
ProviderSubscriptionStatusComponent,
|
||||
ProvidersComponent,
|
||||
VerifyRecoverDeleteProviderComponent,
|
||||
SetupBusinessUnitComponent,
|
||||
],
|
||||
providers: [WebProviderService],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<app-header>
|
||||
<app-header [title]="pageTitle">
|
||||
<bit-search [placeholder]="'search' | i18n" [formControl]="searchControl"></bit-search>
|
||||
<ng-container *ngIf="addExistingOrgsFromProviderPortal$ | async; else addExistingOrgsDisabled">
|
||||
<button
|
||||
@@ -14,7 +14,7 @@
|
||||
<bit-menu #clientMenu>
|
||||
<button type="button" bitMenuItem (click)="createClient()">
|
||||
<i aria-hidden="true" class="bwi bwi-business"></i>
|
||||
{{ "newClient" | i18n }}
|
||||
{{ newClientButtonLabel }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="addExistingOrganization()">
|
||||
<i aria-hidden="true" class="bwi bwi-filter"></i>
|
||||
@@ -48,7 +48,7 @@
|
||||
<ng-container *ngIf="!loading">
|
||||
<bit-table-scroll [dataSource]="dataSource" [rowSize]="53" class="tw-overflow-hidden">
|
||||
<ng-container header>
|
||||
<th colspan="2" bitCell bitSortable="organizationName" default>{{ "client" | i18n }}</th>
|
||||
<th colspan="2" bitCell bitSortable="organizationName" default>{{ clientColumnHeader }}</th>
|
||||
<th bitCell bitSortable="seats">{{ "assigned" | i18n }}</th>
|
||||
<th bitCell bitSortable="occupiedSeats">{{ "used" | i18n }}</th>
|
||||
<th bitCell bitSortable="remainingSeats">{{ "remaining" | i18n }}</th>
|
||||
|
||||
@@ -6,7 +6,11 @@ import { firstValueFrom, from, lastValueFrom, map } from "rxjs";
|
||||
import { debounceTime, first, switchMap } from "rxjs/operators";
|
||||
|
||||
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
|
||||
import { ProviderStatusType, ProviderUserType } from "@bitwarden/common/admin-console/enums";
|
||||
import {
|
||||
ProviderStatusType,
|
||||
ProviderType,
|
||||
ProviderUserType,
|
||||
} from "@bitwarden/common/admin-console/enums";
|
||||
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
|
||||
import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
@@ -73,6 +77,10 @@ export class ManageClientsComponent {
|
||||
FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal,
|
||||
);
|
||||
|
||||
pageTitle = this.i18nService.t("clients");
|
||||
clientColumnHeader = this.i18nService.t("client");
|
||||
newClientButtonLabel = this.i18nService.t("newClient");
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private providerService: ProviderService,
|
||||
@@ -124,6 +132,11 @@ export class ManageClientsComponent {
|
||||
async load() {
|
||||
try {
|
||||
this.provider = await firstValueFrom(this.providerService.get$(this.providerId));
|
||||
if (this.provider?.providerType === ProviderType.BusinessUnit) {
|
||||
this.pageTitle = this.i18nService.t("businessUnits");
|
||||
this.clientColumnHeader = this.i18nService.t("businessUnit");
|
||||
this.newClientButtonLabel = this.i18nService.t("newBusinessUnit");
|
||||
}
|
||||
this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin;
|
||||
this.dataSource.data = (
|
||||
await this.billingApiService.getProviderClientOrganizations(this.providerId)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="tw-mt-12 tw-flex tw-justify-center" *ngIf="loading">
|
||||
<div>
|
||||
<bit-icon class="tw-w-72 tw-block tw-mb-4" [icon]="bitwardenLogo"></bit-icon>
|
||||
<p class="tw-text-center">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-row tw-justify-center tw-mt-12" *ngIf="!loading && !authed">
|
||||
<div class="tw-w-[400px] tw-mt-5">
|
||||
<h2 class="tw-flex tw-justify-center tw-mb-4">{{ "setupProvider" | i18n }}</h2>
|
||||
<bit-card>
|
||||
<p>{{ "setupProviderLoginDesc" | i18n }}</p>
|
||||
<hr />
|
||||
<button bitButton type="button" [block]="true" (click)="login()" buttonType="primary">
|
||||
{{ "logIn" | i18n }}
|
||||
</button>
|
||||
</bit-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ActivatedRoute, Params, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { filter, map, switchMap } from "rxjs/operators";
|
||||
|
||||
import { BitwardenLogo } from "@bitwarden/auth/angular";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { ProviderKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { BillingNotificationService } from "@bitwarden/web-vault/app/billing/services/billing-notification.service";
|
||||
import { BaseAcceptComponent } from "@bitwarden/web-vault/app/common/base.accept.component";
|
||||
|
||||
@Component({
|
||||
templateUrl: "./setup-business-unit.component.html",
|
||||
})
|
||||
export class SetupBusinessUnitComponent extends BaseAcceptComponent {
|
||||
protected bitwardenLogo = BitwardenLogo;
|
||||
|
||||
failedMessage = "emergencyInviteAcceptFailed";
|
||||
failedShortMessage = "emergencyInviteAcceptFailedShort";
|
||||
requiredParameters = ["organizationId", "email", "token"];
|
||||
|
||||
constructor(
|
||||
activatedRoute: ActivatedRoute,
|
||||
authService: AuthService,
|
||||
private billingNotificationService: BillingNotificationService,
|
||||
private encryptService: EncryptService,
|
||||
i18nService: I18nService,
|
||||
private keyService: KeyService,
|
||||
private organizationBillingApiService: OrganizationBillingApiServiceAbstraction,
|
||||
platformUtilsService: PlatformUtilsService,
|
||||
router: Router,
|
||||
private stateProvider: StateProvider,
|
||||
private syncService: SyncService,
|
||||
) {
|
||||
super(router, platformUtilsService, i18nService, activatedRoute, authService);
|
||||
}
|
||||
|
||||
async authedHandler(queryParams: Params) {
|
||||
await this.process(queryParams);
|
||||
}
|
||||
|
||||
async unauthedHandler(_: Params) {}
|
||||
|
||||
async login() {
|
||||
await this.router.navigate(["/login"], { queryParams: { email: this.email } });
|
||||
}
|
||||
|
||||
process = async (queryParams: Params): Promise<boolean> => {
|
||||
const fail = async () => {
|
||||
this.billingNotificationService.showError(this.i18nService.t(this.failedMessage));
|
||||
return await this.router.navigate(["/"]);
|
||||
};
|
||||
|
||||
const organizationId = queryParams.organizationId as string;
|
||||
const token = queryParams.token as string;
|
||||
|
||||
if (!organizationId || !token) {
|
||||
return await fail();
|
||||
}
|
||||
|
||||
const activeUserId$ = this.stateProvider.activeUserId$.pipe(
|
||||
filter((userId): userId is NonNullable<typeof userId> => userId != null),
|
||||
);
|
||||
|
||||
const organizationKey$ = activeUserId$.pipe(
|
||||
switchMap((userId) => this.keyService.orgKeys$(userId)),
|
||||
filter(
|
||||
(organizationKeysById): organizationKeysById is NonNullable<typeof organizationKeysById> =>
|
||||
organizationKeysById != null && organizationId in organizationKeysById,
|
||||
),
|
||||
map((organizationKeysById) => organizationKeysById[organizationId as OrganizationId]),
|
||||
);
|
||||
|
||||
const [{ encryptedString: encryptedProviderKey }, providerKey] =
|
||||
await this.keyService.makeOrgKey<ProviderKey>();
|
||||
|
||||
const organizationKey = await firstValueFrom(organizationKey$);
|
||||
|
||||
const { encryptedString: encryptedOrganizationKey } = await this.encryptService.encrypt(
|
||||
organizationKey.key,
|
||||
providerKey,
|
||||
);
|
||||
|
||||
if (!encryptedProviderKey || !encryptedOrganizationKey) {
|
||||
return await fail();
|
||||
}
|
||||
|
||||
const userId = await firstValueFrom(activeUserId$);
|
||||
|
||||
const request = {
|
||||
userId,
|
||||
token,
|
||||
providerKey: encryptedProviderKey,
|
||||
organizationKey: encryptedOrganizationKey,
|
||||
};
|
||||
|
||||
try {
|
||||
const providerId = await this.organizationBillingApiService.setupBusinessUnit(
|
||||
organizationId,
|
||||
request,
|
||||
);
|
||||
await this.syncService.fullSync(true);
|
||||
this.billingNotificationService.showSuccess(this.i18nService.t("providerSetup"));
|
||||
return await this.router.navigate(["/providers", providerId]);
|
||||
} catch (error) {
|
||||
this.billingNotificationService.handleError(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -39,8 +39,8 @@ export class ProviderSubscriptionStatusComponent {
|
||||
switch (this.subscription.providerType) {
|
||||
case ProviderType.Msp:
|
||||
return "managedServiceProvider";
|
||||
case ProviderType.MultiOrganizationEnterprise:
|
||||
return "multiOrganizationEnterprise";
|
||||
case ProviderType.BusinessUnit:
|
||||
return "businessUnit";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,18 @@ export class ProviderSubscriptionStatusComponent {
|
||||
},
|
||||
};
|
||||
}
|
||||
case "trialing": {
|
||||
return {
|
||||
status: {
|
||||
label: defaultStatusLabel,
|
||||
value: this.i18nService.t("trial"),
|
||||
},
|
||||
date: {
|
||||
label: nextChargeDateLabel,
|
||||
value: this.subscription.currentPeriodEndDate,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "past_due": {
|
||||
const pastDueText = this.i18nService.t("pastDue");
|
||||
const suspensionDate = this.datePipe.transform(
|
||||
|
||||
Reference in New Issue
Block a user