1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-03 00:53:23 +00:00

Merge branch 'main' into auth/pm-8111/browser-refresh-login-component

This commit is contained in:
rr-bw
2024-09-07 12:45:18 -07:00
194 changed files with 5670 additions and 1594 deletions

View File

@@ -428,9 +428,12 @@
{{ paymentDesc }}
</p>
<app-payment
*ngIf="createOrganization || upgradeRequiresPaymentMethod"
*ngIf="!deprecateStripeSourcesAPI && (createOrganization || upgradeRequiresPaymentMethod)"
[hideCredit]="true"
></app-payment>
<app-payment-v2
*ngIf="deprecateStripeSourcesAPI && (createOrganization || upgradeRequiresPaymentMethod)"
></app-payment-v2>
<app-tax-info (onCountryChanged)="changedCountry()"></app-tax-info>
<div id="price" class="tw-my-4">
<div class="tw-text-muted tw-text-base">

View File

@@ -28,6 +28,8 @@ import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -41,6 +43,7 @@ import { ToastService } from "@bitwarden/components";
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
import { BillingSharedModule, secretsManagerSubscribeFormFactory } from "../shared";
import { PaymentV2Component } from "../shared/payment/payment-v2.component";
import { PaymentComponent } from "../shared/payment/payment.component";
import { TaxInfoComponent } from "../shared/tax-info.component";
@@ -63,6 +66,7 @@ const Allowed2020PlansForLegacyProviders = [
})
export class OrganizationPlansComponent implements OnInit, OnDestroy {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(PaymentV2Component) paymentV2Component: PaymentV2Component;
@ViewChild(TaxInfoComponent) taxComponent: TaxInfoComponent;
@Input() organizationId: string;
@@ -108,6 +112,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
singleOrgPolicyAppliesToActiveUser = false;
isInTrialFlow = false;
discount = 0;
deprecateStripeSourcesAPI: boolean;
secretsManagerSubscription = secretsManagerSubscribeFormFactory(this.formBuilder);
@@ -152,11 +157,16 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
private organizationApiService: OrganizationApiServiceAbstraction,
private providerApiService: ProviderApiServiceAbstraction,
private toastService: ToastService,
private configService: ConfigService,
) {
this.selfHosted = platformUtilsService.isSelfHost();
}
async ngOnInit() {
this.deprecateStripeSourcesAPI = await this.configService.getFeatureFlag(
FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
);
if (this.organizationId) {
this.organization = await this.organizationService.get(this.organizationId);
this.billing = await this.organizationApiService.getBilling(this.organizationId);
@@ -535,14 +545,23 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
}
changedCountry() {
this.paymentComponent.hideBank = this.taxComponent.taxFormGroup?.value.country !== "US";
// Bank Account payments are only available for US customers
if (
this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount
) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
if (this.deprecateStripeSourcesAPI) {
this.paymentV2Component.showBankAccount = this.taxComponent.country === "US";
if (
!this.paymentV2Component.showBankAccount &&
this.paymentV2Component.selected === PaymentMethodType.BankAccount
) {
this.paymentV2Component.select(PaymentMethodType.Card);
}
} else {
this.paymentComponent.hideBank = this.taxComponent.taxFormGroup?.value.country !== "US";
if (
this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount
) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
}
@@ -639,10 +658,18 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.buildSecretsManagerRequest(request);
if (this.upgradeRequiresPaymentMethod) {
const tokenResult = await this.paymentComponent.createPaymentToken();
let type: PaymentMethodType;
let token: string;
if (this.deprecateStripeSourcesAPI) {
({ type, token } = await this.paymentV2Component.tokenize());
} else {
[token, type] = await this.paymentComponent.createPaymentToken();
}
const paymentRequest = new PaymentRequest();
paymentRequest.paymentToken = tokenResult[0];
paymentRequest.paymentMethodType = tokenResult[1];
paymentRequest.paymentToken = token;
paymentRequest.paymentMethodType = type;
paymentRequest.country = this.taxComponent.taxFormGroup?.value.country;
paymentRequest.postalCode = this.taxComponent.taxFormGroup?.value.postalCode;
await this.organizationApiService.updatePayment(this.organizationId, paymentRequest);
@@ -679,10 +706,17 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
if (this.selectedPlan.type === PlanType.Free) {
request.planType = PlanType.Free;
} else {
const tokenResult = await this.paymentComponent.createPaymentToken();
let type: PaymentMethodType;
let token: string;
request.paymentToken = tokenResult[0];
request.paymentMethodType = tokenResult[1];
if (this.deprecateStripeSourcesAPI) {
({ type, token } = await this.paymentV2Component.tokenize());
} else {
[token, type] = await this.paymentComponent.createPaymentToken();
}
request.paymentToken = token;
request.paymentMethodType = type;
request.additionalSeats = this.formGroup.controls.additionalSeats.value;
request.additionalStorageGb = this.formGroup.controls.additionalStorage.value;
request.premiumAccessAddon =

View File

@@ -0,0 +1,4 @@
import { NgModule } from "@angular/core";
@NgModule({})
export class BillingServicesModule {}

View File

@@ -0,0 +1,112 @@
import { Injectable } from "@angular/core";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { BillingServicesModule } from "./billing-services.module";
@Injectable({ providedIn: BillingServicesModule })
export class BraintreeService {
private braintree: any;
private containerId: string;
constructor(private logService: LogService) {}
/**
* Utilizes the Braintree SDK to create a [Braintree drop-in]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html} instance attached to the container ID specified as part of the {@link loadBraintree} method.
*/
createDropin() {
window.setTimeout(() => {
const window$ = window as any;
window$.braintree.dropin.create(
{
authorization: process.env.BRAINTREE_KEY,
container: this.containerId,
paymentOptionPriority: ["paypal"],
paypal: {
flow: "vault",
buttonStyle: {
label: "pay",
size: "medium",
shape: "pill",
color: "blue",
tagline: "false",
},
},
},
(error: any, instance: any) => {
if (error != null) {
this.logService.error(error);
return;
}
this.braintree = instance;
},
);
}, 250);
}
/**
* Loads the Bitwarden dropin.js script in the <head> element of the current page.
* This script attaches the Braintree SDK to the window.
* @param containerId - The ID of the HTML element where the Braintree drop-in will be loaded at.
* @param autoCreateDropin - Specifies whether the Braintree drop-in should be created when dropin.js loads.
*/
loadBraintree(containerId: string, autoCreateDropin: boolean) {
const script = window.document.createElement("script");
script.id = "dropin-script";
script.src = `scripts/dropin.js?cache=${process.env.CACHE_TAG}`;
script.async = true;
if (autoCreateDropin) {
script.onload = () => this.createDropin();
}
this.containerId = containerId;
window.document.head.appendChild(script);
}
/**
* Invokes the Braintree [requestPaymentMethod]{@link https://braintree.github.io/braintree-web-drop-in/docs/current/Dropin.html#requestPaymentMethod} method
* in order to generate a payment method token using the active Braintree drop-in.
*/
requestPaymentMethod(): Promise<string> {
return new Promise((resolve, reject) => {
this.braintree.requestPaymentMethod((error: any, payload: any) => {
if (error) {
this.logService.error(error);
reject(error.message);
} else {
resolve(payload.nonce as string);
}
});
});
}
/**
* Removes the following elements from the <head> of the current page:
* - The Bitwarden dropin.js script
* - Any <script> elements that contain the word "paypal"
* - The Braintree drop-in stylesheet
*/
unloadBraintree() {
const script = window.document.getElementById("dropin-script");
window.document.head.removeChild(script);
window.setTimeout(() => {
const scripts = Array.from(window.document.head.querySelectorAll("script")).filter(
(script) => script.src != null && script.src.indexOf("paypal") > -1,
);
scripts.forEach((script) => {
try {
window.document.head.removeChild(script);
} catch (error) {
this.logService.error(error);
}
});
const stylesheet = window.document.head.querySelector("#braintree-dropin-stylesheet");
if (stylesheet != null) {
try {
window.document.head.removeChild(stylesheet);
} catch (error) {
this.logService.error(error);
}
}
}, 500);
}
}

View File

@@ -0,0 +1,3 @@
export * from "./billing-services.module";
export * from "./braintree.service";
export * from "./stripe.service";

View File

@@ -0,0 +1,173 @@
import { Injectable } from "@angular/core";
import { BankAccount } from "@bitwarden/common/billing/models/domain";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { BillingServicesModule } from "./billing-services.module";
@Injectable({ providedIn: BillingServicesModule })
export class StripeService {
private stripe: any;
private elements: any;
private elementIds: {
cardNumber: string;
cardExpiry: string;
cardCvc: string;
};
constructor(private logService: LogService) {}
/**
* Loads [Stripe JS]{@link https://docs.stripe.com/js} in the <head> element of the current page and mounts
* Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements with the provided element IDS.
* We do this to avoid having to load the Stripe JS SDK on every page of the Web Vault given many pages contain sensitive information.
* @param elementIds - The ID attributes of the HTML elements used to load the Stripe JS credit card elements.
* @param autoMount - A flag indicating whether you want to immediately mount the Stripe credit card elements.
*/
loadStripe(
elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string },
autoMount: boolean,
) {
this.elementIds = elementIds;
const script = window.document.createElement("script");
script.id = "stripe-script";
script.src = "https://js.stripe.com/v3?advancedFraudSignals=false";
script.onload = () => {
const window$ = window as any;
this.stripe = window$.Stripe(process.env.STRIPE_KEY);
this.elements = this.stripe.elements();
const options = this.getElementOptions();
setTimeout(() => {
this.elements.create("cardNumber", options);
this.elements.create("cardExpiry", options);
this.elements.create("cardCvc", options);
if (autoMount) {
this.mountElements();
}
}, 50);
};
window.document.head.appendChild(script);
}
/**
* Re-mounts previously created Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements
* specified during the {@link loadStripe} call. This is useful for when those HTML elements are removed from the DOM by Angular.
*/
mountElements() {
setTimeout(() => {
const cardNumber = this.elements.getElement("cardNumber");
const cardExpiry = this.elements.getElement("cardExpiry");
const cardCvc = this.elements.getElement("cardCvc");
cardNumber.mount(this.elementIds.cardNumber);
cardExpiry.mount(this.elementIds.cardExpiry);
cardCvc.mount(this.elementIds.cardCvc);
});
}
/**
* Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret
* to invoke the Stripe JS [confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup} method,
* thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}.
* @returns The ID of the newly created PaymentMethod.
*/
async setupBankAccountPaymentMethod(
clientSecret: string,
{ accountHolderName, routingNumber, accountNumber, accountHolderType }: BankAccount,
): Promise<string> {
const result = await this.stripe.confirmUsBankAccountSetup(clientSecret, {
payment_method: {
us_bank_account: {
routing_number: routingNumber,
account_number: accountNumber,
account_holder_type: accountHolderType,
},
billing_details: {
name: accountHolderName,
},
},
});
if (result.error || (result.setupIntent && result.setupIntent.status !== "requires_action")) {
this.logService.error(result.error);
throw result.error;
}
return result.setupIntent.payment_method as string;
}
/**
* Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret
* to invoke the Stripe JS [confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup} method,
* thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}.
* @returns The ID of the newly created PaymentMethod.
*/
async setupCardPaymentMethod(clientSecret: string): Promise<string> {
const cardNumber = this.elements.getElement("cardNumber");
const result = await this.stripe.confirmCardSetup(clientSecret, {
payment_method: {
card: cardNumber,
},
});
if (result.error || (result.setupIntent && result.setupIntent.status !== "succeeded")) {
this.logService.error(result.error);
throw result.error;
}
return result.setupIntent.payment_method as string;
}
/**
* Removes {@link https://docs.stripe.com/js} from the <head> element of the current page as well as all
* Stripe-managed <iframe> elements.
*/
unloadStripe() {
const script = window.document.getElementById("stripe-script");
window.document.head.removeChild(script);
window.setTimeout(() => {
const iFrames = Array.from(window.document.querySelectorAll("iframe")).filter(
(element) => element.src != null && element.src.indexOf("stripe") > -1,
);
iFrames.forEach((iFrame) => {
try {
window.document.body.removeChild(iFrame);
} catch (error) {
this.logService.error(error);
}
});
}, 500);
}
private getElementOptions(): any {
const options: any = {
style: {
base: {
color: null,
fontFamily:
'"Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif, ' +
'"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
fontSize: "14px",
fontSmoothing: "antialiased",
"::placeholder": {
color: null,
},
},
invalid: {
color: null,
},
},
classes: {
focus: "is-focused",
empty: "is-empty",
invalid: "is-invalid",
},
};
const style = getComputedStyle(document.documentElement);
options.style.base.color = `rgb(${style.getPropertyValue("--color-text-main")})`;
options.style.base["::placeholder"].color = `rgb(${style.getPropertyValue(
"--color-text-muted",
)})`;
options.style.invalid.color = `rgb(${style.getPropertyValue("--color-text-main")})`;
options.style.invalid.borderColor = `rgb(${style.getPropertyValue("--color-danger-600")})`;
return options;
}
}

View File

@@ -3,15 +3,12 @@ import { FormControl, FormGroup, Validators } from "@angular/forms";
import { Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import {
BillingApiServiceAbstraction,
BraintreeServiceAbstraction,
StripeServiceAbstraction,
} from "@bitwarden/common/billing/abstractions";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
import { TokenizedPaymentSourceRequest } from "@bitwarden/common/billing/models/request/tokenized-payment-source.request";
import { SharedModule } from "../../../shared";
import { BillingServicesModule, BraintreeService, StripeService } from "../../services";
/**
* Render a form that allows the user to enter their payment method, tokenize it against one of our payment providers and,
@@ -23,7 +20,7 @@ import { SharedModule } from "../../../shared";
selector: "app-payment-v2",
templateUrl: "./payment-v2.component.html",
standalone: true,
imports: [SharedModule],
imports: [BillingServicesModule, SharedModule],
})
export class PaymentV2Component implements OnInit, OnDestroy {
/** Show account credit as a payment option. */
@@ -56,8 +53,8 @@ export class PaymentV2Component implements OnInit, OnDestroy {
constructor(
private billingApiService: BillingApiServiceAbstraction,
private braintreeService: BraintreeServiceAbstraction,
private stripeService: StripeServiceAbstraction,
private braintreeService: BraintreeService,
private stripeService: StripeService,
) {}
ngOnInit(): void {