mirror of
https://github.com/bitwarden/browser
synced 2025-12-21 10:43:35 +00:00
Merge branch 'PM-12985-Reports' of github.com:bitwarden/clients into PM-12985-Reports
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<bit-container>
|
||||
<div class="tabbed-header">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<h1>{{ "devices" | i18n }}</h1>
|
||||
<button
|
||||
[bitPopoverTriggerFor]="infoPopover"
|
||||
type="button"
|
||||
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-flex tw-items-center tw-h-4 tw-w-4"
|
||||
[position]="'right-start'"
|
||||
>
|
||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<bit-popover [title]="'whatIsADevice' | i18n" #infoPopover>
|
||||
<p>{{ "aDeviceIs" | i18n }}</p>
|
||||
</bit-popover>
|
||||
<i
|
||||
*ngIf="asyncActionLoading"
|
||||
class="bwi bwi-spinner bwi-spin tw-flex tw-items-center tw-h-4 tw-w-4"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>{{ "deviceListDescription" | i18n }}</p>
|
||||
|
||||
<div *ngIf="loading" class="tw-flex tw-justify-center tw-items-center tw-p-4">
|
||||
<i class="bwi bwi-spinner bwi-spin tw-text-2xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<bit-table-scroll *ngIf="!loading" [dataSource]="dataSource" [rowSize]="50">
|
||||
<ng-container header>
|
||||
<th
|
||||
*ngFor="let col of columnConfig"
|
||||
[class]="col.headerClass"
|
||||
bitCell
|
||||
[bitSortable]="col.sortable ? col.name : null"
|
||||
[default]="col.name === 'loginStatus' ? 'desc' : null"
|
||||
scope="col"
|
||||
role="columnheader"
|
||||
>
|
||||
{{ col.title }}
|
||||
</th>
|
||||
<th bitCell scope="col" role="columnheader"></th>
|
||||
</ng-container>
|
||||
<ng-template bitRowDef let-row>
|
||||
<td bitCell class="tw-flex tw-gap-2">
|
||||
<div class="tw-flex tw-items-center tw-justify-center tw-w-10">
|
||||
<i [class]="getDeviceIcon(row.type)" class="bwi-lg" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
{{ row.displayName }}
|
||||
<span *ngIf="row.trusted" class="tw-text-sm tw-text-muted tw-block">
|
||||
{{ "trusted" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td bitCell>
|
||||
<span *ngIf="isCurrentDevice(row)" bitBadge variant="primary">{{
|
||||
"currentSession" | i18n
|
||||
}}</span>
|
||||
<span *ngIf="hasPendingAuthRequest(row)" bitBadge variant="warning">{{
|
||||
"requestPending" | i18n
|
||||
}}</span>
|
||||
</td>
|
||||
<td bitCell>{{ row.firstLogin | date: "medium" }}</td>
|
||||
<td bitCell>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
[bitMenuTriggerFor]="optionsMenu"
|
||||
></button>
|
||||
<bit-menu #optionsMenu>
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="removeDevice(row)"
|
||||
[disabled]="isCurrentDevice(row)"
|
||||
>
|
||||
<span [class]="isCurrentDevice(row) ? 'tw-text-muted' : 'tw-text-danger'">
|
||||
<i class="bwi bwi-trash" aria-hidden="true"></i>
|
||||
{{ "removeDevice" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
</td>
|
||||
</ng-template>
|
||||
</bit-table-scroll>
|
||||
</bit-container>
|
||||
@@ -0,0 +1,220 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { switchMap } from "rxjs/operators";
|
||||
|
||||
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
|
||||
import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view";
|
||||
import { DeviceType, DeviceTypeMetadata } from "@bitwarden/common/enums";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import {
|
||||
DialogService,
|
||||
ToastService,
|
||||
TableDataSource,
|
||||
TableModule,
|
||||
PopoverModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
interface DeviceTableData {
|
||||
id: string;
|
||||
type: DeviceType;
|
||||
displayName: string;
|
||||
loginStatus: string;
|
||||
firstLogin: Date;
|
||||
trusted: boolean;
|
||||
devicePendingAuthRequest: object | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a table of devices and allows the user to log out, approve or remove a device
|
||||
*/
|
||||
@Component({
|
||||
selector: "app-device-management",
|
||||
templateUrl: "./device-management.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule, SharedModule, TableModule, PopoverModule],
|
||||
})
|
||||
export class DeviceManagementComponent {
|
||||
protected readonly tableId = "device-management-table";
|
||||
protected dataSource = new TableDataSource<DeviceTableData>();
|
||||
protected currentDevice: DeviceView | undefined;
|
||||
protected loading = true;
|
||||
protected asyncActionLoading = false;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private devicesService: DevicesServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
private validationService: ValidationService,
|
||||
) {
|
||||
this.devicesService
|
||||
.getCurrentDevice$()
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
switchMap((currentDevice) => {
|
||||
this.currentDevice = new DeviceView(currentDevice);
|
||||
return this.devicesService.getDevices$();
|
||||
}),
|
||||
)
|
||||
.subscribe({
|
||||
next: (devices) => {
|
||||
this.dataSource.data = devices.map((device) => {
|
||||
return {
|
||||
id: device.id,
|
||||
type: device.type,
|
||||
displayName: this.getHumanReadableDeviceType(device.type),
|
||||
loginStatus: this.getLoginStatus(device),
|
||||
devicePendingAuthRequest: device.response.devicePendingAuthRequest,
|
||||
firstLogin: new Date(device.creationDate),
|
||||
trusted: device.response.isTrusted,
|
||||
};
|
||||
});
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Column configuration for the table
|
||||
*/
|
||||
protected readonly columnConfig = [
|
||||
{
|
||||
name: "displayName",
|
||||
title: this.i18nService.t("device"),
|
||||
headerClass: "tw-w-1/3",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: "loginStatus",
|
||||
title: this.i18nService.t("loginStatus"),
|
||||
headerClass: "tw-w-1/3",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
name: "firstLogin",
|
||||
title: this.i18nService.t("firstLogin"),
|
||||
headerClass: "tw-w-1/3",
|
||||
sortable: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the icon for a device type
|
||||
* @param type - The device type
|
||||
* @returns The icon for the device type
|
||||
*/
|
||||
getDeviceIcon(type: DeviceType): string {
|
||||
const defaultIcon = "bwi bwi-desktop";
|
||||
const categoryIconMap: Record<string, string> = {
|
||||
webVault: "bwi bwi-browser",
|
||||
desktop: "bwi bwi-desktop",
|
||||
mobile: "bwi bwi-mobile",
|
||||
cli: "bwi bwi-cli",
|
||||
extension: "bwi bwi-puzzle",
|
||||
sdk: "bwi bwi-desktop",
|
||||
};
|
||||
|
||||
const metadata = DeviceTypeMetadata[type];
|
||||
return metadata ? (categoryIconMap[metadata.category] ?? defaultIcon) : defaultIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the login status of a device
|
||||
* It will return the current session if the device is the current device
|
||||
* It will return the date of the pending auth request when available
|
||||
* @param device - The device
|
||||
* @returns The login status
|
||||
*/
|
||||
private getLoginStatus(device: DeviceView): string {
|
||||
if (this.isCurrentDevice(device)) {
|
||||
return this.i18nService.t("currentSession");
|
||||
}
|
||||
|
||||
if (device.response.devicePendingAuthRequest?.creationDate) {
|
||||
return this.i18nService.t("requestPending");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human readable device type from the DeviceType enum
|
||||
* @param type - The device type
|
||||
* @returns The human readable device type
|
||||
*/
|
||||
private getHumanReadableDeviceType(type: DeviceType): string {
|
||||
const metadata = DeviceTypeMetadata[type];
|
||||
if (!metadata) {
|
||||
return this.i18nService.t("unknownDevice");
|
||||
}
|
||||
|
||||
// If the platform is "Unknown" translate it since it is not a proper noun
|
||||
const platform =
|
||||
metadata.platform === "Unknown" ? this.i18nService.t("unknown") : metadata.platform;
|
||||
const category = this.i18nService.t(metadata.category);
|
||||
return platform ? `${category} - ${platform}` : category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a device is the current device
|
||||
* @param device - The device or device table data
|
||||
* @returns True if the device is the current device, false otherwise
|
||||
*/
|
||||
protected isCurrentDevice(device: DeviceView | DeviceTableData): boolean {
|
||||
return "response" in device
|
||||
? device.id === this.currentDevice?.id
|
||||
: device.id === this.currentDevice?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a device has a pending auth request
|
||||
* @param device - The device
|
||||
* @returns True if the device has a pending auth request, false otherwise
|
||||
*/
|
||||
protected hasPendingAuthRequest(device: DeviceTableData): boolean {
|
||||
return (
|
||||
device.devicePendingAuthRequest !== undefined && device.devicePendingAuthRequest !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a device
|
||||
* @param device - The device
|
||||
*/
|
||||
protected async removeDevice(device: DeviceTableData) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "removeDevice" },
|
||||
content: { key: "removeDeviceConfirmation" },
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.asyncActionLoading = true;
|
||||
await firstValueFrom(this.devicesService.deactivateDevice$(device.id));
|
||||
this.asyncActionLoading = false;
|
||||
|
||||
// Remove the device from the data source
|
||||
this.dataSource.data = this.dataSource.data.filter((d) => d.id !== device.id);
|
||||
|
||||
this.toastService.showToast({
|
||||
title: "",
|
||||
message: this.i18nService.t("deviceRemoved"),
|
||||
variant: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
this.validationService.showError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RouterModule, Routes } from "@angular/router";
|
||||
import { ChangePasswordComponent } from "../change-password.component";
|
||||
import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component";
|
||||
|
||||
import { DeviceManagementComponent } from "./device-management.component";
|
||||
import { SecurityKeysComponent } from "./security-keys.component";
|
||||
import { SecurityComponent } from "./security.component";
|
||||
|
||||
@@ -29,6 +30,11 @@ const routes: Routes = [
|
||||
component: SecurityKeysComponent,
|
||||
data: { titleId: "keys" },
|
||||
},
|
||||
{
|
||||
path: "device-management",
|
||||
component: DeviceManagementComponent,
|
||||
data: { titleId: "devices" },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<bit-tab-link route="change-password">{{ "masterPassword" | i18n }}</bit-tab-link>
|
||||
</ng-container>
|
||||
<bit-tab-link route="two-factor">{{ "twoStepLogin" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="device-management">{{ "devices" | i18n }}</bit-tab-link>
|
||||
<bit-tab-link route="security-keys">{{ "keys" | i18n }}</bit-tab-link>
|
||||
</bit-tab-nav-bar>
|
||||
</app-header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-security",
|
||||
@@ -9,7 +10,10 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
|
||||
export class SecurityComponent implements OnInit {
|
||||
showChangePassword = true;
|
||||
|
||||
constructor(private userVerificationService: UserVerificationService) {}
|
||||
constructor(
|
||||
private userVerificationService: UserVerificationService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.showChangePassword = await this.userVerificationService.hasMasterPassword();
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="submit">
|
||||
<bit-dialog dialogSize="large" [loading]="loading">
|
||||
<span bitDialogTitle class="tw-font-semibold">
|
||||
{{ "upgradeFreeOrganization" | i18n: currentPlanName }}
|
||||
{{ dialogHeaderName }}
|
||||
</span>
|
||||
<div bitDialogContent>
|
||||
<p>{{ "upgradePlans" | i18n }}</p>
|
||||
<div class="tw-mb-3 tw-flex tw-justify-between">
|
||||
<span class="tw-text-lg tw-pr-1 tw-font-bold">{{ "selectAPlan" | i18n }}</span>
|
||||
<span [hidden]="isSubscriptionCanceled" class="tw-text-lg tw-pr-1 tw-font-bold">{{
|
||||
"selectAPlan" | i18n
|
||||
}}</span>
|
||||
<!-- Discount Badge -->
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<span
|
||||
class="tw-mr-1"
|
||||
[hidden]="isSubscriptionCanceled"
|
||||
*ngIf="
|
||||
this.discountPercentageFromSub > 0
|
||||
? discountPercentageFromSub
|
||||
@@ -69,7 +72,10 @@
|
||||
>
|
||||
<div class="tw-relative">
|
||||
<div
|
||||
*ngIf="selectableProduct.productTier === productTypes.Enterprise"
|
||||
*ngIf="
|
||||
selectableProduct.productTier === productTypes.Enterprise &&
|
||||
!isSubscriptionCanceled
|
||||
"
|
||||
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
|
||||
[ngClass]="{
|
||||
'tw-bg-primary-700 !tw-text-contrast': selectableProduct === selectedPlan,
|
||||
@@ -330,9 +336,15 @@
|
||||
<br />
|
||||
</ng-container>
|
||||
<!-- Payment info -->
|
||||
<ng-container *ngIf="formGroup.value.productTier !== productTypes.Free">
|
||||
<ng-container
|
||||
*ngIf="formGroup.value.productTier !== productTypes.Free || isSubscriptionCanceled"
|
||||
>
|
||||
<h2 bitTypography="h4">{{ "paymentMethod" | i18n }}</h2>
|
||||
<p *ngIf="!showPayment && (paymentSource || billing?.paymentSource)">
|
||||
<p
|
||||
*ngIf="
|
||||
!showPayment && (paymentSource || billing?.paymentSource) && !isSubscriptionCanceled
|
||||
"
|
||||
>
|
||||
<i class="bwi bwi-fw" [ngClass]="paymentSourceClasses"></i>
|
||||
{{
|
||||
deprecateStripeSourcesAPI
|
||||
|
||||
@@ -24,7 +24,14 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||
import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import {
|
||||
BillingApiServiceAbstraction,
|
||||
BillingInformation,
|
||||
OrganizationInformation,
|
||||
PaymentInformation,
|
||||
PlanInformation,
|
||||
OrganizationBillingServiceAbstraction as OrganizationBillingService,
|
||||
} from "@bitwarden/common/billing/abstractions";
|
||||
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
|
||||
import {
|
||||
PaymentMethodType,
|
||||
@@ -49,6 +56,7 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { BillingSharedModule } from "../shared/billing-shared.module";
|
||||
import { PaymentV2Component } from "../shared/payment/payment-v2.component";
|
||||
import { PaymentComponent } from "../shared/payment/payment.component";
|
||||
|
||||
@@ -89,6 +97,8 @@ interface OnSuccessArgs {
|
||||
|
||||
@Component({
|
||||
templateUrl: "./change-plan-dialog.component.html",
|
||||
standalone: true,
|
||||
imports: [BillingSharedModule],
|
||||
})
|
||||
export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
|
||||
@@ -163,6 +173,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
organization: Organization;
|
||||
sub: OrganizationSubscriptionResponse;
|
||||
billing: BillingResponse;
|
||||
dialogHeaderName: string;
|
||||
currentPlanName: string;
|
||||
showPayment: boolean = false;
|
||||
totalOpened: boolean = false;
|
||||
@@ -174,6 +185,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
paymentSource?: PaymentSourceResponse;
|
||||
|
||||
deprecateStripeSourcesAPI: boolean;
|
||||
isSubscriptionCanceled: boolean = false;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -196,6 +208,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
private configService: ConfigService,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private taxService: TaxServiceAbstraction,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -208,6 +221,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
this.sub =
|
||||
this.dialogParams.subscription ??
|
||||
(await this.organizationApiService.getSubscription(this.dialogParams.organizationId));
|
||||
this.dialogHeaderName = this.resolveHeaderName(this.sub);
|
||||
this.organizationId = this.dialogParams.organizationId;
|
||||
this.currentPlan = this.sub?.plan;
|
||||
this.selectedPlan = this.sub?.plan;
|
||||
@@ -281,6 +295,20 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
this.refreshSalesTax();
|
||||
}
|
||||
|
||||
resolveHeaderName(subscription: OrganizationSubscriptionResponse): string {
|
||||
if (subscription.subscription != null) {
|
||||
this.isSubscriptionCanceled = subscription.subscription.cancelled;
|
||||
if (subscription.subscription.cancelled) {
|
||||
return this.i18nService.t("restartSubscription");
|
||||
}
|
||||
}
|
||||
|
||||
return this.i18nService.t(
|
||||
"upgradeFreeOrganization",
|
||||
this.resolvePlanName(this.dialogParams.productTierType),
|
||||
);
|
||||
}
|
||||
|
||||
setInitialPlanSelection() {
|
||||
this.focusedIndex = this.selectableProducts.length - 1;
|
||||
this.selectPlan(this.getPlanByType(ProductTierType.Enterprise));
|
||||
@@ -388,6 +416,19 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
];
|
||||
}
|
||||
case PlanCardState.Disabled: {
|
||||
if (this.isSubscriptionCanceled) {
|
||||
return [
|
||||
"tw-cursor-not-allowed",
|
||||
"tw-bg-secondary-100",
|
||||
"tw-font-normal",
|
||||
"tw-bg-blur",
|
||||
"tw-text-muted",
|
||||
"tw-block",
|
||||
"tw-rounded",
|
||||
"tw-w-80",
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
"tw-cursor-not-allowed",
|
||||
"tw-bg-secondary-100",
|
||||
@@ -409,7 +450,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan === this.currentPlan) {
|
||||
if (plan === this.currentPlan && !this.isSubscriptionCanceled) {
|
||||
return;
|
||||
}
|
||||
this.selectedPlan = plan;
|
||||
@@ -446,6 +487,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
get selectableProducts() {
|
||||
if (this.isSubscriptionCanceled) {
|
||||
// Return only the current plan if the subscription is canceled
|
||||
return [this.currentPlan];
|
||||
}
|
||||
|
||||
if (this.acceptingSponsorship) {
|
||||
const familyPlan = this.passwordManagerPlans.find(
|
||||
(plan) => plan.type === PlanType.FamiliesAnnually,
|
||||
@@ -692,11 +738,18 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
const doSubmit = async (): Promise<string> => {
|
||||
let orgId: string = null;
|
||||
orgId = await this.updateOrganization();
|
||||
if (this.isSubscriptionCanceled) {
|
||||
await this.restartSubscription();
|
||||
orgId = this.organizationId;
|
||||
} else {
|
||||
orgId = await this.updateOrganization();
|
||||
}
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: this.i18nService.t("organizationUpgraded"),
|
||||
message: this.isSubscriptionCanceled
|
||||
? this.i18nService.t("restartOrganizationSubscription")
|
||||
: this.i18nService.t("organizationUpgraded"),
|
||||
});
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
@@ -726,6 +779,44 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
this.dialogRef.close();
|
||||
};
|
||||
|
||||
private async restartSubscription() {
|
||||
const org = await this.organizationApiService.get(this.organizationId);
|
||||
const organization: OrganizationInformation = {
|
||||
name: org.name,
|
||||
billingEmail: org.billingEmail,
|
||||
};
|
||||
|
||||
const plan: PlanInformation = {
|
||||
type: this.selectedPlan.type,
|
||||
passwordManagerSeats: org.seats,
|
||||
};
|
||||
|
||||
if (org.useSecretsManager) {
|
||||
plan.subscribeToSecretsManager = true;
|
||||
plan.secretsManagerSeats = org.smSeats;
|
||||
}
|
||||
|
||||
let paymentMethod: [string, PaymentMethodType];
|
||||
|
||||
if (this.deprecateStripeSourcesAPI) {
|
||||
const { type, token } = await this.paymentV2Component.tokenize();
|
||||
paymentMethod = [token, type];
|
||||
} else {
|
||||
paymentMethod = await this.paymentComponent.createPaymentToken();
|
||||
}
|
||||
|
||||
const payment: PaymentInformation = {
|
||||
paymentMethod,
|
||||
billing: this.getBillingInformationFromTaxInfoComponent(),
|
||||
};
|
||||
|
||||
await this.organizationBillingService.restartSubscription(this.organization.id, {
|
||||
organization,
|
||||
plan,
|
||||
payment,
|
||||
});
|
||||
}
|
||||
|
||||
private async updateOrganization() {
|
||||
const request = new OrganizationUpgradeRequest();
|
||||
if (this.selectedPlan.productTier !== ProductTierType.Families) {
|
||||
@@ -802,6 +893,18 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
return text;
|
||||
}
|
||||
|
||||
private getBillingInformationFromTaxInfoComponent(): BillingInformation {
|
||||
return {
|
||||
country: this.taxInformation.country,
|
||||
postalCode: this.taxInformation.postalCode,
|
||||
taxId: this.taxInformation.taxId,
|
||||
addressLine1: this.taxInformation.line1,
|
||||
addressLine2: this.taxInformation.line2,
|
||||
city: this.taxInformation.city,
|
||||
state: this.taxInformation.state,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSecretsManagerRequest(request: OrganizationUpgradeRequest): void {
|
||||
request.useSecretsManager = this.organization.useSecretsManager;
|
||||
if (!this.organization.useSecretsManager) {
|
||||
@@ -997,6 +1100,11 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
protected canUpdatePaymentInformation(): boolean {
|
||||
return this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty();
|
||||
return (
|
||||
this.upgradeRequiresPaymentMethod ||
|
||||
this.showPayment ||
|
||||
this.isPaymentSourceEmpty() ||
|
||||
this.isSubscriptionCanceled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { BillingSharedModule } from "../shared";
|
||||
import { AdjustSubscription } from "./adjust-subscription.component";
|
||||
import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component";
|
||||
import { BillingSyncKeyComponent } from "./billing-sync-key.component";
|
||||
import { ChangePlanDialogComponent } from "./change-plan-dialog.component";
|
||||
import { ChangePlanComponent } from "./change-plan.component";
|
||||
import { DownloadLicenceDialogComponent } from "./download-license.component";
|
||||
import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component";
|
||||
@@ -44,7 +43,6 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
|
||||
SecretsManagerSubscribeStandaloneComponent,
|
||||
SubscriptionHiddenComponent,
|
||||
SubscriptionStatusComponent,
|
||||
ChangePlanDialogComponent,
|
||||
OrganizationPaymentMethodComponent,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -2,25 +2,37 @@
|
||||
// @ts-strict-ignore
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { BillingSourceResponse } from "@bitwarden/common/billing/models/response/billing.response";
|
||||
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.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 { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { FreeTrial } from "../../core/types/free-trial";
|
||||
import {
|
||||
ChangePlanDialogResultType,
|
||||
openChangePlanDialog,
|
||||
} from "../organizations/change-plan-dialog.component";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class TrialFlowService {
|
||||
private resellerManagedOrgAlert: boolean;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
protected dialogService: DialogService,
|
||||
private router: Router,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
checkForOrgsWithUpcomingPaymentIssues(
|
||||
organization: Organization,
|
||||
@@ -66,16 +78,31 @@ export class TrialFlowService {
|
||||
org: Organization,
|
||||
organizationBillingMetadata: OrganizationBillingMetadataResponse,
|
||||
): Promise<void> {
|
||||
if (organizationBillingMetadata.isSubscriptionUnpaid) {
|
||||
const confirmed = await this.promptForPaymentNavigation(org);
|
||||
if (
|
||||
organizationBillingMetadata.isSubscriptionUnpaid ||
|
||||
organizationBillingMetadata.isSubscriptionCanceled
|
||||
) {
|
||||
const confirmed = await this.promptForPaymentNavigation(
|
||||
org,
|
||||
organizationBillingMetadata.isSubscriptionCanceled,
|
||||
organizationBillingMetadata.isSubscriptionUnpaid,
|
||||
);
|
||||
if (confirmed) {
|
||||
await this.navigateToPaymentMethod(org?.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async promptForPaymentNavigation(org: Organization): Promise<boolean> {
|
||||
if (!org?.isOwner) {
|
||||
private async promptForPaymentNavigation(
|
||||
org: Organization,
|
||||
isCanceled: boolean,
|
||||
isUnpaid: boolean,
|
||||
): Promise<boolean> {
|
||||
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.ResellerManagedOrgAlert,
|
||||
);
|
||||
|
||||
if (!org?.isOwner && !org.providerId) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
|
||||
content: { key: "suspendedUserOrgMessage" },
|
||||
@@ -85,13 +112,31 @@ export class TrialFlowService {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return await this.dialogService.openSimpleDialog({
|
||||
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
|
||||
content: { key: "suspendedOwnerOrgMessage" },
|
||||
type: "danger",
|
||||
acceptButtonText: this.i18nService.t("continue"),
|
||||
cancelButtonText: this.i18nService.t("close"),
|
||||
});
|
||||
|
||||
if (org.providerId && this.resellerManagedOrgAlert) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: this.i18nService.t("suspendedOrganizationTitle", org.name),
|
||||
content: { key: "suspendedManagedOrgMessage", placeholders: [org.providerName] },
|
||||
type: "danger",
|
||||
acceptButtonText: this.i18nService.t("close"),
|
||||
cancelButtonText: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (org.isOwner && isUnpaid) {
|
||||
return await this.dialogService.openSimpleDialog({
|
||||
title: this.i18nService.t("suspendedOrganizationTitle", org.name),
|
||||
content: { key: "suspendedOwnerOrgMessage" },
|
||||
type: "danger",
|
||||
acceptButtonText: this.i18nService.t("continue"),
|
||||
cancelButtonText: this.i18nService.t("close"),
|
||||
});
|
||||
}
|
||||
|
||||
if (org.isOwner && isCanceled && this.resellerManagedOrgAlert) {
|
||||
await this.changePlan(org);
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateToPaymentMethod(orgId: string) {
|
||||
@@ -99,4 +144,20 @@ export class TrialFlowService {
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async changePlan(org: Organization) {
|
||||
const subscription = await this.organizationApiService.getSubscription(org.id);
|
||||
const reference = openChangePlanDialog(this.dialogService, {
|
||||
data: {
|
||||
organizationId: org.id,
|
||||
subscription: subscription,
|
||||
productTierType: org.productTierType,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await lastValueFrom(reference.closed);
|
||||
if (result === ChangePlanDialogResultType.Closed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { firstValueFrom, Subject } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
@@ -16,6 +15,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { TrialFlowService } from "../../../../billing/services/trial-flow.service";
|
||||
import { VaultFilterService } from "../services/abstractions/vault-filter.service";
|
||||
import {
|
||||
VaultFilterList,
|
||||
@@ -91,6 +91,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
return "searchVault";
|
||||
}
|
||||
|
||||
private trialFlowService = inject(TrialFlowService);
|
||||
|
||||
constructor(
|
||||
protected vaultFilterService: VaultFilterService,
|
||||
protected policyService: PolicyService,
|
||||
@@ -126,13 +128,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
this.i18nService.t("disabledOrganizationFilterError"),
|
||||
);
|
||||
const metadata = await this.billingApiService.getOrganizationBillingMetadata(orgNode.node.id);
|
||||
if (metadata.isSubscriptionUnpaid) {
|
||||
const confirmed = await this.promptForPaymentNavigation(orgNode.node);
|
||||
if (confirmed) {
|
||||
await this.navigateToPaymentMethod(orgNode.node.id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
await this.trialFlowService.handleUnpaidSubscriptionDialog(orgNode.node, metadata);
|
||||
}
|
||||
const filter = this.activeFilter;
|
||||
if (orgNode?.node.id === "AllVaults") {
|
||||
@@ -144,32 +140,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
await this.vaultFilterService.expandOrgFilter();
|
||||
};
|
||||
|
||||
private async promptForPaymentNavigation(org: Organization): Promise<boolean> {
|
||||
if (!org?.isOwner) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
|
||||
content: { key: "suspendedUserOrgMessage" },
|
||||
type: "danger",
|
||||
acceptButtonText: this.i18nService.t("close"),
|
||||
cancelButtonText: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return await this.dialogService.openSimpleDialog({
|
||||
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
|
||||
content: { key: "suspendedOwnerOrgMessage" },
|
||||
type: "danger",
|
||||
acceptButtonText: this.i18nService.t("continue"),
|
||||
cancelButtonText: this.i18nService.t("close"),
|
||||
});
|
||||
}
|
||||
|
||||
private async navigateToPaymentMethod(orgId: string) {
|
||||
await this.router.navigate(["organizations", `${orgId}`, "billing", "payment-method"], {
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
});
|
||||
}
|
||||
|
||||
applyTypeFilter = async (filterNode: TreeNode<CipherTypeFilter>): Promise<void> => {
|
||||
const filter = this.activeFilter;
|
||||
filter.resetFilter();
|
||||
|
||||
@@ -1128,6 +1128,12 @@
|
||||
"verifyIdentity": {
|
||||
"message": "Verify your Identity"
|
||||
},
|
||||
"whatIsADevice": {
|
||||
"message": "What is a device?"
|
||||
},
|
||||
"aDeviceIs": {
|
||||
"message": "A device is a unique installation of the Bitwarden app where you have logged in. Reinstalling, clearing app data, or clearing your cookies could result in a device appearing multiple times."
|
||||
},
|
||||
"logInInitiated": {
|
||||
"message": "Log in initiated"
|
||||
},
|
||||
@@ -1715,6 +1721,12 @@
|
||||
"logBackIn": {
|
||||
"message": "Please log back in."
|
||||
},
|
||||
"currentSession": {
|
||||
"message": "Current session"
|
||||
},
|
||||
"requestPending": {
|
||||
"message": "Request pending"
|
||||
},
|
||||
"logBackInOthersToo": {
|
||||
"message": "Please log back in. If you are using other Bitwarden applications log out and back in to those as well."
|
||||
},
|
||||
@@ -3765,6 +3777,15 @@
|
||||
"device": {
|
||||
"message": "Device"
|
||||
},
|
||||
"loginStatus": {
|
||||
"message": "Login status"
|
||||
},
|
||||
"firstLogin": {
|
||||
"message": "First login"
|
||||
},
|
||||
"trusted": {
|
||||
"message": "Trusted"
|
||||
},
|
||||
"creatingAccountOn": {
|
||||
"message": "Creating account on"
|
||||
},
|
||||
@@ -8236,6 +8257,18 @@
|
||||
"approveRequest": {
|
||||
"message": "Approve request"
|
||||
},
|
||||
"deviceApproved": {
|
||||
"message": "Device approved"
|
||||
},
|
||||
"deviceRemoved": {
|
||||
"message": "Device removed"
|
||||
},
|
||||
"removeDevice": {
|
||||
"message": "Remove device"
|
||||
},
|
||||
"removeDeviceConfirmation": {
|
||||
"message": "Are you sure you want to remove this device?"
|
||||
},
|
||||
"noDeviceRequests": {
|
||||
"message": "No device requests"
|
||||
},
|
||||
@@ -9939,6 +9972,12 @@
|
||||
"removeMembers": {
|
||||
"message": "Remove members"
|
||||
},
|
||||
"devices": {
|
||||
"message": "Devices"
|
||||
},
|
||||
"deviceListDescription": {
|
||||
"message": "Your account was logged in to each of the devices below. If you do not recognize a device, remove it now."
|
||||
},
|
||||
"claimedDomains": {
|
||||
"message": "Claimed domains"
|
||||
},
|
||||
@@ -10066,5 +10105,20 @@
|
||||
"example": "02/14/2024"
|
||||
}
|
||||
}
|
||||
},
|
||||
"restartOrganizationSubscription": {
|
||||
"message": "Organization subscription restarted"
|
||||
},
|
||||
"restartSubscription": {
|
||||
"message": "Restart your subscription"
|
||||
},
|
||||
"suspendedManagedOrgMessage": {
|
||||
"message": "Contact $PROVIDER$ for assistance.",
|
||||
"placeholders": {
|
||||
"provider": {
|
||||
"content": "$1",
|
||||
"example": "Acme c"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user