1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[PM-15179] Implement add-existing-organization-dialog.component (#13010)

* Implement add-existing-organization-dialog.component

* Add missing button type

* Thomas' feedback

* Import order issue
This commit is contained in:
Alex Morask
2025-02-04 09:02:12 -05:00
committed by GitHub
parent 72434bfa77
commit cf7a174d11
11 changed files with 332 additions and 12 deletions

View File

@@ -10346,5 +10346,32 @@
"example": "10"
}
}
},
"existingOrganization": {
"message": "Existing organization"
},
"selectOrganizationProviderPortal": {
"message": "Select an organization to add to your Provider Portal."
},
"noOrganizations": {
"message": "There are no organizations to list"
},
"yourProviderSubscriptionCredit": {
"message": "Your provider subscription will receive a credit for any remaining time in the organization's subscription."
},
"doYouWantToAddThisOrg": {
"message": "Do you want to add this organization to $PROVIDER$?",
"placeholders": {
"provider": {
"content": "$1",
"example": "Cool MSP"
}
}
},
"addedExistingOrganization": {
"message": "Added existing organization"
},
"assignedExceedsAvailable": {
"message": "Assigned seats exceed available seats."
}
}

View File

@@ -17,6 +17,7 @@ import {
ProviderSubscriptionComponent,
ProviderSubscriptionStatusComponent,
} from "../../billing/providers";
import { AddExistingOrganizationDialogComponent } from "../../billing/providers/clients/add-existing-organization-dialog.component";
import { AddOrganizationComponent } from "./clients/add-organization.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component";
@@ -63,6 +64,7 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
SetupProviderComponent,
UserAddEditComponent,
AddEditMemberDialogComponent,
AddExistingOrganizationDialogComponent,
CreateClientDialogComponent,
ManageClientNameDialogComponent,
ManageClientSubscriptionDialogComponent,

View File

