1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00
Files
browser/apps/web/src/app/billing/trial-initiation/complete-trial-initiation/complete-trial-initiation.component.ts
Kyle Denney e3acd27dec [PM-24284] - milestone 3 (#17230)
* first draft

# Conflicts:
#	apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts
#	apps/web/src/app/billing/organizations/organization-plans.component.ts
#	libs/common/src/billing/services/subscription-pricing.service.ts
#	libs/common/src/enums/feature-flag.enum.ts

* more filtering for pricing cards

* prettier

* tests

* tests v2
2025-11-10 11:50:49 -06:00

459 lines
16 KiB
TypeScript

import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, map, Subject, switchMap, takeUntil } from "rxjs";
import {
InputPasswordFlow,
PasswordInputResult,
RegistrationFinishService,
} from "@bitwarden/auth/angular";
import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
import {
OrganizationBillingServiceAbstraction as OrganizationBillingService,
OrganizationInformation,
PlanInformation,
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType, ProductTierType, ProductType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
import { UserId } from "@bitwarden/user-core";
import { Trial } from "@bitwarden/web-vault/app/billing/trial-initiation/trial-billing-step/trial-billing-step.service";
import { RouterService } from "../../../core/router.service";
import { OrganizationCreatedEvent } from "../trial-billing-step/trial-billing-step.component";
import { VerticalStepperComponent } from "../vertical-stepper/vertical-stepper.component";
export type InitiationPath =
| "Password Manager trial from marketing website"
| "Secrets Manager trial from marketing website";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-complete-trial-initiation",
templateUrl: "complete-trial-initiation.component.html",
standalone: false,
})
export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("stepper", { static: false }) verticalStepper!: VerticalStepperComponent;
inputPasswordFlow = InputPasswordFlow.SetInitialPasswordAccountRegistration;
initializing = true;
/** Password Manager or Secrets Manager */
product?: ProductType;
/** The tier of product being subscribed to */
productTier!: ProductTierType;
/** Product types that display steppers for Password Manager */
stepperProductTypes: ProductTierType[] = [
ProductTierType.Teams,
ProductTierType.Enterprise,
ProductTierType.Families,
];
/** Display multi-step trial flow when true */
useTrialStepper = false;
/** True, registering a password is in progress */
submitting = false;
/** Valid product types, used to filter out invalid query parameters */
validProducts = [ProductType.PasswordManager, ProductType.SecretsManager];
orgInfoSubLabel = "";
orgId = "";
orgLabel = "";
billingSubLabel = "";
enforcedPolicyOptions?: MasterPasswordPolicyOptions;
/** User's email address associated with the trial */
email = "";
/** Token from the backend associated with the email verification */
emailVerificationToken?: string;
loading = false;
productTierValue?: ProductTierType;
trialLength!: number;
orgInfoFormGroup = this.formBuilder.group({
name: ["", { validators: [Validators.required, Validators.maxLength(50)], updateOn: "change" }],
billingEmail: [""],
});
private destroy$ = new Subject<void>();
protected readonly ProductType = ProductType;
protected trialPaymentOptional$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);
constructor(
protected router: Router,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private logService: LogService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private i18nService: I18nService,
private routerService: RouterService,
private organizationBillingService: OrganizationBillingService,
private organizationInviteService: OrganizationInviteService,
private toastService: ToastService,
private registrationFinishService: RegistrationFinishService,
private validationService: ValidationService,
private loginStrategyService: LoginStrategyServiceAbstraction,
private configService: ConfigService,
private accountService: AccountService,
) {}
async ngOnInit(): Promise<void> {
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => {
// Retrieve email from query params
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
this.email = qParams.email;
this.orgInfoFormGroup.controls.billingEmail.setValue(qParams.email);
}
// Show email validation toast when coming from email
if (qParams.fromEmail && qParams.fromEmail === "true") {
this.toastService.showToast({
title: "",
message: this.i18nService.t("emailVerifiedV2"),
variant: "success",
});
}
if (qParams.token != null) {
this.emailVerificationToken = qParams.token;
}
const product = parseInt(qParams.product);
// Get product from query params, default to password manager
this.product = this.validProducts.includes(product) ? product : ProductType.PasswordManager;
const productTierParam = parseInt(qParams.productTier) as ProductTierType;
this.productTierValue = productTierParam;
/** Only show the trial stepper for a subset of types */
const showPasswordManagerStepper = this.stepperProductTypes.includes(productTierParam);
/** All types of secret manager should see the trial stepper */
const showSecretsManagerStepper = this.product === ProductType.SecretsManager;
if ((showPasswordManagerStepper || showSecretsManagerStepper) && !isNaN(productTierParam)) {
this.productTier = productTierParam;
this.orgLabel = this.planTypeDisplay;
this.useTrialStepper = true;
}
this.trialLength = qParams.trialLength ? parseInt(qParams.trialLength) : 7;
// Are they coming from an email for sponsoring a families organization
// After logging in redirect them to setup the families sponsorship
this.setupFamilySponsorship(qParams.sponsorshipToken);
});
const invite = await this.organizationInviteService.getOrganizationInvite();
let policies: Policy[] | undefined | null = null;
if (
invite != null &&
invite.organizationId &&
invite.token &&
invite.email &&
invite.organizationUserId
) {
try {
policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
}
if (policies !== null) {
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)),
takeUntil(this.destroy$),
)
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPolicyOptions = enforcedPasswordPolicyOptions;
});
}
this.orgInfoFormGroup.controls.name.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.orgInfoFormGroup.controls.name.markAsTouched();
});
this.initializing = false;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/** Handle manual stepper change */
verticalStepChange(event: StepperSelectionEvent) {
if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") {
this.orgInfoSubLabel = this.planInfoLabel;
} else if (event.previouslySelectedIndex === 1) {
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value!;
}
}
async orgNameEntrySubmit(): Promise<void> {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const isTrialPaymentOptional = await firstValueFrom(this.trialPaymentOptional$);
/** Only skip payment if the flag is on AND trialLength > 0 */
if (isTrialPaymentOptional && this.trialLength > 0) {
await this.createOrganizationOnTrial(activeUserId);
} else {
await this.conditionallyCreateOrganization(activeUserId);
}
}
/** Update local details from organization created event */
createdOrganization(event: OrganizationCreatedEvent) {
this.orgId = event.organizationId;
this.billingSubLabel = event.planDescription;
this.verticalStepper.next();
}
/** create an organization on trial without payment method */
async createOrganizationOnTrial(activeUserId: UserId) {
this.loading = true;
let trialInitiationPath: InitiationPath = "Password Manager trial from marketing website";
let plan: PlanInformation = {
type: await this.getPlanType(),
passwordManagerSeats: 1,
};
if (this.product === ProductType.SecretsManager) {
trialInitiationPath = "Secrets Manager trial from marketing website";
plan = {
...plan,
subscribeToSecretsManager: true,
isFromSecretsManagerTrial: true,
secretsManagerSeats: 1,
};
}
const organization: OrganizationInformation = {
name: this.orgInfoFormGroup.value.name == null ? "" : this.orgInfoFormGroup.value.name,
billingEmail:
this.orgInfoFormGroup.value.billingEmail == null
? ""
: this.orgInfoFormGroup.value.billingEmail,
initiationPath: trialInitiationPath,
};
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod(
{
organization,
plan,
},
activeUserId,
);
this.orgId = response?.id;
this.billingSubLabel = response.name.toString();
this.loading = false;
this.verticalStepper.next();
}
/** Move the user to the previous step */
previousStep() {
this.verticalStepper.previous();
}
async getPlanType() {
const milestone3FeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM26462_Milestone_3,
);
const familyPlan = milestone3FeatureEnabled
? PlanType.FamiliesAnnually
: PlanType.FamiliesAnnually2025;
switch (this.productTier) {
case ProductTierType.Teams:
return PlanType.TeamsAnnually;
case ProductTierType.Enterprise:
return PlanType.EnterpriseAnnually;
case ProductTierType.Families:
return familyPlan;
case ProductTierType.Free:
return PlanType.Free;
default:
return PlanType.EnterpriseAnnually;
}
}
get isSecretsManagerFree() {
return this.product === ProductType.SecretsManager && this.productTier === ProductTierType.Free;
}
get planTypeDisplay() {
switch (this.productTier) {
case ProductTierType.Teams:
return "Teams";
case ProductTierType.Enterprise:
return "Enterprise";
case ProductTierType.Families:
return "Families";
default:
return "";
}
}
get planInfoLabel() {
switch (this.productTier) {
case ProductTierType.Teams:
return this.i18nService.t("enterTeamsOrgInfo");
case ProductTierType.Enterprise:
return this.i18nService.t("enterEnterpriseOrgInfo");
case ProductTierType.Families:
return this.i18nService.t("enterFamiliesOrgInfo");
default:
return "";
}
}
readonly showBillingStep$ = this.trialPaymentOptional$.pipe(
map((trialPaymentOptional) => {
return (
(!trialPaymentOptional && !this.isSecretsManagerFree) ||
(trialPaymentOptional && this.trialLength === 0)
);
}),
);
/** Create an organization unless the trial is for secrets manager */
async conditionallyCreateOrganization(activeUserId: UserId): Promise<void> {
if (!this.isSecretsManagerFree) {
this.verticalStepper.next();
return;
}
const response = await this.organizationBillingService.startFree(
{
organization: {
name: this.orgInfoFormGroup.value.name == null ? "" : this.orgInfoFormGroup.value.name,
billingEmail:
this.orgInfoFormGroup.value.billingEmail == null
? ""
: this.orgInfoFormGroup.value.billingEmail,
initiationPath: "Password Manager trial from marketing website",
},
plan: {
type: 0,
subscribeToSecretsManager: true,
isFromSecretsManagerTrial: true,
},
},
activeUserId,
);
this.orgId = response.id;
this.verticalStepper.next();
}
/**
* Complete the users registration with their password.
*
* When a the trial stepper isn't used, redirect the user to the login page.
*/
async handlePasswordSubmit(passwordInputResult: PasswordInputResult) {
if (!this.useTrialStepper) {
await this.finishRegistration(passwordInputResult);
this.submitting = false;
await this.router.navigate(["/login"], { queryParams: { email: this.email } });
return;
}
await this.finishRegistration(passwordInputResult);
await this.logIn(passwordInputResult.newPassword);
this.submitting = false;
this.verticalStepper.next();
}
private setupFamilySponsorship(sponsorshipToken: string) {
if (sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
queryParams: { plan: sponsorshipToken },
});
this.routerService.setPreviousUrl(route.toString());
}
}
/** Logs the user in */
private async logIn(masterPassword: string): Promise<void> {
const credentials = new PasswordLoginCredentials(this.email, masterPassword);
await this.loginStrategyService.logIn(credentials);
}
async finishRegistration(passwordInputResult: PasswordInputResult) {
this.submitting = true;
return this.registrationFinishService
.finishRegistration(this.email, passwordInputResult, this.emailVerificationToken)
.catch((e: unknown): null => {
this.validationService.showError(e);
this.submitting = false;
return null;
});
}
get trial(): Trial {
const product =
this.product === ProductType.PasswordManager ? "passwordManager" : "secretsManager";
const tier =
this.productTier === ProductTierType.Families
? "families"
: this.productTier === ProductTierType.Teams
? "teams"
: "enterprise";
return {
organization: {
name: this.orgInfoFormGroup.value.name!,
email: this.orgInfoFormGroup.value.billingEmail!,
},
product,
tier,
length: this.trialLength,
};
}
}