1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 22:03:36 +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:
Alex Morask
2025-04-10 10:06:23 -04:00
committed by GitHub
parent 5a1b0744f0
commit eea0bb6d6e
23 changed files with 380 additions and 27 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -32,4 +32,12 @@ export class BillingNotificationService {
message: message,
});
}
showError(message: string, title: string = "") {
this.toastService.showToast({
variant: "error",
title,
message,
});
}
}

View File

@@ -19,6 +19,7 @@ import {
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -153,6 +154,11 @@ export class ProductSwitcherService {
// TODO: This should be migrated to an Observable provided by the provider service and moved to the combineLatest above. See AC-2092.
const providers = await this.providerService.getAll();
const providerPortalName =
providers[0]?.providerType === ProviderType.BusinessUnit
? "Business Unit Portal"
: "Provider Portal";
const orgsMarketingRoute = this.platformUtilsService.isSelfHost()
? {
route: "https://bitwarden.com/products/business/",
@@ -201,7 +207,7 @@ export class ProductSwitcherService {
isActive: this.router.url.includes("/organizations/"),
},
provider: {
name: "Provider Portal",
name: providerPortalName,
icon: "bwi-provider",
appRoute: ["/providers", providers[0]?.id],
isActive: this.router.url.includes("/providers/"),

View File

@@ -10616,5 +10616,14 @@
},
"cannotCreateCollection": {
"message": "Free organizations may have up to 2 collections. Upgrade to a paid plan to add more collections."
},
"businessUnit": {
"message": "Business Unit"
},
"businessUnits": {
"message": "Business Units"
},
"newBusinessUnit": {
"message": "New business unit"
}
}

View File

@@ -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

View File

@@ -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",
),
);
}

View File

@@ -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" },
},
],
},
{

View File

@@ -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],
})

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>

View File

@@ -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;
}
};
}

View File

@@ -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(

View File

@@ -1,5 +1,5 @@
export enum ProviderType {
Msp = 0,
Reseller = 1,
MultiOrganizationEnterprise = 2,
BusinessUnit = 2,
}

View File

@@ -1,4 +1,9 @@
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums";
import {
ProviderStatusType,
ProviderType,
ProviderUserStatusType,
ProviderUserType,
} from "../../enums";
import { ProfileProviderResponse } from "../response/profile-provider.response";
export class ProviderData {
@@ -10,6 +15,7 @@ export class ProviderData {
userId: string;
useEvents: boolean;
providerStatus: ProviderStatusType;
providerType: ProviderType;
constructor(response: ProfileProviderResponse) {
this.id = response.id;
@@ -20,5 +26,6 @@ export class ProviderData {
this.userId = response.userId;
this.useEvents = response.useEvents;
this.providerStatus = response.providerStatus;
this.providerType = response.providerType;
}
}

View File

@@ -331,8 +331,7 @@ export class Organization {
get hasBillableProvider() {
return (
this.hasProvider &&
(this.providerType === ProviderType.Msp ||
this.providerType === ProviderType.MultiOrganizationEnterprise)
(this.providerType === ProviderType.Msp || this.providerType === ProviderType.BusinessUnit)
);
}

View File

@@ -1,6 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums";
import {
ProviderStatusType,
ProviderType,
ProviderUserStatusType,
ProviderUserType,
} from "../../enums";
import { ProviderData } from "../data/provider.data";
export class Provider {
@@ -12,6 +17,7 @@ export class Provider {
userId: string;
useEvents: boolean;
providerStatus: ProviderStatusType;
providerType: ProviderType;
constructor(obj?: ProviderData) {
if (obj == null) {
@@ -26,6 +32,7 @@ export class Provider {
this.userId = obj.userId;
this.useEvents = obj.useEvents;
this.providerStatus = obj.providerStatus;
this.providerType = obj.providerType;
}
get canAccess() {

View File

@@ -1,5 +1,10 @@
import { BaseResponse } from "../../../models/response/base.response";
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums";
import {
ProviderStatusType,
ProviderType,
ProviderUserStatusType,
ProviderUserType,
} from "../../enums";
import { PermissionsApi } from "../api/permissions.api";
export class ProfileProviderResponse extends BaseResponse {
@@ -13,6 +18,7 @@ export class ProfileProviderResponse extends BaseResponse {
userId: string;
useEvents: boolean;
providerStatus: ProviderStatusType;
providerType: ProviderType;
constructor(response: any) {
super(response);
@@ -26,5 +32,6 @@ export class ProfileProviderResponse extends BaseResponse {
this.userId = this.getResponseProperty("UserId");
this.useEvents = this.getResponseProperty("UseEvents");
this.providerStatus = this.getResponseProperty("ProviderStatus");
this.providerType = this.getResponseProperty("ProviderType");
}
}

View File

@@ -4,7 +4,12 @@ import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from ".
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../enums";
import {
ProviderStatusType,
ProviderType,
ProviderUserStatusType,
ProviderUserType,
} from "../enums";
import { ProviderData } from "../models/data/provider.data";
import { Provider } from "../models/domain/provider";
@@ -67,6 +72,7 @@ describe("PROVIDERS key definition", () => {
userId: "string",
useEvents: true,
providerStatus: ProviderStatusType.Pending,
providerType: ProviderType.Msp,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));

View File

@@ -1,19 +1,27 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
BillingInvoiceResponse,
BillingTransactionResponse,
} from "../../models/response/billing.response";
export class OrganizationBillingApiServiceAbstraction {
getBillingInvoices: (
export abstract class OrganizationBillingApiServiceAbstraction {
abstract getBillingInvoices: (
id: string,
status?: string,
startAfter?: string,
) => Promise<BillingInvoiceResponse[]>;
getBillingTransactions: (
abstract getBillingTransactions: (
id: string,
startAfter?: string,
) => Promise<BillingTransactionResponse[]>;
abstract setupBusinessUnit: (
id: string,
request: {
userId: string;
token: string;
providerKey: string;
organizationKey: string;
},
) => Promise<string>;
}

View File

@@ -49,4 +49,24 @@ export class OrganizationBillingApiService implements OrganizationBillingApiServ
);
return r?.map((i: any) => new BillingTransactionResponse(i)) || [];
}
async setupBusinessUnit(
id: string,
request: {
userId: string;
token: string;
providerKey: string;
organizationKey: string;
},
): Promise<string> {
const response = await this.apiService.send(
"POST",
`/organizations/${id}/billing/setup-business-unit`,
request,
true,
true,
);
return response as string;
}
}