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:
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user