@@ -1,8 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { switchMap } from "rxjs/operators";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { ProviderAddOrganizationRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-add-organization.request";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
@@ -10,6 +13,8 @@ import { PlanType } from "@bitwarden/common/billing/enums";
import { CreateClientOrganizationRequest } from "@bitwarden/common/billing/models/request/create-client-organization.request";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { KeyService } from "@bitwarden/key-management";
@@ -23,6 +28,8 @@ export class WebProviderService {
private i18nService: I18nService,
private encryptService: EncryptService,
private billingApiService: BillingApiServiceAbstraction,
private stateProvider: StateProvider,
private providerApiService: ProviderApiServiceAbstraction,
) {}
async addOrganizationToProvider(providerId: string, organizationId: string) {
@@ -40,6 +47,22 @@ export class WebProviderService {
return response;
}
async addOrganizationToProviderVNext(providerId: string, organizationId: string): Promise<void> {
const orgKey = await firstValueFrom(
this.stateProvider.activeUserId$.pipe(
switchMap((userId) => this.keyService.orgKeys$(userId)),
map((organizationKeysById) => organizationKeysById[organizationId as OrganizationId]),
),
);
const providerKey = await this.keyService.getProviderKey(providerId);
const encryptedOrgKey = await this.encryptService.encrypt(orgKey.key, providerKey);
await this.providerApiService.addOrganizationToProvider(providerId, {
key: encryptedOrgKey.encryptedString,
organizationId,
});
await this.syncService.fullSync(true);
}
async createClientOrganization(
providerId: string,
name: string,

View File

@@ -0,0 +1,73 @@
<bit-dialog [loading]="loading">
<span bitDialogTitle>
{{ "addExistingOrganization" | i18n }}
</span>
<ng-container bitDialogContent>
<ng-container *ngIf="!selectedOrganization; else organizationSelected">
<p>{{ "selectOrganizationProviderPortal" | i18n }}</p>
<bit-table>
<ng-container header>
<tr>
<th colspan="2" bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "assigned" | i18n }}</th>
</tr>
</ng-container>
<ng-template body>
<tr
bitRow
*ngFor="let addable of addableOrganizations"
[ngClass]="{ 'tw-text-muted': addable.disabled }"
>
<td bitCell class="tw-w-8">
<bit-avatar [text]="addable.name" [id]="addable.id" size="small"></bit-avatar>
</td>
<td bitCell>
{{ addable.name }}
<div *ngIf="addable.disabled" class="tw-text-xs">
{{ "assignedExceedsAvailable" | i18n }}
</div>
</td>
<td bitCell>{{ addable.seats }}</td>
<td bitCell class="tw-text-right">
<button
type="button"
bitButton
buttonType="secondary"
[disabled]="addable.disabled"
(click)="selectOrganization(addable.id)"
>
{{ "next" | i18n }}
</button>
</td>
</tr>
</ng-template>
</bit-table>
<p *ngIf="addableOrganizations.length === 0" class="tw-text-muted tw-mt-2">
{{ "noOrganizations" | i18n }}
</p>
</ng-container>
<ng-template #organizationSelected>
<p>{{ "yourProviderSubscriptionCredit" | i18n }}</p>
<p>{{ "doYouWantToAddThisOrg" | i18n: dialogParams.provider.name }}</p>
<div class="tw-flex tw-flex-col">
<div>{{ "organization" | i18n }}: {{ selectedOrganization.name }}</div>
<div>{{ "billingPlan" | i18n }}: {{ selectedOrganization.plan }}</div>
<div>{{ "assignedSeats" | i18n }}: {{ selectedOrganization.seats }}</div>
</div>
</ng-template>
</ng-container>
<ng-container bitDialogFooter>
<button
*ngIf="selectedOrganization"
type="button"
bitButton
buttonType="primary"
[bitAction]="addExistingOrganization"
>
{{ "addOrganization" | i18n }}
</button>
<button bitButton buttonType="secondary" type="button" [bitDialogClose]="ResultType.Closed">
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,82 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnInit } from "@angular/core";
import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
export type AddExistingOrganizationDialogParams = {
provider: Provider;
};
export enum AddExistingOrganizationDialogResultType {
Closed = "closed",
Submitted = "submitted",
}
@Component({
templateUrl: "./add-existing-organization-dialog.component.html",
})
export class AddExistingOrganizationDialogComponent implements OnInit {
protected loading: boolean = true;
addableOrganizations: AddableOrganizationResponse[] = [];
selectedOrganization?: AddableOrganizationResponse;
protected readonly ResultType = AddExistingOrganizationDialogResultType;
constructor(
@Inject(DIALOG_DATA) protected dialogParams: AddExistingOrganizationDialogParams,
private dialogRef: DialogRef<AddExistingOrganizationDialogResultType>,
private i18nService: I18nService,
private providerApiService: ProviderApiServiceAbstraction,
private toastService: ToastService,
private webProviderService: WebProviderService,
) {}
async ngOnInit() {
this.addableOrganizations = await this.providerApiService.getProviderAddableOrganizations(
this.dialogParams.provider.id,
);
this.loading = false;
}
addExistingOrganization = async (): Promise<void> => {
if (this.selectedOrganization) {
await this.webProviderService.addOrganizationToProviderVNext(
this.dialogParams.provider.id,
this.selectedOrganization.id,
);
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("addedExistingOrganization"),
});
this.dialogRef.close(this.ResultType.Submitted);
}
};
selectOrganization(organizationId: string) {
this.selectedOrganization = this.addableOrganizations.find(
(organization) => organization.id === organizationId,
);
}
static open = (
dialogService: DialogService,
dialogConfig: DialogConfig<
AddExistingOrganizationDialogParams,
DialogRef<AddExistingOrganizationDialogResultType>
>,
) =>
dialogService.open<
AddExistingOrganizationDialogResultType,
AddExistingOrganizationDialogParams
>(AddExistingOrganizationDialogComponent, dialogConfig);
}

View File

