mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-24 12:06:15 +01:00
[PM-8161] Payment optional trial MVP (#10872)
* Initial comment * Add changes for the create org with payment method * Add the secrets manager trail flow * Add the banners * Add changes for the Disabled Org * Add banner to payment method page * Refactoring changes * Resolve the bug on tha payment method * Resolve lint error * Resolve Pr comments * resolve the lint issue * Resolve the lint wrong file issue * Rename object properly * Resolve pr comments from sm team * Resolve the pr comments from sm team * Fix the failing test * Resolve some issue with vault * Resolve the comments from sm team * Resolve some pr comments from vault team * Resolve pr comments from auth team * Exported ValidOrgParams enum * Removed unnecessary interpolation * Corrected bit-banner id for trial * Resolve pr comments from auth team * Resolve pr comments from auth team * Removed unnecessary method * Made OrganizationCreateRequest a subtype of OrganizationNoPaymentMethodCreateRequest * Resolve review changes from sm * Resolve review changes from dm * Resolve the pr comments from billing * move the free-trial to core * Move free-trial change to right file * Revert changes on the free trial page * Resolve the comment on protected trial page * Resolve the comment on protected trial page * Revert the next async change * resolve pr comment fro vault team * resolve the default message comments * remove unused method * resolve email sending issue * Fix the pop issue on payment method * Fix some console errors * Fix the pop refresh page * move the trial services to billing folder * resolve pr comments * Resolve the import issues * Move the observable up * Resolve blank payment method for trialing org * Changes to disable icon is removed onsubmit * Remove unused references * add a missing a period at the end of it * resolve the reload issue * Resolve the disable icon issue * Fix the admin access bug * Resolve the lint issue * Fix the message incorrect format * Formatting fixed * Resolve the access issue of other users role
This commit is contained in:
parent
888b9e346c
commit
f593269133
@ -46,7 +46,7 @@ export class SecretsManagerTrialFreeStepperComponent implements OnInit {
|
||||
protected formBuilder: UntypedFormBuilder,
|
||||
protected i18nService: I18nService,
|
||||
protected organizationBillingService: OrganizationBillingService,
|
||||
private router: Router,
|
||||
protected router: Router,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@ -22,12 +22,29 @@
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="formGroup.get('name').invalid"
|
||||
[loading]="createOrganizationLoading"
|
||||
(click)="createOrganizationOnTrial()"
|
||||
*ngIf="enableTrialPayment$ | async"
|
||||
>
|
||||
{{ "startTrial" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="formGroup.get('name').invalid"
|
||||
[loading]="createOrganizationLoading"
|
||||
cdkStepperNext
|
||||
*ngIf="!(enableTrialPayment$ | async)"
|
||||
>
|
||||
{{ "next" | i18n }}
|
||||
</button>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step label="{{ 'billing' | i18n | titlecase }}" [subLabel]="billingSubLabel">
|
||||
<app-vertical-step
|
||||
label="{{ 'billing' | i18n | titlecase }}"
|
||||
[subLabel]="billingSubLabel"
|
||||
*ngIf="!(enableTrialPayment$ | async)"
|
||||
>
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
[organizationInfo]="{
|
||||
|
@ -1,6 +1,14 @@
|
||||
import { Component, Input, ViewChild } from "@angular/core";
|
||||
import { Component, Input, OnInit, ViewChild } from "@angular/core";
|
||||
import { UntypedFormBuilder } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import {
|
||||
OrganizationCreatedEvent,
|
||||
@ -9,18 +17,64 @@ import {
|
||||
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
|
||||
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
|
||||
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
|
||||
import { ValidOrgParams } from "../trial-initiation.component";
|
||||
|
||||
const trialFlowOrgs = [
|
||||
ValidOrgParams.teams,
|
||||
ValidOrgParams.teamsStarter,
|
||||
ValidOrgParams.enterprise,
|
||||
ValidOrgParams.families,
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: "app-secrets-manager-trial-paid-stepper",
|
||||
templateUrl: "secrets-manager-trial-paid-stepper.component.html",
|
||||
})
|
||||
export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrialFreeStepperComponent {
|
||||
export class SecretsManagerTrialPaidStepperComponent
|
||||
extends SecretsManagerTrialFreeStepperComponent
|
||||
implements OnInit
|
||||
{
|
||||
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
|
||||
@Input() organizationTypeQueryParameter: string;
|
||||
|
||||
plan: PlanType;
|
||||
createOrganizationLoading = false;
|
||||
billingSubLabel = this.i18nService.t("billingTrialSubLabel");
|
||||
organizationId: string;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.TrialPaymentOptional,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private configService: ConfigService,
|
||||
protected formBuilder: UntypedFormBuilder,
|
||||
protected i18nService: I18nService,
|
||||
protected organizationBillingService: OrganizationBillingService,
|
||||
protected router: Router,
|
||||
) {
|
||||
super(formBuilder, i18nService, organizationBillingService, router);
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.referenceEventRequest = new ReferenceEventRequest();
|
||||
this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website";
|
||||
|
||||
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => {
|
||||
if (trialFlowOrgs.includes(qParams.org)) {
|
||||
if (qParams.org === ValidOrgParams.teamsStarter) {
|
||||
this.plan = PlanType.TeamsStarter;
|
||||
} else if (qParams.org === ValidOrgParams.teams) {
|
||||
this.plan = PlanType.TeamsAnnually;
|
||||
} else if (qParams.org === ValidOrgParams.enterprise) {
|
||||
this.plan = PlanType.EnterpriseAnnually;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
organizationCreated(event: OrganizationCreatedEvent) {
|
||||
this.organizationId = event.organizationId;
|
||||
this.billingSubLabel = event.planDescription;
|
||||
@ -31,6 +85,29 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial
|
||||
this.verticalStepper.previous();
|
||||
}
|
||||
|
||||
async createOrganizationOnTrial(): Promise<void> {
|
||||
this.createOrganizationLoading = true;
|
||||
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
|
||||
organization: {
|
||||
name: this.formGroup.get("name").value,
|
||||
billingEmail: this.formGroup.get("email").value,
|
||||
initiationPath: "Secrets Manager trial from marketing website",
|
||||
},
|
||||
plan: {
|
||||
type: this.plan,
|
||||
subscribeToSecretsManager: true,
|
||||
isFromSecretsManagerTrial: true,
|
||||
passwordManagerSeats: 1,
|
||||
secretsManagerSeats: 1,
|
||||
},
|
||||
});
|
||||
|
||||
this.organizationId = response?.id;
|
||||
this.subLabels.organizationInfo = response?.name;
|
||||
this.createOrganizationLoading = false;
|
||||
this.verticalStepper.next();
|
||||
}
|
||||
|
||||
get createAccountLabel() {
|
||||
const organizationType =
|
||||
this.productType === ProductTierType.TeamsStarter
|
||||
|
@ -91,12 +91,17 @@
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="orgInfoFormGroup.get('name').invalid"
|
||||
cdkStepperNext
|
||||
[loading]="loading"
|
||||
(click)="createOrganizationOnTrial()"
|
||||
>
|
||||
{{ "next" | i18n }}
|
||||
{{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
|
||||
</button>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step label="Billing" [subLabel]="billingSubLabel">
|
||||
<app-vertical-step
|
||||
label="Billing"
|
||||
[subLabel]="billingSubLabel"
|
||||
*ngIf="!(enableTrialPayment$ | async)"
|
||||
>
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
[organizationInfo]="{
|
||||
|
@ -13,7 +13,9 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
@ -39,6 +41,8 @@ describe("TrialInitiationComponent", () => {
|
||||
let policyServiceMock: MockProxy<PolicyService>;
|
||||
let routerServiceMock: MockProxy<RouterService>;
|
||||
let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>;
|
||||
let organizationBillingServiceMock: MockProxy<OrganizationBillingService>;
|
||||
let configServiceMock: MockProxy<ConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
// only define services directly that we want to mock return values in this component
|
||||
@ -47,6 +51,8 @@ describe("TrialInitiationComponent", () => {
|
||||
policyServiceMock = mock<PolicyService>();
|
||||
routerServiceMock = mock<RouterService>();
|
||||
acceptOrgInviteServiceMock = mock<AcceptOrganizationInviteService>();
|
||||
organizationBillingServiceMock = mock<OrganizationBillingService>();
|
||||
configServiceMock = mock<ConfigService>();
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
@ -92,6 +98,14 @@ describe("TrialInitiationComponent", () => {
|
||||
provide: AcceptOrganizationInviteService,
|
||||
useValue: acceptOrgInviteServiceMock,
|
||||
},
|
||||
{
|
||||
provide: OrganizationBillingService,
|
||||
useValue: organizationBillingServiceMock,
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: configServiceMock,
|
||||
},
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component)
|
||||
}).compileComponents();
|
||||
|
@ -9,8 +9,15 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import {
|
||||
OrganizationInformation,
|
||||
PlanInformation,
|
||||
OrganizationBillingServiceAbstraction as OrganizationBillingService,
|
||||
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
@ -25,7 +32,7 @@ import { OrganizationInvite } from "../organization-invite/organization-invite";
|
||||
import { RouterService } from "./../../core/router.service";
|
||||
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
|
||||
|
||||
enum ValidOrgParams {
|
||||
export enum ValidOrgParams {
|
||||
families = "families",
|
||||
enterprise = "enterprise",
|
||||
teams = "teams",
|
||||
@ -69,6 +76,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
productTier: ProductTierType;
|
||||
accountCreateOnly = true;
|
||||
useTrialStepper = false;
|
||||
loading = false;
|
||||
policies: Policy[];
|
||||
enforcedPolicyOptions: MasterPasswordPolicyOptions;
|
||||
trialFlowOrgs: string[] = [
|
||||
@ -115,6 +123,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.TrialPaymentOptional,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@ -127,6 +138,8 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
private i18nService: I18nService,
|
||||
private routerService: RouterService,
|
||||
private acceptOrgInviteService: AcceptOrganizationInviteService,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@ -215,6 +228,30 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async createOrganizationOnTrial() {
|
||||
this.loading = true;
|
||||
const organization: OrganizationInformation = {
|
||||
name: this.orgInfoFormGroup.get("name").value,
|
||||
billingEmail: this.orgInfoFormGroup.get("email").value,
|
||||
initiationPath: "Password Manager trial from marketing website",
|
||||
};
|
||||
|
||||
const plan: PlanInformation = {
|
||||
type: this.plan,
|
||||
passwordManagerSeats: 1,
|
||||
};
|
||||
|
||||
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
|
||||
organization,
|
||||
plan,
|
||||
});
|
||||
|
||||
this.orgId = response?.id;
|
||||
this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`;
|
||||
this.loading = false;
|
||||
this.verticalStepper.next();
|
||||
}
|
||||
|
||||
createdAccount(email: string) {
|
||||
this.email = email;
|
||||
this.orgInfoFormGroup.get("email")?.setValue(email);
|
||||
|
@ -345,16 +345,22 @@
|
||||
<a></a>
|
||||
</p>
|
||||
<app-payment
|
||||
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && !deprecateStripeSourcesAPI"
|
||||
*ngIf="
|
||||
(upgradeRequiresPaymentMethod || showPayment || isPaymentSourceEmpty()) &&
|
||||
!deprecateStripeSourcesAPI
|
||||
"
|
||||
[hideCredit]="true"
|
||||
></app-payment>
|
||||
<app-payment-v2
|
||||
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && deprecateStripeSourcesAPI"
|
||||
*ngIf="
|
||||
(upgradeRequiresPaymentMethod || showPayment || isPaymentSourceEmpty()) &&
|
||||
deprecateStripeSourcesAPI
|
||||
"
|
||||
[showAccountCredit]="false"
|
||||
>
|
||||
</app-payment-v2>
|
||||
<app-tax-info
|
||||
*ngIf="showPayment || upgradeRequiresPaymentMethod"
|
||||
*ngIf="showPayment || upgradeRequiresPaymentMethod || isPaymentSourceEmpty()"
|
||||
(onCountryChanged)="changedCountry()"
|
||||
></app-tax-info>
|
||||
<div id="price" class="tw-mt-4">
|
||||
|
@ -282,6 +282,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
: this.discountPercentageFromSub + this.discountPercentage;
|
||||
}
|
||||
|
||||
isPaymentSourceEmpty() {
|
||||
return this.deprecateStripeSourcesAPI
|
||||
? this.paymentSource === null || this.paymentSource === undefined
|
||||
: this.billing?.paymentSource === null || this.billing?.paymentSource === undefined;
|
||||
}
|
||||
|
||||
isSecretsManagerTrial(): boolean {
|
||||
return (
|
||||
this.sub?.subscription?.items?.some((item) =>
|
||||
@ -723,7 +729,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
// Secrets Manager
|
||||
this.buildSecretsManagerRequest(request);
|
||||
|
||||
if (this.upgradeRequiresPaymentMethod || this.showPayment) {
|
||||
if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) {
|
||||
if (this.deprecateStripeSourcesAPI) {
|
||||
const tokenizedPaymentSource = await this.paymentV2Component.tokenize();
|
||||
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BannerModule } from "../../../../../../libs/components/src/banner/banner.module";
|
||||
import { UserVerificationModule } from "../../auth/shared/components/user-verification";
|
||||
import { LooseComponentsModule } from "../../shared";
|
||||
import { BillingSharedModule } from "../shared";
|
||||
@ -28,6 +29,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
|
||||
BillingSharedModule,
|
||||
OrganizationPlansComponent,
|
||||
LooseComponentsModule,
|
||||
BannerModule,
|
||||
],
|
||||
declarations: [
|
||||
AdjustSubscription,
|
||||
|
@ -1,3 +1,22 @@
|
||||
<bit-banner
|
||||
id="free-trial-banner"
|
||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||
bannerType="premium"
|
||||
icon="bwi-billing"
|
||||
[showClose]="false"
|
||||
*ngIf="freeTrialData?.shownBanner"
|
||||
>
|
||||
{{ freeTrialData.message }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="contrast"
|
||||
(click)="changePayment()"
|
||||
class="tw-cursor-pointer"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{ "routeToPaymentMethodTrigger" | i18n }}
|
||||
</a>
|
||||
</bit-banner>
|
||||
<app-header></app-header>
|
||||
<bit-container>
|
||||
<ng-container *ngIf="loading">
|
||||
|
@ -1,17 +1,25 @@
|
||||
import { Component, ViewChild } from "@angular/core";
|
||||
import { Location } from "@angular/common";
|
||||
import { Component, OnDestroy, ViewChild } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { from, lastValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
|
||||
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { FreeTrial } from "../../../core/types/free-trial";
|
||||
import { TrialFlowService } from "../../services/trial-flow.service";
|
||||
import { TaxInfoComponent } from "../../shared";
|
||||
import {
|
||||
AddCreditDialogResult,
|
||||
@ -25,26 +33,36 @@ import {
|
||||
@Component({
|
||||
templateUrl: "./organization-payment-method.component.html",
|
||||
})
|
||||
export class OrganizationPaymentMethodComponent {
|
||||
export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
|
||||
|
||||
organizationId: string;
|
||||
isUnpaid = false;
|
||||
accountCredit: number;
|
||||
paymentSource?: PaymentSourceResponse;
|
||||
subscriptionStatus?: string;
|
||||
protected freeTrialData: FreeTrial;
|
||||
organization: Organization;
|
||||
organizationSubscriptionResponse: OrganizationSubscriptionResponse;
|
||||
|
||||
loading = true;
|
||||
|
||||
protected readonly Math = Math;
|
||||
launchPaymentModalAutomatically = false;
|
||||
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
private toastService: ToastService,
|
||||
private location: Location,
|
||||
private trialFlowService: TrialFlowService,
|
||||
private organizationService: OrganizationService,
|
||||
protected syncService: SyncService,
|
||||
) {
|
||||
this.activatedRoute.params
|
||||
.pipe(
|
||||
@ -59,6 +77,23 @@ export class OrganizationPaymentMethodComponent {
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
const state = this.router.getCurrentNavigation()?.extras?.state;
|
||||
// incase the above state is undefined or null we use redundantState
|
||||
const redundantState: any = location.getState();
|
||||
if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) {
|
||||
this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically;
|
||||
} else if (
|
||||
redundantState &&
|
||||
Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically")
|
||||
) {
|
||||
this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically;
|
||||
} else {
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
}
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
}
|
||||
|
||||
protected addAccountCredit = async (): Promise<void> => {
|
||||
@ -82,6 +117,34 @@ export class OrganizationPaymentMethodComponent {
|
||||
this.accountCredit = accountCredit;
|
||||
this.paymentSource = paymentSource;
|
||||
this.subscriptionStatus = subscriptionStatus;
|
||||
|
||||
if (this.organizationId) {
|
||||
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
|
||||
this.organizationId,
|
||||
);
|
||||
const organizationPromise = this.organizationService.get(this.organizationId);
|
||||
|
||||
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
|
||||
organizationSubscriptionPromise,
|
||||
organizationPromise,
|
||||
]);
|
||||
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
this.organization,
|
||||
this.organizationSubscriptionResponse,
|
||||
paymentSource,
|
||||
);
|
||||
}
|
||||
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
|
||||
// If the flag `launchPaymentModalAutomatically` is set to true,
|
||||
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
|
||||
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
|
||||
if (this.launchPaymentModalAutomatically) {
|
||||
window.setTimeout(async () => {
|
||||
await this.changePayment();
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
}, 800);
|
||||
}
|
||||
this.loading = false;
|
||||
};
|
||||
|
||||
@ -100,6 +163,24 @@ export class OrganizationPaymentMethodComponent {
|
||||
}
|
||||
};
|
||||
|
||||
changePayment = async () => {
|
||||
const dialogRef = AdjustPaymentDialogV2Component.open(this.dialogService, {
|
||||
data: {
|
||||
initialPaymentMethod: this.paymentSource?.type,
|
||||
organizationId: this.organizationId,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === AdjustPaymentDialogV2ResultType.Submitted) {
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
|
||||
protected updateTaxInformation = async (): Promise<void> => {
|
||||
this.taxInfoComponent.taxFormGroup.updateValueAndValidity();
|
||||
this.taxInfoComponent.taxFormGroup.markAllAsTouched();
|
||||
|
100
apps/web/src/app/billing/services/trial-flow.service.ts
Normal file
100
apps/web/src/app/billing/services/trial-flow.service.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { FreeTrial } from "../../core/types/free-trial";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class TrialFlowService {
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
protected dialogService: DialogService,
|
||||
private router: Router,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
) {}
|
||||
checkForOrgsWithUpcomingPaymentIssues(
|
||||
organization: Organization,
|
||||
organizationSubscription: OrganizationSubscriptionResponse,
|
||||
paymentSource: BillingSourceResponse | PaymentSourceResponse,
|
||||
): FreeTrial {
|
||||
const trialEndDate = organizationSubscription?.subscription?.trialEndDate;
|
||||
const displayBanner =
|
||||
!paymentSource &&
|
||||
organization?.isOwner &&
|
||||
organizationSubscription?.subscription?.status === "trialing";
|
||||
const trialRemainingDays = trialEndDate ? this.calculateTrialRemainingDays(trialEndDate) : 0;
|
||||
const freeTrialMessage = this.getFreeTrialMessage(trialRemainingDays);
|
||||
|
||||
return {
|
||||
remainingDays: trialRemainingDays,
|
||||
message: freeTrialMessage,
|
||||
shownBanner: displayBanner,
|
||||
organizationId: organization.id,
|
||||
organizationName: organization.name,
|
||||
};
|
||||
}
|
||||
|
||||
calculateTrialRemainingDays(trialEndDate: string): number | undefined {
|
||||
const today = new Date();
|
||||
const trialEnd = new Date(trialEndDate);
|
||||
const timeDifference = trialEnd.getTime() - today.getTime();
|
||||
|
||||
return Math.ceil(timeDifference / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
getFreeTrialMessage(trialRemainingDays: number): string {
|
||||
if (trialRemainingDays >= 2) {
|
||||
return this.i18nService.t("freeTrialEndPrompt", trialRemainingDays);
|
||||
} else if (trialRemainingDays === 1) {
|
||||
return this.i18nService.t("freeTrialEndPromptForOneDayNoOrgName");
|
||||
} else {
|
||||
return this.i18nService.t("freeTrialEndingSoonWithoutOrgName");
|
||||
}
|
||||
}
|
||||
|
||||
async handleUnpaidSubscriptionDialog(
|
||||
org: Organization,
|
||||
organizationBillingMetadata: OrganizationBillingMetadataResponse,
|
||||
): Promise<void> {
|
||||
if (organizationBillingMetadata.isSubscriptionUnpaid) {
|
||||
const confirmed = await this.promptForPaymentNavigation(org);
|
||||
if (confirmed) {
|
||||
await this.navigateToPaymentMethod(org?.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
@ -74,6 +74,7 @@ export class AdjustPaymentDialogComponent {
|
||||
}
|
||||
});
|
||||
await response;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: null,
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
|
||||
import { HeaderModule } from "../../layouts/header/header.module";
|
||||
import { SharedModule } from "../../shared";
|
||||
|
||||
@ -27,6 +29,7 @@ import { VerifyBankAccountComponent } from "./verify-bank-account/verify-bank-ac
|
||||
PaymentComponent,
|
||||
TaxInfoComponent,
|
||||
HeaderModule,
|
||||
BannerModule,
|
||||
PaymentV2Component,
|
||||
VerifyBankAccountComponent,
|
||||
],
|
||||
|
@ -1,3 +1,23 @@
|
||||
<bit-banner
|
||||
id="free-trial-banner"
|
||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||
bannerType="premium"
|
||||
icon="bwi-billing"
|
||||
[showClose]="false"
|
||||
*ngIf="freeTrialData?.shownBanner"
|
||||
>
|
||||
{{ freeTrialData?.message }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="contrast"
|
||||
(click)="changePayment()"
|
||||
class="tw-cursor-pointer"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{ "routeToPaymentMethodTrigger" | i18n }}
|
||||
</a>
|
||||
</bit-banner>
|
||||
|
||||
<app-header *ngIf="organizationId">
|
||||
<button
|
||||
type="button"
|
||||
@ -77,7 +97,13 @@
|
||||
{{ paymentSource.description }}
|
||||
</p>
|
||||
</ng-container>
|
||||
<button type="button" bitButton buttonType="secondary" [bitAction]="changePayment">
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
class="payment_trigger_button"
|
||||
[bitAction]="changePayment"
|
||||
>
|
||||
{{ (paymentSource ? "changePaymentMethod" : "addPaymentMethod") | i18n }}
|
||||
</button>
|
||||
<p *ngIf="isUnpaid" bitTypography="body1">
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { Component, OnInit, ViewChild } from "@angular/core";
|
||||
import { Location } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { BillingPaymentResponse } from "@bitwarden/common/billing/models/response/billing-payment.response";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
@ -13,8 +16,12 @@ import { VerifyBankRequest } from "@bitwarden/common/models/request/verify-bank.
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { FreeTrial } from "../../core/types/free-trial";
|
||||
import { TrialFlowService } from "../services/trial-flow.service";
|
||||
|
||||
import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component";
|
||||
import {
|
||||
AdjustPaymentDialogResult,
|
||||
@ -26,7 +33,7 @@ import { TaxInfoComponent } from "./tax-info.component";
|
||||
templateUrl: "payment-method.component.html",
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class PaymentMethodComponent implements OnInit {
|
||||
export class PaymentMethodComponent implements OnInit, OnDestroy {
|
||||
@ViewChild(TaxInfoComponent) taxInfo: TaxInfoComponent;
|
||||
|
||||
loading = false;
|
||||
@ -37,6 +44,7 @@ export class PaymentMethodComponent implements OnInit {
|
||||
paymentMethodType = PaymentMethodType;
|
||||
organizationId: string;
|
||||
isUnpaid = false;
|
||||
organization: Organization;
|
||||
|
||||
verifyBankForm = this.formBuilder.group({
|
||||
amount1: new FormControl<number>(null, [
|
||||
@ -52,6 +60,8 @@ export class PaymentMethodComponent implements OnInit {
|
||||
});
|
||||
|
||||
taxForm = this.formBuilder.group({});
|
||||
launchPaymentModalAutomatically = false;
|
||||
protected freeTrialData: FreeTrial;
|
||||
|
||||
constructor(
|
||||
protected apiService: ApiService,
|
||||
@ -59,12 +69,30 @@ export class PaymentMethodComponent implements OnInit {
|
||||
protected i18nService: I18nService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
private location: Location,
|
||||
private logService: LogService,
|
||||
private route: ActivatedRoute,
|
||||
private formBuilder: FormBuilder,
|
||||
private dialogService: DialogService,
|
||||
private toastService: ToastService,
|
||||
) {}
|
||||
private trialFlowService: TrialFlowService,
|
||||
private organizationService: OrganizationService,
|
||||
protected syncService: SyncService,
|
||||
) {
|
||||
const state = this.router.getCurrentNavigation()?.extras?.state;
|
||||
// incase the above state is undefined or null we use redundantState
|
||||
const redundantState: any = location.getState();
|
||||
if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) {
|
||||
this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically;
|
||||
} else if (
|
||||
redundantState &&
|
||||
Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically")
|
||||
) {
|
||||
this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically;
|
||||
} else {
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
|
||||
@ -88,27 +116,37 @@ export class PaymentMethodComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
|
||||
if (this.forOrganization) {
|
||||
const billingPromise = this.organizationApiService.getBilling(this.organizationId);
|
||||
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
|
||||
this.organizationId,
|
||||
);
|
||||
const organizationPromise = this.organizationService.get(this.organizationId);
|
||||
|
||||
[this.billing, this.org] = await Promise.all([
|
||||
[this.billing, this.org, this.organization] = await Promise.all([
|
||||
billingPromise,
|
||||
organizationSubscriptionPromise,
|
||||
organizationPromise,
|
||||
]);
|
||||
this.determineOrgsWithUpcomingPaymentIssues();
|
||||
} else {
|
||||
const billingPromise = this.apiService.getUserBillingPayment();
|
||||
const subPromise = this.apiService.getUserSubscription();
|
||||
|
||||
[this.billing, this.sub] = await Promise.all([billingPromise, subPromise]);
|
||||
}
|
||||
|
||||
this.isUnpaid = this.subscription?.status === "unpaid" ?? false;
|
||||
|
||||
this.loading = false;
|
||||
// If the flag `launchPaymentModalAutomatically` is set to true,
|
||||
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
|
||||
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
|
||||
if (this.launchPaymentModalAutomatically) {
|
||||
window.setTimeout(async () => {
|
||||
await this.changePayment();
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
addCredit = async () => {
|
||||
@ -132,6 +170,11 @@ export class PaymentMethodComponent implements OnInit {
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === AdjustPaymentDialogResult.Adjusted) {
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
@ -162,6 +205,14 @@ export class PaymentMethodComponent implements OnInit {
|
||||
});
|
||||
};
|
||||
|
||||
determineOrgsWithUpcomingPaymentIssues() {
|
||||
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
this.organization,
|
||||
this.org,
|
||||
this.billing?.paymentSource,
|
||||
);
|
||||
}
|
||||
|
||||
get isCreditBalance() {
|
||||
return this.billing == null || this.billing.balance <= 0;
|
||||
}
|
||||
@ -203,4 +254,8 @@ export class PaymentMethodComponent implements OnInit {
|
||||
get subscription() {
|
||||
return this.sub?.subscription ?? this.org?.subscription ?? null;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
}
|
||||
}
|
||||
|
7
apps/web/src/app/core/types/free-trial.ts
Normal file
7
apps/web/src/app/core/types/free-trial.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type FreeTrial = {
|
||||
remainingDays: number;
|
||||
message: string;
|
||||
shownBanner: boolean;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
};
|
@ -22,6 +22,7 @@
|
||||
[route]="['../', org.id]"
|
||||
(mainContentClicked)="toggle()"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
(click)="handleUnpaidSubscription(org)"
|
||||
>
|
||||
<i
|
||||
slot="end"
|
||||
|
@ -6,7 +6,10 @@ import { combineLatest, map, Observable } from "rxjs";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import type { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { NavigationModule } from "@bitwarden/components";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { DialogService, NavigationModule } from "@bitwarden/components";
|
||||
|
||||
import { TrialFlowService } from "./../../billing/services/trial-flow.service";
|
||||
|
||||
@Component({
|
||||
selector: "org-switcher",
|
||||
@ -52,7 +55,10 @@ export class OrgSwitcherComponent {
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
protected dialogService: DialogService,
|
||||
private organizationService: OrganizationService,
|
||||
private trialFlowService: TrialFlowService,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
) {}
|
||||
|
||||
protected toggle(event?: MouseEvent) {
|
||||
@ -60,4 +66,9 @@ export class OrgSwitcherComponent {
|
||||
this.open = !this.open;
|
||||
this.openChange.emit(this.open);
|
||||
}
|
||||
|
||||
async handleUnpaidSubscription(org: Organization) {
|
||||
const metaData = await this.billingApiService.getOrganizationBillingMetadata(org.id);
|
||||
await this.trialFlowService.handleUnpaidSubscriptionDialog(org, metaData);
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,23 @@
|
||||
<bit-banner
|
||||
id="free-trial-banner"
|
||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||
bannerType="premium"
|
||||
icon="bwi-billing"
|
||||
[showClose]="false"
|
||||
*ngFor="let organization of organizationsPaymentStatus; trackBy: trackBy; index as i"
|
||||
>
|
||||
{{ freeTrialMessage(organization) }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="contrast"
|
||||
(click)="navigateToPaymentMethod(organization?.organizationId)"
|
||||
rel="noreferrer noopener"
|
||||
class="tw-cursor-pointer"
|
||||
>
|
||||
{{ "routeToPaymentMethodTrigger" | i18n }}
|
||||
</a>
|
||||
</bit-banner>
|
||||
|
||||
<bit-banner
|
||||
id="update-browser-banner"
|
||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
|
||||
import { VerifyEmailComponent } from "../../../auth/settings/verify-email.component";
|
||||
import { FreeTrial } from "../../../core/types/free-trial";
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
import { VaultBannersService, VisibleVaultBanner } from "./services/vault-banners.service";
|
||||
@ -19,8 +22,13 @@ export class VaultBannersComponent implements OnInit {
|
||||
visibleBanners: VisibleVaultBanner[] = [];
|
||||
premiumBannerVisible$: Observable<boolean>;
|
||||
VisibleVaultBanner = VisibleVaultBanner;
|
||||
@Input() organizationsPaymentStatus: FreeTrial[] = [];
|
||||
|
||||
constructor(private vaultBannerService: VaultBannersService) {
|
||||
constructor(
|
||||
private vaultBannerService: VaultBannersService,
|
||||
private router: Router,
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
this.premiumBannerVisible$ = this.vaultBannerService.shouldShowPremiumBanner$;
|
||||
}
|
||||
|
||||
@ -34,6 +42,17 @@ export class VaultBannersComponent implements OnInit {
|
||||
await this.determineVisibleBanners();
|
||||
}
|
||||
|
||||
async navigateToPaymentMethod(organizationId: string): Promise<void> {
|
||||
const navigationExtras = {
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
};
|
||||
|
||||
await this.router.navigate(
|
||||
["organizations", organizationId, "billing", "payment-method"],
|
||||
navigationExtras,
|
||||
);
|
||||
}
|
||||
|
||||
/** Determine which banners should be present */
|
||||
private async determineVisibleBanners(): Promise<void> {
|
||||
const showBrowserOutdated = await this.vaultBannerService.shouldShowUpdateBrowserBanner();
|
||||
@ -46,4 +65,22 @@ export class VaultBannersComponent implements OnInit {
|
||||
showLowKdf ? VisibleVaultBanner.KDFSettings : null,
|
||||
].filter(Boolean); // remove all falsy values, i.e. null
|
||||
}
|
||||
|
||||
freeTrialMessage(organization: FreeTrial) {
|
||||
if (organization.remainingDays >= 2) {
|
||||
return this.i18nService.t(
|
||||
"freeTrialEndPromptAboveTwoDays",
|
||||
organization.organizationName,
|
||||
organization.remainingDays.toString(),
|
||||
);
|
||||
} else if (organization.remainingDays === 1) {
|
||||
return this.i18nService.t("freeTrialEndPromptForOneDay", organization.organizationName);
|
||||
} else {
|
||||
return this.i18nService.t("freeTrialEndPromptForLessThanADay", organization.organizationName);
|
||||
}
|
||||
}
|
||||
|
||||
trackBy(index: number) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,16 @@
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { VaultFilterService } from "../services/abstractions/vault-filter.service";
|
||||
import {
|
||||
@ -40,7 +44,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
isLoaded = false;
|
||||
|
||||
protected destroy$: Subject<void> = new Subject<void>();
|
||||
|
||||
private router = inject(Router);
|
||||
get filtersList() {
|
||||
return this.filters ? Object.values(this.filters) : [];
|
||||
}
|
||||
@ -85,6 +89,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
protected policyService: PolicyService,
|
||||
protected i18nService: I18nService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
protected dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@ -111,6 +117,13 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
null,
|
||||
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;
|
||||
}
|
||||
const filter = this.activeFilter;
|
||||
@ -123,6 +136,32 @@ 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();
|
||||
|
@ -1,4 +1,4 @@
|
||||
<app-vault-banners></app-vault-banners>
|
||||
<app-vault-banners [organizationsPaymentStatus]="organizationsPaymentStatus"></app-vault-banners>
|
||||
|
||||
<app-vault-header
|
||||
[filter]="filter"
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
from,
|
||||
lastValueFrom,
|
||||
Observable,
|
||||
Subject,
|
||||
@ -41,10 +42,12 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
@ -72,6 +75,8 @@ import {
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { TrialFlowService } from "../../billing/services/trial-flow.service";
|
||||
import { FreeTrial } from "../../core/types/free-trial";
|
||||
import { SharedModule } from "../../shared/shared.module";
|
||||
import { AssignCollectionsWebComponent } from "../components/assign-collections";
|
||||
import {
|
||||
@ -174,12 +179,28 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
protected canCreateCollections = false;
|
||||
protected currentSearchText$: Observable<string>;
|
||||
private activeUserId: UserId;
|
||||
protected organizationsPaymentStatus: FreeTrial[] = [];
|
||||
private searchText$ = new Subject<string>();
|
||||
private refresh$ = new BehaviorSubject<void>(null);
|
||||
private destroy$ = new Subject<void>();
|
||||
private extensionRefreshEnabled: boolean;
|
||||
|
||||
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
|
||||
private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe(
|
||||
filter((organizations) => organizations.length === 1),
|
||||
switchMap(([organization]) =>
|
||||
from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe(
|
||||
switchMap((organizationMetaData) =>
|
||||
from(
|
||||
this.trialFlowService.handleUnpaidSubscriptionDialog(
|
||||
organization,
|
||||
organizationMetaData,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private syncService: SyncService,
|
||||
@ -211,6 +232,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
private toastService: ToastService,
|
||||
private accountService: AccountService,
|
||||
private cipherFormConfigService: DefaultCipherFormConfigService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
private trialFlowService: TrialFlowService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@ -309,7 +333,6 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
if (filter.collectionId === undefined || filter.collectionId === Unassigned) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let collectionsToReturn = [];
|
||||
if (filter.organizationId !== undefined && filter.collectionId === All) {
|
||||
collectionsToReturn = collections
|
||||
@ -362,7 +385,6 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled),
|
||||
switchMap(async (params) => {
|
||||
const cipherId = getCipherIdFromParams(params);
|
||||
|
||||
if (cipherId) {
|
||||
if (await this.cipherService.get(cipherId)) {
|
||||
let action = params.action;
|
||||
@ -393,6 +415,32 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe();
|
||||
|
||||
const organizationsPaymentStatus$ = this.organizationService.organizations$.pipe(
|
||||
switchMap((allOrganizations) => {
|
||||
return combineLatest(
|
||||
allOrganizations
|
||||
.filter((org) => org.isOwner)
|
||||
.map((org) =>
|
||||
combineLatest([
|
||||
this.organizationApiService.getSubscription(org.id),
|
||||
this.organizationApiService.getBilling(org.id),
|
||||
]).pipe(
|
||||
map(([subscription, billing]) => {
|
||||
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
org,
|
||||
subscription,
|
||||
billing?.paymentSource,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
map((results) => results.filter((result) => result.shownBanner)),
|
||||
);
|
||||
|
||||
firstSetup$
|
||||
.pipe(
|
||||
switchMap(() => this.refresh$),
|
||||
@ -406,6 +454,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
ciphers$,
|
||||
collections$,
|
||||
selectedCollection$,
|
||||
organizationsPaymentStatus$,
|
||||
]),
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
@ -419,6 +468,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
ciphers,
|
||||
collections,
|
||||
selectedCollection,
|
||||
organizationsPaymentStatus,
|
||||
]) => {
|
||||
this.filter = filter;
|
||||
this.canAccessPremium = canAccessPremium;
|
||||
@ -434,7 +484,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.showBulkMove = filter.type !== "trash";
|
||||
this.isEmpty = collections?.length === 0 && ciphers?.length === 0;
|
||||
|
||||
this.organizationsPaymentStatus = organizationsPaymentStatus;
|
||||
this.performingInitialLoad = false;
|
||||
this.refreshing = false;
|
||||
},
|
||||
|
@ -3,9 +3,11 @@ import { firstValueFrom, Subject } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../individual-vault/vault-filter/components/vault-filter.component"; //../../vault/vault-filter/components/vault-filter.component";
|
||||
import { VaultFilterService } from "../../individual-vault/vault-filter/services/abstractions/vault-filter.service";
|
||||
@ -38,8 +40,17 @@ export class VaultFilterComponent
|
||||
protected policyService: PolicyService,
|
||||
protected i18nService: I18nService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
protected dialogService: DialogService,
|
||||
) {
|
||||
super(vaultFilterService, policyService, i18nService, platformUtilsService);
|
||||
super(
|
||||
vaultFilterService,
|
||||
policyService,
|
||||
i18nService,
|
||||
platformUtilsService,
|
||||
billingApiService,
|
||||
dialogService,
|
||||
);
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
|
@ -1,3 +1,25 @@
|
||||
<ng-container *ngIf="freeTrial$ | async as freeTrial">
|
||||
<bit-banner
|
||||
id="free-trial-banner"
|
||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||
icon="bwi-billing"
|
||||
bannerType="premium"
|
||||
[showClose]="false"
|
||||
*ngIf="!refreshing && freeTrial.shownBanner"
|
||||
>
|
||||
{{ freeTrial.message }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="contrast"
|
||||
(click)="navigateToPaymentMethod()"
|
||||
class="tw-cursor-pointer"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{ "routeToPaymentMethodTrigger" | i18n }}
|
||||
</a>
|
||||
</bit-banner>
|
||||
</ng-container>
|
||||
|
||||
<app-org-vault-header
|
||||
[filter]="filter"
|
||||
[loading]="refreshing"
|
||||
|
@ -13,8 +13,10 @@ import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
from,
|
||||
lastValueFrom,
|
||||
Observable,
|
||||
of,
|
||||
Subject,
|
||||
} from "rxjs";
|
||||
import {
|
||||
@ -43,8 +45,10 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
@ -63,7 +67,13 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
import { DialogService, Icons, NoItemsModule, ToastService } from "@bitwarden/components";
|
||||
import {
|
||||
DialogService,
|
||||
Icons,
|
||||
NoItemsModule,
|
||||
ToastService,
|
||||
BannerModule,
|
||||
} from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfig,
|
||||
CipherFormConfigService,
|
||||
@ -73,6 +83,8 @@ import {
|
||||
|
||||
import { GroupService, GroupView } from "../../admin-console/organizations/core";
|
||||
import { openEntityEventsDialog } from "../../admin-console/organizations/manage/entity-events.component";
|
||||
import { TrialFlowService } from "../../billing/services/trial-flow.service";
|
||||
import { FreeTrial } from "../../core/types/free-trial";
|
||||
import { SharedModule } from "../../shared";
|
||||
import { VaultFilterService } from "../../vault/individual-vault/vault-filter/services/abstractions/vault-filter.service";
|
||||
import { VaultFilter } from "../../vault/individual-vault/vault-filter/shared/models/vault-filter.model";
|
||||
@ -130,6 +142,7 @@ enum AddAccessStatusType {
|
||||
VaultFilterModule,
|
||||
VaultItemsModule,
|
||||
SharedModule,
|
||||
BannerModule,
|
||||
NoItemsModule,
|
||||
],
|
||||
providers: [
|
||||
@ -166,6 +179,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
protected isEmpty: boolean;
|
||||
protected showCollectionAccessRestricted: boolean;
|
||||
protected currentSearchText$: Observable<string>;
|
||||
protected freeTrial$: Observable<FreeTrial>;
|
||||
/**
|
||||
* A list of collections that the user can assign items to and edit those items within.
|
||||
* @protected
|
||||
@ -183,6 +197,21 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
|
||||
private extensionRefreshEnabled: boolean;
|
||||
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
|
||||
private readonly unpaidSubscriptionDialog$ = this.organizationService.organizations$.pipe(
|
||||
filter((organizations) => organizations.length === 1),
|
||||
switchMap(([organization]) =>
|
||||
from(this.billingApiService.getOrganizationBillingMetadata(organization.id)).pipe(
|
||||
switchMap((organizationMetaData) =>
|
||||
from(
|
||||
this.trialFlowService.handleUnpaidSubscriptionDialog(
|
||||
organization,
|
||||
organizationMetaData,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@ -214,6 +243,9 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
private cipherFormConfigService: CipherFormConfigService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private trialFlowService: TrialFlowService,
|
||||
protected billingApiService: BillingApiServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@ -546,6 +578,26 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe();
|
||||
|
||||
this.freeTrial$ = organization$.pipe(
|
||||
filter((org) => org.isOwner),
|
||||
switchMap((org) =>
|
||||
combineLatest([
|
||||
of(org),
|
||||
this.organizationApiService.getSubscription(org.id),
|
||||
this.organizationApiService.getBilling(org.id),
|
||||
]),
|
||||
),
|
||||
map(([org, sub, billing]) => {
|
||||
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
org,
|
||||
sub,
|
||||
billing?.paymentSource,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
firstSetup$
|
||||
.pipe(
|
||||
switchMap(() => this.refresh$),
|
||||
@ -596,6 +648,13 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
async navigateToPaymentMethod() {
|
||||
await this.router.navigate(
|
||||
["organizations", `${this.organization?.id}`, "billing", "payment-method"],
|
||||
{ state: { launchPaymentModalAutomatically: true } },
|
||||
);
|
||||
}
|
||||
|
||||
addAccessToggle(e: AddAccessStatusType) {
|
||||
this.addAccessStatus$.next(e);
|
||||
}
|
||||
|
@ -3837,6 +3837,55 @@
|
||||
"updateBrowserDesc": {
|
||||
"message": "You are using an unsupported web browser. The web vault may not function properly."
|
||||
},
|
||||
"freeTrialEndPrompt": {
|
||||
"message": "Your free trial ends in $COUNT$ days. To maintain your subscription,",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"content": "$1",
|
||||
"example": "You must set up 2FA on your user account before you can join this organization."
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeTrialEndPromptAboveTwoDays": {
|
||||
"message": "$ORGANIZATION$, your free trial ends in $COUNT$ days. To maintain your subscription,",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"content": "$2",
|
||||
"example": "organization name"
|
||||
},
|
||||
"organization": {
|
||||
"content": "$1",
|
||||
"example": "remaining days"
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeTrialEndPromptForOneDay": {
|
||||
"message": "$ORGANIZATION$, your free trial ends tomorrow. To maintain your subscription,",
|
||||
"placeholders": {
|
||||
"organization": {
|
||||
"content": "$1",
|
||||
"example": "organization name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeTrialEndPromptForOneDayNoOrgName": {
|
||||
"message": "Your free trial ends tomorrow. To maintain your subscription,"
|
||||
},
|
||||
"freeTrialEndPromptForLessThanADay": {
|
||||
"message": "$ORGANIZATION$, your free trial ends today. To maintain your subscription,",
|
||||
"placeholders": {
|
||||
"organization": {
|
||||
"content": "$1",
|
||||
"example": "organization name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeTrialEndingSoonWithoutOrgName": {
|
||||
"message": "Your free trial ends today. To maintain your subscription,"
|
||||
},
|
||||
"routeToPaymentMethodTrigger": {
|
||||
"message": "add a payment method."
|
||||
},
|
||||
"joinOrganization": {
|
||||
"message": "Join organization"
|
||||
},
|
||||
@ -8444,7 +8493,7 @@
|
||||
},
|
||||
"addAPaymentMethod": {
|
||||
"message": "add a payment method",
|
||||
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method.'"
|
||||
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'To maintain your subscription for $ORG$, add a payment method'"
|
||||
},
|
||||
"organizationInformation": {
|
||||
"message": "Organization information"
|
||||
@ -9631,5 +9680,20 @@
|
||||
"example": "First 8 Character of a GUID"
|
||||
}
|
||||
}
|
||||
},
|
||||
"suspendedOrganizationTitle": {
|
||||
"message": "The $ORGANIZATION$ is suspended",
|
||||
"placeholders": {
|
||||
"organization": {
|
||||
"content": "$1",
|
||||
"example": "Acme c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"suspendedUserOrgMessage": {
|
||||
"message": "Contact your organization owner for assistance."
|
||||
},
|
||||
"suspendedOwnerOrgMessage": {
|
||||
"message": "To regain access to your organization, add a payment method."
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,24 @@
|
||||
<ng-container *ngIf="freeTrial$ | async as freeTrial">
|
||||
<bit-banner
|
||||
id="update-browser-banner"
|
||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||
bannerType="premium"
|
||||
icon="bwi-billing"
|
||||
[showClose]="false"
|
||||
*ngIf="!loading && freeTrial.shownBanner"
|
||||
>
|
||||
{{ freeTrial.message }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="contrast"
|
||||
class="tw-cursor-pointer"
|
||||
(click)="navigateToPaymentMethod()"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{ "routeToPaymentMethodTrigger" | i18n }}
|
||||
</a>
|
||||
</bit-banner>
|
||||
</ng-container>
|
||||
<app-header [title]="organizationName">
|
||||
<sm-new-menu></sm-new-menu>
|
||||
</app-header>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
map,
|
||||
Observable,
|
||||
@ -12,14 +12,20 @@ import {
|
||||
take,
|
||||
share,
|
||||
firstValueFrom,
|
||||
concatMap,
|
||||
of,
|
||||
filter,
|
||||
} from "rxjs";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { TrialFlowService } from "@bitwarden/web-vault/app/billing/services/trial-flow.service";
|
||||
import { FreeTrial } from "@bitwarden/web-vault/app/core/types/free-trial";
|
||||
|
||||
import { OrganizationCounts } from "../models/view/counts.view";
|
||||
import { ProjectListView } from "../models/view/project-list.view";
|
||||
@ -81,6 +87,8 @@ export class OverviewComponent implements OnInit, OnDestroy {
|
||||
protected showOnboarding = false;
|
||||
protected loading = true;
|
||||
protected organizationEnabled = false;
|
||||
protected organization: Organization;
|
||||
protected i18n: I18nPipe;
|
||||
protected onboardingTasks$: Observable<SMOnboardingTasks>;
|
||||
|
||||
protected view$: Observable<{
|
||||
@ -91,6 +99,7 @@ export class OverviewComponent implements OnInit, OnDestroy {
|
||||
tasks: OrganizationTasks;
|
||||
counts: OrganizationCounts;
|
||||
}>;
|
||||
protected freeTrial$: Observable<FreeTrial>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@ -104,6 +113,10 @@ export class OverviewComponent implements OnInit, OnDestroy {
|
||||
private i18nService: I18nService,
|
||||
private smOnboardingTasksService: SMOnboardingTasksService,
|
||||
private logService: LogService,
|
||||
private router: Router,
|
||||
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private trialFlowService: TrialFlowService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@ -114,18 +127,35 @@ export class OverviewComponent implements OnInit, OnDestroy {
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
orgId$
|
||||
.pipe(
|
||||
concatMap(async (orgId) => await this.organizationService.get(orgId)),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((org) => {
|
||||
this.organizationId = org.id;
|
||||
this.organizationName = org.name;
|
||||
this.userIsAdmin = org.isAdmin;
|
||||
this.loading = true;
|
||||
this.organizationEnabled = org.enabled;
|
||||
});
|
||||
const org$ = orgId$.pipe(switchMap((orgId) => this.organizationService.get(orgId)));
|
||||
|
||||
org$.pipe(takeUntil(this.destroy$)).subscribe((org) => {
|
||||
this.organizationId = org.id;
|
||||
this.organization = org;
|
||||
this.organizationName = org.name;
|
||||
this.userIsAdmin = org.isAdmin;
|
||||
this.loading = true;
|
||||
this.organizationEnabled = org.enabled;
|
||||
});
|
||||
|
||||
this.freeTrial$ = org$.pipe(
|
||||
filter((org) => org.isOwner),
|
||||
switchMap((org) =>
|
||||
combineLatest([
|
||||
of(org),
|
||||
this.organizationApiService.getSubscription(org.id),
|
||||
this.organizationApiService.getBilling(org.id),
|
||||
]),
|
||||
),
|
||||
map(([org, sub, billing]) => {
|
||||
return this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
org,
|
||||
sub,
|
||||
billing?.paymentSource,
|
||||
);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
);
|
||||
|
||||
const projects$ = combineLatest([
|
||||
orgId$,
|
||||
@ -197,6 +227,15 @@ export class OverviewComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async navigateToPaymentMethod() {
|
||||
await this.router.navigate(
|
||||
["organizations", `${this.organizationId}`, "billing", "payment-method"],
|
||||
{
|
||||
state: { launchPaymentModalAutomatically: true },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BannerModule } from "@bitwarden/components";
|
||||
|
||||
import { OnboardingModule } from "../../../../../../apps/web/src/app/shared/components/onboarding/onboarding.module";
|
||||
import { SecretsManagerSharedModule } from "../shared/sm-shared.module";
|
||||
|
||||
@ -8,7 +10,7 @@ import { OverviewComponent } from "./overview.component";
|
||||
import { SectionComponent } from "./section.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule],
|
||||
imports: [SecretsManagerSharedModule, OverviewRoutingModule, OnboardingModule, BannerModule],
|
||||
declarations: [OverviewComponent, SectionComponent],
|
||||
providers: [],
|
||||
})
|
||||
|
@ -6,6 +6,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
|
||||
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
|
||||
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
|
||||
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
|
||||
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
|
||||
import { PaymentRequest } from "../../../billing/models/request/payment.request";
|
||||
@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction {
|
||||
getLicense: (id: string, installationId: string) => Promise<unknown>;
|
||||
getAutoEnrollStatus: (identifier: string) => Promise<OrganizationAutoEnrollStatusResponse>;
|
||||
create: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>;
|
||||
createWithoutPayment: (
|
||||
request: OrganizationNoPaymentMethodCreateRequest,
|
||||
) => Promise<OrganizationResponse>;
|
||||
createLicense: (data: FormData) => Promise<OrganizationResponse>;
|
||||
save: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>;
|
||||
updatePayment: (id: string, request: PaymentRequest) => Promise<void>;
|
||||
|
@ -1,32 +1,7 @@
|
||||
import { PaymentMethodType, PlanType } from "../../../billing/enums";
|
||||
import { InitiationPath } from "../../../models/request/reference-event.request";
|
||||
import { PaymentMethodType } from "../../../billing/enums";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
|
||||
|
||||
import { OrganizationKeysRequest } from "./organization-keys.request";
|
||||
|
||||
export class OrganizationCreateRequest {
|
||||
name: string;
|
||||
businessName: string;
|
||||
billingEmail: string;
|
||||
planType: PlanType;
|
||||
key: string;
|
||||
keys: OrganizationKeysRequest;
|
||||
export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest {
|
||||
paymentMethodType: PaymentMethodType;
|
||||
paymentToken: string;
|
||||
additionalSeats: number;
|
||||
maxAutoscaleSeats: number;
|
||||
additionalStorageGb: number;
|
||||
premiumAccessAddon: boolean;
|
||||
collectionName: string;
|
||||
taxIdNumber: string;
|
||||
billingAddressLine1: string;
|
||||
billingAddressLine2: string;
|
||||
billingAddressCity: string;
|
||||
billingAddressState: string;
|
||||
billingAddressPostalCode: string;
|
||||
billingAddressCountry: string;
|
||||
useSecretsManager: boolean;
|
||||
additionalSmSeats: number;
|
||||
additionalServiceAccounts: number;
|
||||
isFromSecretsManagerTrial: boolean;
|
||||
initiationPath: InitiationPath;
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
|
||||
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
|
||||
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
|
||||
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
|
||||
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
|
||||
import { PaymentRequest } from "../../../billing/models/request/payment.request";
|
||||
@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
|
||||
return new OrganizationResponse(r);
|
||||
}
|
||||
|
||||
async createWithoutPayment(
|
||||
request: OrganizationNoPaymentMethodCreateRequest,
|
||||
): Promise<OrganizationResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
"/organizations/create-without-payment",
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
// Forcing a sync will notify organization service that they need to repull
|
||||
await this.syncService.fullSync(true);
|
||||
return new OrganizationResponse(r);
|
||||
}
|
||||
|
||||
async createLicense(data: FormData): Promise<OrganizationResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"POST",
|
||||
|
@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction {
|
||||
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
|
||||
|
||||
startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
|
||||
|
||||
purchaseSubscriptionNoPaymentMethod: (
|
||||
subscription: SubscriptionInformation,
|
||||
) => Promise<OrganizationResponse>;
|
||||
}
|
||||
|
@ -0,0 +1,29 @@
|
||||
import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request";
|
||||
import { InitiationPath } from "../../../models/request/reference-event.request";
|
||||
import { PlanType } from "../../enums";
|
||||
|
||||
export class OrganizationNoPaymentMethodCreateRequest {
|
||||
name: string;
|
||||
businessName: string;
|
||||
billingEmail: string;
|
||||
planType: PlanType;
|
||||
key: string;
|
||||
keys: OrganizationKeysRequest;
|
||||
additionalSeats: number;
|
||||
maxAutoscaleSeats: number;
|
||||
additionalStorageGb: number;
|
||||
premiumAccessAddon: boolean;
|
||||
collectionName: string;
|
||||
taxIdNumber: string;
|
||||
billingAddressLine1: string;
|
||||
billingAddressLine2: string;
|
||||
billingAddressCity: string;
|
||||
billingAddressState: string;
|
||||
billingAddressPostalCode: string;
|
||||
billingAddressCountry: string;
|
||||
useSecretsManager: boolean;
|
||||
additionalSmSeats: number;
|
||||
additionalServiceAccounts: number;
|
||||
isFromSecretsManagerTrial: boolean;
|
||||
initiationPath: InitiationPath;
|
||||
}
|
@ -4,11 +4,13 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
|
||||
isEligibleForSelfHost: boolean;
|
||||
isManaged: boolean;
|
||||
isOnSecretsManagerStandalone: boolean;
|
||||
isSubscriptionUnpaid: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
|
||||
this.isManaged = this.getResponseProperty("IsManaged");
|
||||
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
|
||||
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
SubscriptionInformation,
|
||||
} from "../abstractions/organization-billing.service";
|
||||
import { PlanType } from "../enums";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
|
||||
|
||||
interface OrganizationKeys {
|
||||
encryptedKey: EncString;
|
||||
@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
return response;
|
||||
}
|
||||
|
||||
async purchaseSubscriptionNoPaymentMethod(
|
||||
subscription: SubscriptionInformation,
|
||||
): Promise<OrganizationResponse> {
|
||||
const request = new OrganizationNoPaymentMethodCreateRequest();
|
||||
|
||||
const organizationKeys = await this.makeOrganizationKeys();
|
||||
|
||||
this.setOrganizationKeys(request, organizationKeys);
|
||||
|
||||
this.setOrganizationInformation(request, subscription.organization);
|
||||
|
||||
this.setPlanInformation(request, subscription.plan);
|
||||
|
||||
const response = await this.organizationApiService.createWithoutPayment(request);
|
||||
|
||||
await this.apiService.refreshIdentityToken();
|
||||
|
||||
await this.syncService.fullSync(true);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async makeOrganizationKeys(): Promise<OrganizationKeys> {
|
||||
const [encryptedKey, key] = await this.keyService.makeOrgKey<OrgKey>();
|
||||
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key);
|
||||
@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
}
|
||||
|
||||
private setOrganizationInformation(
|
||||
request: OrganizationCreateRequest,
|
||||
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
|
||||
information: OrganizationInformation,
|
||||
): void {
|
||||
request.name = information.name;
|
||||
@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
request.initiationPath = information.initiationPath;
|
||||
}
|
||||
|
||||
private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void {
|
||||
private setOrganizationKeys(
|
||||
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
|
||||
keys: OrganizationKeys,
|
||||
): void {
|
||||
request.key = keys.encryptedKey.encryptedString;
|
||||
request.keys = new OrganizationKeysRequest(
|
||||
keys.publicKey,
|
||||
@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
}
|
||||
|
||||
private setPlanInformation(
|
||||
request: OrganizationCreateRequest,
|
||||
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
|
||||
information: PlanInformation,
|
||||
): void {
|
||||
request.planType = information.type;
|
||||
|
@ -38,6 +38,7 @@ export enum FeatureFlag {
|
||||
Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions",
|
||||
LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split",
|
||||
CriticalApps = "pm-14466-risk-insights-critical-application",
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@ -86,6 +87,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE,
|
||||
[FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE,
|
||||
[FeatureFlag.CriticalApps]: FALSE,
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
Loading…
Reference in New Issue
Block a user