@@ -1,9 +1,39 @@
<app-header>
<bit-search [placeholder]="'search' | i18n" [formControl]="searchControl"></bit-search>
<a type="button" bitButton *ngIf="isProviderAdmin" buttonType="primary" (click)="createClient()">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addNewOrganization" | i18n }}
</a>
<ng-container *ngIf="addExistingOrgsFromProviderPortal$ | async; else addExistingOrgsDisabled">
<button
bitButton
buttonType="primary"
type="button"
[bitMenuTriggerFor]="clientMenu"
appA11yTitle="{{ 'add' | i18n }}"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "add" | i18n }}
</button>
<bit-menu #clientMenu>
<button type="button" bitMenuItem (click)="createClient()">
<i aria-hidden="true" class="bwi bwi-business"></i>
{{ "newClient" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addExistingOrganization()">
<i aria-hidden="true" class="bwi bwi-sitemap"></i>
{{ "existingOrganization" | i18n }}
</button>
</bit-menu>
</ng-container>
<ng-template #addExistingOrgsDisabled>
<a
type="button"
bitButton
*ngIf="isProviderAdmin"
buttonType="primary"
(click)="createClient()"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addNewOrganization" | i18n }}
</a>
</ng-template>
</app-header>
<ng-container *ngIf="loading">

View File

@@ -11,6 +11,8 @@ 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";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import {
@@ -25,6 +27,10 @@ import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.mod
import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service";
import {
AddExistingOrganizationDialogComponent,
AddExistingOrganizationDialogResultType,
} from "./add-existing-organization-dialog.component";
import {
CreateClientDialogResultType,
openCreateClientDialog,
@@ -62,6 +68,9 @@ export class ManageClientsComponent {
protected searchControl = new FormControl("", { nonNullable: true });
protected plans: PlanResponse[] = [];
protected addExistingOrgsFromProviderPortal$ = this.configService.getFeatureFlag$(
FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal,
);
constructor(
private billingApiService: BillingApiServiceAbstraction,
@@ -73,6 +82,7 @@ export class ManageClientsComponent {
private toastService: ToastService,
private validationService: ValidationService,
private webProviderService: WebProviderService,
private configService: ConfigService,
) {
this.activatedRoute.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((queryParams) => {
this.searchControl.setValue(queryParams.search);
@@ -111,19 +121,30 @@ export class ManageClientsComponent {
async load() {
this.provider = await firstValueFrom(this.providerService.get$(this.providerId));
this.isProviderAdmin = this.provider?.type === ProviderUserType.ProviderAdmin;
const clients = (await this.billingApiService.getProviderClientOrganizations(this.providerId))
.data;
this.dataSource.data = clients;
this.dataSource.data = (
await this.billingApiService.getProviderClientOrganizations(this.providerId)
).data;
this.plans = (await this.billingApiService.getPlans()).data;
this.loading = false;
}
addExistingOrganization = async () => {
if (this.provider) {
const reference = AddExistingOrganizationDialogComponent.open(this.dialogService, {
data: {
provider: this.provider,
},
});
const result = await lastValueFrom(reference.closed);
if (result === AddExistingOrganizationDialogResultType.Submitted) {
await this.load();
}
}
};
createClient = async () => {
const reference = openCreateClientDialog(this.dialogService, {
data: {

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
import { ProviderUpdateRequest } from "../../models/request/provider/provider-update.request";
import { ProviderVerifyRecoverDeleteRequest } from "../../models/request/provider/provider-verify-recover-delete.request";
@@ -14,4 +16,12 @@ export class ProviderApiServiceAbstraction {
request: ProviderVerifyRecoverDeleteRequest,
) => Promise<any>;
deleteProvider: (id: string) => Promise<void>;
getProviderAddableOrganizations: (providerId: string) => Promise<AddableOrganizationResponse[]>;
addOrganizationToProvider: (
providerId: string,
request: {
key: string;
organizationId: string;
},
) => Promise<void>;
}

View File

@@ -0,0 +1,18 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class AddableOrganizationResponse extends BaseResponse {
id: string;
plan: string;
name: string;
seats: number;
disabled: boolean;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("id");
this.plan = this.getResponseProperty("plan");
this.name = this.getResponseProperty("name");
this.seats = this.getResponseProperty("seats");
this.disabled = this.getResponseProperty("disabled");
}
}

View File

@@ -1,3 +1,5 @@
import { AddableOrganizationResponse } from "@bitwarden/common/admin-console/models/response/addable-organization.response";
import { ApiService } from "../../../abstractions/api.service";
import { ProviderApiServiceAbstraction } from "../../abstractions/provider/provider-api.service.abstraction";
import { ProviderSetupRequest } from "../../models/request/provider/provider-setup.request";
@@ -44,4 +46,34 @@ export class ProviderApiService implements ProviderApiServiceAbstraction {
async deleteProvider(id: string): Promise<void> {
await this.apiService.send("DELETE", "/providers/" + id, null, true, false);
}
async getProviderAddableOrganizations(
providerId: string,
): Promise<AddableOrganizationResponse[]> {
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/clients/addable",
null,
true,
true,
);
return response.map((data: any) => new AddableOrganizationResponse(data));
}
addOrganizationToProvider(
providerId: string,
request: {
key: string;
organizationId: string;
},
): Promise<void> {
return this.apiService.send(
"POST",
"/providers/" + providerId + "/clients/existing",
request,
true,
false,
);
}
}

View File

@@ -49,6 +49,7 @@ export enum FeatureFlag {
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
NewDeviceVerification = "new-device-verification",
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
PM15179_AddExistingOrgsFromProviderPortal = "PM-15179-add-existing-orgs-from-provider-portal",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -108,6 +109,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
[FeatureFlag.NewDeviceVerification]: FALSE,
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;