1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-21 11:35:34 +01:00

[AC-1842] Secrets Manager Trial Page (#7475)

* Got trial page working without the form set up

* Set up the form to create SM subscription

* Add free SM trial page and sign up

* Conner's changes

* fixed imports

* Set isFromSecretsManagerTrial

* Fixed OrgKey location

* Add isFromSecretsManager prop to free org create

* Add LTO callout

* Switch LTO to background box

* Defect: AC-2081

* Fixed typo "Secrets Manger" to "Secrets Manager"

* Removed discount price logic for storage and secrets manager prices since they don't apply

---------

Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com>
Co-authored-by: Conner Turnbull <cturnbull@bitwarden.com>
This commit is contained in:
Alex Morask 2024-01-29 10:45:48 -05:00 committed by GitHub
parent 305fd39871
commit 8468dbab5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1073 additions and 146 deletions

View File

@ -0,0 +1,13 @@
<figure>
<h2 class="tw-mx-auto tw-pb-2 tw-max-w-xl tw-font-semibold tw-text-center">
{{ header }}
</h2>
<blockquote class="tw-mx-auto tw-my-2 tw-max-w-xl tw-px-4 tw-text-center">
"{{ quote }}"
</blockquote>
<figcaption>
<cite>
<p class="tw-mx-auto tw-text-center tw-font-bold">{{ source }}</p>
</cite>
</figcaption>
</figure>

View File

@ -0,0 +1,11 @@
import { Component, Input } from "@angular/core";
@Component({
selector: "app-review-blurb",
templateUrl: "review-blurb.component.html",
})
export class ReviewBlurbComponent {
@Input() header: string;
@Input() quote: string;
@Input() source: string;
}

View File

@ -0,0 +1,32 @@
<h1 class="tw-text-4xl !tw-text-alt2">{{ header }}</h1>
<div class="tw-pt-16">
<h2 class="tw-text-2xl tw-font-semibold">
Secure your business with easy-to-use secrets management
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Straightforward and predictable pricing</li>
<li>Unlimited secure secret storage and sharing</li>
<li>End-to-end encryption</li>
</ul>
<div class="tw-mt-12 tw-flex tw-flex-col">
<div class="tw-rounded-[32px] tw-bg-background">
<div class="tw-my-8 tw-mx-6">
<h2 class="tw-pl-5 tw-font-semibold">Limited time offer</h2>
<ul class="tw-space-y-4 tw-mt-4 tw-pl-10">
<li>
Sign up today and receive a free 12-month subscription to Bitwarden Password Manager
</li>
<li>Experience complete security across your organization</li>
<li>Secure all your sensitive credentials, from passwords to machine secrets</li>
</ul>
</div>
</div>
</div>
<div class="tw-mt-12 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-review-blurb
header="Businesses trust Bitwarden to secure their secrets"
quote="At this point, it would be almost impossible to leak our secrets. It's just one less thing we have to worry about."
source="Head of IT, Titanom Technologies"
></app-review-blurb>
</div>

View File

@ -0,0 +1,37 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
@Component({
selector: "app-secrets-manager-content",
templateUrl: "secrets-manager-content.component.html",
})
export class SecretsManagerContentComponent implements OnInit, OnDestroy {
header: string;
private destroy$ = new Subject<void>();
constructor(private activatedRoute: ActivatedRoute) {}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
ngOnInit(): void {
this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((queryParameters) => {
switch (queryParameters.org) {
case "enterprise":
this.header = "Secrets Manager for Enterprise";
break;
case "free":
this.header = "Bitwarden Secrets Manager";
break;
case "teams":
case "teamsStarter":
this.header = "Secrets Manager for Teams";
break;
}
});
}
}

View File

@ -0,0 +1,72 @@
<ng-container *ngIf="loading">
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<form
#form
[formGroup]="formGroup"
[appApiAction]="formPromise"
(ngSubmit)="submit()"
*ngIf="!loading"
>
<div class="tw-container tw-mb-3">
<div class="tw-mb-6">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "billingPlanLabel" | i18n }}</h2>
<div class="tw-mb-1 tw-items-center">
<label class="tw- tw-block tw-text-main" for="annual">
<input
class="tw-h-4 tw-w-4 tw-align-middle"
id="annual"
name="cadence"
type="radio"
[value]="annualCadence"
formControlName="cadence"
/>
{{ "annual" | i18n }} -
{{
(annualPlan.SecretsManager.basePrice === 0
? annualPlan.SecretsManager.seatPrice
: annualPlan.SecretsManager.basePrice
) | currency: "$"
}}
/{{ "yr" | i18n }}
</label>
</div>
<div class="tw-mb-1 tw-items-center">
<label class="tw- tw-block tw-text-main" for="monthly">
<input
class="tw-h-4 tw-w-4 tw-align-middle"
id="monthly"
name="cadence"
type="radio"
[value]="monthlyCadence"
formControlName="cadence"
/>
{{ "monthly" | i18n }} -
{{
(monthlyPlan.SecretsManager.basePrice === 0
? monthlyPlan.SecretsManager.seatPrice
: monthlyPlan.SecretsManager.basePrice
) | currency: "$"
}}
/{{ "monthAbbr" | i18n }}
</label>
</div>
</div>
<div class="tw-mb-4">
<h2 class="tw-mb-3 tw-text-base tw-font-semibold">{{ "paymentType" | i18n }}</h2>
<app-payment [hideCredit]="true" [trialFlow]="true"></app-payment>
<app-tax-info [trialFlow]="true" (onCountryChanged)="changedCountry()"></app-tax-info>
</div>
<div class="tw-flex tw-space-x-2">
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
{{ "startTrial" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">Back</button>
</div>
</div>
</form>

View File

@ -0,0 +1,183 @@
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { ProductType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BillingSharedModule, PaymentComponent, TaxInfoComponent } from "../../../billing/shared";
export interface OrganizationInfo {
name: string;
email: string;
}
export interface OrganizationCreatedEvent {
organizationId: string;
planDescription: string;
}
enum SubscriptionCadence {
Monthly,
Annual,
}
export enum SubscriptionType {
Teams,
Enterprise,
}
@Component({
selector: "app-secrets-manager-trial-billing-step",
templateUrl: "secrets-manager-trial-billing-step.component.html",
imports: [BillingSharedModule],
standalone: true,
})
export class SecretsManagerTrialBillingStepComponent implements OnInit {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
@Input() organizationInfo: OrganizationInfo;
@Input() subscriptionType: SubscriptionType;
@Output() steppedBack = new EventEmitter();
@Output() organizationCreated = new EventEmitter<OrganizationCreatedEvent>();
loading = true;
annualCadence = SubscriptionCadence.Annual;
monthlyCadence = SubscriptionCadence.Monthly;
formGroup = this.formBuilder.group({
cadence: [SubscriptionCadence.Annual, Validators.required],
});
formPromise: Promise<string>;
applicablePlans: PlanResponse[];
annualPlan: PlanResponse;
monthlyPlan: PlanResponse;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private formBuilder: FormBuilder,
private messagingService: MessagingService,
private organizationBillingService: OrganizationBillingService,
private platformUtilsService: PlatformUtilsService,
) {}
async ngOnInit(): Promise<void> {
const plans = await this.apiService.getPlans();
this.applicablePlans = plans.data.filter(this.isApplicable);
this.annualPlan = this.findPlanFor(SubscriptionCadence.Annual);
this.monthlyPlan = this.findPlanFor(SubscriptionCadence.Monthly);
this.loading = false;
}
async submit(): Promise<void> {
this.formPromise = this.createOrganization();
const organizationId = await this.formPromise;
const planDescription = this.getPlanDescription();
this.platformUtilsService.showToast(
"success",
this.i18nService.t("organizationCreated"),
this.i18nService.t("organizationReadyToGo"),
);
this.organizationCreated.emit({
organizationId,
planDescription,
});
this.messagingService.send("organizationCreated", organizationId);
}
protected changedCountry() {
this.paymentComponent.hideBank = this.taxInfoComponent.taxInfo.country !== "US";
if (
this.paymentComponent.hideBank &&
this.paymentComponent.method === PaymentMethodType.BankAccount
) {
this.paymentComponent.method = PaymentMethodType.Card;
this.paymentComponent.changeMethod();
}
}
protected stepBack() {
this.steppedBack.emit();
}
private async createOrganization(): Promise<string> {
const plan = this.findPlanFor(this.formGroup.value.cadence);
const paymentMethod = await this.paymentComponent.createPaymentToken();
const response = await this.organizationBillingService.purchaseSubscription({
organization: {
name: this.organizationInfo.name,
billingEmail: this.organizationInfo.email,
},
plan: {
type: plan.type,
passwordManagerSeats: 1,
subscribeToSecretsManager: true,
isFromSecretsManagerTrial: true,
secretsManagerSeats: 1,
},
payment: {
paymentMethod,
billing: {
postalCode: this.taxInfoComponent.taxInfo.postalCode,
country: this.taxInfoComponent.taxInfo.country,
taxId: this.taxInfoComponent.taxInfo.taxId,
addressLine1: this.taxInfoComponent.taxInfo.line1,
addressLine2: this.taxInfoComponent.taxInfo.line2,
city: this.taxInfoComponent.taxInfo.city,
state: this.taxInfoComponent.taxInfo.state,
},
},
});
return response.id;
}
private findPlanFor(cadence: SubscriptionCadence) {
switch (this.subscriptionType) {
case SubscriptionType.Teams:
return cadence === SubscriptionCadence.Annual
? this.applicablePlans.find((plan) => plan.type === PlanType.TeamsAnnually)
: this.applicablePlans.find((plan) => plan.type === PlanType.TeamsMonthly);
case SubscriptionType.Enterprise:
return cadence === SubscriptionCadence.Annual
? this.applicablePlans.find((plan) => plan.type === PlanType.EnterpriseAnnually)
: this.applicablePlans.find((plan) => plan.type === PlanType.EnterpriseMonthly);
}
}
private getPlanDescription(): string {
const plan = this.findPlanFor(this.formGroup.value.cadence);
const price =
plan.SecretsManager.basePrice === 0
? plan.SecretsManager.seatPrice
: plan.SecretsManager.basePrice;
switch (this.formGroup.value.cadence) {
case SubscriptionCadence.Annual:
return `${this.i18nService.t("annual")} ($${price}/${this.i18nService.t("yr")})`;
case SubscriptionCadence.Monthly:
return `${this.i18nService.t("monthly")} ($${price}/${this.i18nService.t("monthAbbr")})`;
}
}
private isApplicable(plan: PlanResponse): boolean {
const hasSecretsManager = !!plan.SecretsManager;
const isTeamsOrEnterprise =
plan.product === ProductType.Teams || plan.product === ProductType.Enterprise;
const notDisabledOrLegacy = !plan.disabled && !plan.legacyYear;
return hasSecretsManager && isTeamsOrEnterprise && notDisabledOrLegacy;
}
}

View File

@ -0,0 +1,54 @@
<app-vertical-stepper #stepper linear>
<app-vertical-step
label="{{ 'createAccount' | i18n | titlecase }}"
[editable]="false"
[subLabel]="subLabels.createAccount"
[addSubLabelSpacing]="true"
>
<app-register-form [isInTrialFlow]="true" (createdAccount)="accountCreated($event)">
</app-register-form>
</app-vertical-step>
<app-vertical-step
label="{{ 'organizationInformation' | i18n | titlecase }}"
[subLabel]="subLabels.organizationInfo"
>
<app-org-info [nameOnly]="true" [formGroup]="formGroup"> </app-org-info>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="formGroup.get('name').invalid"
(click)="createOrganization()"
>
{{ "next" | i18n }}
</button>
</app-vertical-step>
<app-vertical-step label="{{ 'confirmationDetails' | i18n | titlecase }}">
<div class="tw-pb-6 tw-pl-6">
<p class="tw-text-xl">{{ "smFreeTrialThankYou" | i18n }}</p>
<ul class="tw-list-disc">
<li>
<p>
{{ "smFreeTrialConfirmationEmail" | i18n }}
<span class="tw-font-bold">{{ formGroup.get("email").value }}</span
>.
</p>
</li>
</ul>
</div>
<div class="tw-mb-3 tw-flex">
<button type="button" bitButton buttonType="primary" (click)="navigateTo('vault')">
{{ "getStarted" | i18n | titlecase }}
</button>
<button
type="button"
bitButton
buttonType="secondary"
(click)="navigateTo('members')"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
{{ "inviteUsers" | i18n }}
</button>
</div>
</app-vertical-step>
</app-vertical-stepper>

View File

@ -0,0 +1,76 @@
import { Component, ViewChild } from "@angular/core";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
@Component({
selector: "app-secrets-manager-trial-free-stepper",
templateUrl: "secrets-manager-trial-free-stepper.component.html",
})
export class SecretsManagerTrialFreeStepperComponent {
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
formGroup = this.formBuilder.group({
name: [
"",
{
validators: [Validators.required, Validators.maxLength(50)],
updateOn: "change",
},
],
email: [
"",
{
validators: [Validators.email],
},
],
});
subLabels = {
createAccount:
"Before creating your free organization, you first need to log in or create a personal account.",
organizationInfo: "Enter your organization information",
};
organizationId: string;
constructor(
protected formBuilder: UntypedFormBuilder,
protected i18nService: I18nService,
protected organizationBillingService: OrganizationBillingService,
private router: Router,
) {}
accountCreated(email: string): void {
this.formGroup.get("email")?.setValue(email);
this.subLabels.createAccount = email;
this.verticalStepper.next();
}
async createOrganization(): Promise<void> {
const response = await this.organizationBillingService.startFree({
organization: {
name: this.formGroup.get("name").value,
billingEmail: this.formGroup.get("email").value,
},
plan: {
type: PlanType.Free,
subscribeToSecretsManager: true,
isFromSecretsManagerTrial: true,
},
});
this.organizationId = response.id;
this.subLabels.organizationInfo = response.name;
this.verticalStepper.next();
}
async navigateTo(organizationRoute: string): Promise<void> {
await this.router.navigate(["organizations", this.organizationId, organizationRoute]);
}
}

View File

@ -0,0 +1,58 @@
<app-vertical-stepper #stepper linear>
<app-vertical-step
label="{{ 'createAccount' | i18n | titlecase }}"
[editable]="false"
[subLabel]="createAccountLabel"
[addSubLabelSpacing]="true"
>
<app-register-form [isInTrialFlow]="true" (createdAccount)="accountCreated($event)">
</app-register-form>
</app-vertical-step>
<app-vertical-step
label="{{ 'organizationInformation' | i18n | titlecase }}"
[subLabel]="subLabels.organizationInfo"
>
<app-org-info [nameOnly]="true" [formGroup]="formGroup"></app-org-info>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="formGroup.get('name').invalid"
cdkStepperNext
>
{{ "next" | i18n }}
</button>
</app-vertical-step>
<app-vertical-step label="{{ 'billing' | i18n | titlecase }}" [subLabel]="billingSubLabel">
<app-secrets-manager-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: formGroup.get('name').value,
email: formGroup.get('email').value
}"
[subscriptionType]="paidSubscriptionType"
(steppedBack)="steppedBack()"
(organizationCreated)="organizationCreated($event)"
></app-secrets-manager-trial-billing-step>
</app-vertical-step>
<app-vertical-step label="{{ 'confirmationDetails' | i18n | titlecase }}">
<app-trial-confirmation-details
[email]="formGroup.get('email').value"
[orgLabel]="subscriptionType"
></app-trial-confirmation-details>
<div class="tw-mb-3 tw-flex">
<button type="button" bitButton buttonType="primary" (click)="navigateTo('vault')">
{{ "getStarted" | i18n | titlecase }}
</button>
<button
type="button"
bitButton
buttonType="secondary"
(click)="navigateTo('members')"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
{{ "inviteUsers" | i18n }}
</button>
</div>
</app-vertical-step>
</app-vertical-stepper>

View File

@ -0,0 +1,46 @@
import { Component, Input, ViewChild } from "@angular/core";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
import {
OrganizationCreatedEvent,
SubscriptionType,
} from "./secrets-manager-trial-billing-step.component";
@Component({
selector: "app-secrets-manager-trial-paid-stepper",
templateUrl: "secrets-manager-trial-paid-stepper.component.html",
})
export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrialFreeStepperComponent {
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
@Input() subscriptionType: string;
billingSubLabel = this.i18nService.t("billingTrialSubLabel");
organizationId: string;
organizationCreated(event: OrganizationCreatedEvent) {
this.organizationId = event.organizationId;
this.billingSubLabel = event.planDescription;
this.verticalStepper.next();
}
steppedBack() {
this.verticalStepper.previous();
}
get createAccountLabel() {
const organizationType =
this.paidSubscriptionType == SubscriptionType.Enterprise ? "Enterprise" : "Teams";
return `Before creating your ${organizationType} organization, you first need to log in or create a personal account.`;
}
get paidSubscriptionType() {
switch (this.subscriptionType) {
case "enterprise":
return SubscriptionType.Enterprise;
case "teams":
return SubscriptionType.Teams;
}
}
}

View File

@ -0,0 +1,41 @@
<!-- eslint-disable tailwindcss/no-custom-classname -->
<ng-container>
<div class="tw-absolute tw--z-10 tw--mt-48 tw-h-[28rem] tw-w-full tw-bg-background-alt2"></div>
<div class="tw-min-w-4xl tw-mx-auto tw-flex tw-max-w-screen-xl tw-gap-12 tw-px-4">
<div class="tw-w-1/2">
<img
alt="Bitwarden"
style="height: 50px; width: 335px"
class="tw-mt-6"
src="../../../../images/register-layout/logo-horizontal-white.svg"
/>
<div class="tw-pt-12">
<app-secrets-manager-content></app-secrets-manager-content>
</div>
</div>
<div class="tw-w-1/2">
<div class="tw-pt-44">
<div class="tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background">
<div
*ngIf="!freeOrganization"
class="tw-flex tw-h-auto tw-w-full tw-gap-5 tw-rounded-t tw-bg-secondary-100"
>
<h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase">
{{ "startYour7DayFreeTrialOfBitwardenSecretsManagerFor" | i18n: subscriptionType }}
</h2>
<environment-selector
class="tw-mr-4 tw-mt-6 tw-flex-shrink-0 tw-text-end"
></environment-selector>
</div>
<app-secrets-manager-trial-free-stepper
*ngIf="freeOrganization"
></app-secrets-manager-trial-free-stepper>
<app-secrets-manager-trial-paid-stepper
*ngIf="!freeOrganization"
[subscriptionType]="subscriptionType"
></app-secrets-manager-trial-paid-stepper>
</div>
</div>
</div>
</div>
</ng-container>

View File

@ -0,0 +1,30 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
@Component({
selector: "app-secrets-manager-trial",
templateUrl: "secrets-manager-trial.component.html",
})
export class SecretsManagerTrialComponent implements OnInit, OnDestroy {
subscriptionType: string;
private destroy$ = new Subject<void>();
constructor(private route: ActivatedRoute) {}
ngOnInit(): void {
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((queryParameters) => {
this.subscriptionType = queryParameters.org;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
get freeOrganization() {
return this.subscriptionType === "free";
}
}

View File

@ -1,126 +1,140 @@
<!-- eslint-disable tailwindcss/no-custom-classname --> <!-- eslint-disable tailwindcss/no-custom-classname -->
<div *ngIf="accountCreateOnly" class=""> <app-secrets-manager-trial
<h1 class="tw-mt-12 tw-text-center tw-text-xl">{{ "createAccount" | i18n }}</h1> *ngIf="layout === layouts.secretsManager; else passwordManagerTrial"
<div ></app-secrets-manager-trial>
class="tw-min-w-xl tw-m-auto tw-max-w-xl tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-8" <ng-template #passwordManagerTrial>
> <div *ngIf="accountCreateOnly" class="">
<app-register-form <h1 class="tw-mt-12 tw-text-center tw-text-xl">{{ "createAccount" | i18n }}</h1>
[queryParamEmail]="email" <div
[queryParamFromOrgInvite]="fromOrgInvite" class="tw-min-w-xl tw-m-auto tw-max-w-xl tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-8"
[enforcedPolicyOptions]="enforcedPolicyOptions" >
[referenceDataValue]="referenceData" <app-register-form
></app-register-form> [queryParamEmail]="email"
</div> [queryParamFromOrgInvite]="fromOrgInvite"
</div> [enforcedPolicyOptions]="enforcedPolicyOptions"
<div *ngIf="!accountCreateOnly"> [referenceDataValue]="referenceData"
<div class="tw-absolute tw--z-10 tw--mt-48 tw-h-[28rem] tw-w-full tw-bg-background-alt2"></div> ></app-register-form>
<div class="tw-min-w-4xl tw-mx-auto tw-flex tw-max-w-screen-xl tw-gap-12 tw-px-4">
<div class="tw-w-1/2">
<img
alt="Bitwarden"
style="height: 50px; width: 335px"
class="tw-mt-6"
src="../../images/register-layout/logo-horizontal-white.svg"
/>
<div class="tw-pt-12">
<!-- Layout params are used by marketing to determine left-hand content -->
<app-default-content *ngIf="layout === layouts.default"></app-default-content>
<app-teams-content *ngIf="layout === layouts.teams"></app-teams-content>
<app-teams1-content *ngIf="layout === layouts.teams1"></app-teams1-content>
<app-teams2-content *ngIf="layout === layouts.teams2"></app-teams2-content>
<app-teams3-content *ngIf="layout === layouts.teams3"></app-teams3-content>
<app-enterprise-content *ngIf="layout === layouts.enterprise"></app-enterprise-content>
<app-enterprise1-content *ngIf="layout === layouts.enterprise1"></app-enterprise1-content>
<app-enterprise2-content *ngIf="layout === layouts.enterprise2"></app-enterprise2-content>
<app-cnet-enterprise-content
*ngIf="layout === layouts.cnetcmpgnent"
></app-cnet-enterprise-content>
<app-cnet-individual-content
*ngIf="layout === layouts.cnetcmpgnind"
></app-cnet-individual-content>
<app-cnet-teams-content *ngIf="layout === layouts.cnetcmpgnteams"></app-cnet-teams-content>
<app-abm-enterprise-content
*ngIf="layout === layouts.abmenterprise"
></app-abm-enterprise-content>
<app-abm-teams-content *ngIf="layout === layouts.abmteams"></app-abm-teams-content>
</div>
</div> </div>
<div class="tw-w-1/2"> </div>
<div *ngIf="!useTrialStepper"> <div *ngIf="!accountCreateOnly">
<div <div class="tw-absolute tw--z-10 tw--mt-48 tw-h-[28rem] tw-w-full tw-bg-background-alt2"></div>
class="tw-min-w-xl tw-m-auto tw-mt-28 tw-max-w-xl tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-8" <div class="tw-min-w-4xl tw-mx-auto tw-flex tw-max-w-screen-xl tw-gap-12 tw-px-4">
> <div class="tw-w-1/2">
<app-register-form <img
[queryParamEmail]="email" alt="Bitwarden"
[enforcedPolicyOptions]="enforcedPolicyOptions" style="height: 50px; width: 335px"
[referenceDataValue]="referenceData" class="tw-mt-6"
></app-register-form> src="../../images/register-layout/logo-horizontal-white.svg"
/>
<div class="tw-pt-12">
<!-- Layout params are used by marketing to determine left-hand content -->
<app-default-content *ngIf="layout === layouts.default"></app-default-content>
<app-teams-content *ngIf="layout === layouts.teams"></app-teams-content>
<app-teams1-content *ngIf="layout === layouts.teams1"></app-teams1-content>
<app-teams2-content *ngIf="layout === layouts.teams2"></app-teams2-content>
<app-teams3-content *ngIf="layout === layouts.teams3"></app-teams3-content>
<app-enterprise-content *ngIf="layout === layouts.enterprise"></app-enterprise-content>
<app-enterprise1-content *ngIf="layout === layouts.enterprise1"></app-enterprise1-content>
<app-enterprise2-content *ngIf="layout === layouts.enterprise2"></app-enterprise2-content>
<app-cnet-enterprise-content
*ngIf="layout === layouts.cnetcmpgnent"
></app-cnet-enterprise-content>
<app-cnet-individual-content
*ngIf="layout === layouts.cnetcmpgnind"
></app-cnet-individual-content>
<app-cnet-teams-content
*ngIf="layout === layouts.cnetcmpgnteams"
></app-cnet-teams-content>
<app-abm-enterprise-content
*ngIf="layout === layouts.abmenterprise"
></app-abm-enterprise-content>
<app-abm-teams-content *ngIf="layout === layouts.abmteams"></app-abm-teams-content>
</div> </div>
</div> </div>
<div class="tw-pt-44" *ngIf="useTrialStepper"> <div class="tw-w-1/2">
<div class="tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background"> <div *ngIf="!useTrialStepper">
<div class="tw-flex tw-h-auto tw-w-full tw-gap-5 tw-rounded-t tw-bg-secondary-100"> <div
<h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase"> class="tw-min-w-xl tw-m-auto tw-mt-28 tw-max-w-xl tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-8"
{{ "startYour7DayFreeTrialOfBitwardenFor" | i18n: orgDisplayName }} >
</h2> <app-register-form
<environment-selector [queryParamEmail]="email"
class="tw-mr-4 tw-mt-6 tw-flex-shrink-0 tw-text-end" [enforcedPolicyOptions]="enforcedPolicyOptions"
></environment-selector> [referenceDataValue]="referenceData"
></app-register-form>
</div> </div>
<app-vertical-stepper #stepper linear (selectionChange)="stepSelectionChange($event)"> </div>
<app-vertical-step label="Create Account" [editable]="false" [subLabel]="email"> <div class="tw-pt-44" *ngIf="useTrialStepper">
<app-register-form <div
[isInTrialFlow]="true" class="tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background"
(createdAccount)="createdAccount($event)" >
[referenceDataValue]="referenceData" <div class="tw-flex tw-h-auto tw-w-full tw-gap-5 tw-rounded-t tw-bg-secondary-100">
></app-register-form> <h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase">
</app-vertical-step> {{ freeTrialText }}
<app-vertical-step label="Organization Information" [subLabel]="orgInfoSubLabel"> </h2>
<app-org-info [nameOnly]="true" [formGroup]="orgInfoFormGroup"></app-org-info> <environment-selector
<button class="tw-mr-4 tw-mt-6 tw-flex-shrink-0 tw-text-end"
type="button" ></environment-selector>
bitButton </div>
buttonType="primary" <app-vertical-stepper #stepper linear (selectionChange)="stepSelectionChange($event)">
[disabled]="orgInfoFormGroup.get('name').invalid" <app-vertical-step label="Create Account" [editable]="false" [subLabel]="email">
cdkStepperNext <app-register-form
> [isInTrialFlow]="true"
{{ "next" | i18n }} (createdAccount)="createdAccount($event)"
</button> [referenceDataValue]="referenceData"
</app-vertical-step> ></app-register-form>
<app-vertical-step label="Billing" [subLabel]="billingSubLabel"> </app-vertical-step>
<app-billing <app-vertical-step label="Organization Information" [subLabel]="orgInfoSubLabel">
*ngIf="stepper.selectedIndex === 2" <app-org-info [nameOnly]="true" [formGroup]="orgInfoFormGroup"></app-org-info>
[plan]="plan"
[product]="product"
[orgInfoForm]="orgInfoFormGroup"
(previousStep)="previousStep()"
(onTrialBillingSuccess)="billingSuccess($event)"
></app-billing>
</app-vertical-step>
<app-vertical-step label="Confirmation Details" [applyBorder]="false">
<app-trial-confirmation-details
[email]="email"
[orgLabel]="orgLabel"
></app-trial-confirmation-details>
<div class="tw-mb-3 tw-flex">
<button type="button" bitButton buttonType="primary" (click)="navigateToOrgVault()">
{{ "getStarted" | i18n | titlecase }}
</button>
<button <button
type="button" type="button"
bitButton bitButton
buttonType="secondary" buttonType="primary"
(click)="navigateToOrgInvite()" [disabled]="orgInfoFormGroup.get('name').invalid"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3" cdkStepperNext
> >
{{ "inviteUsers" | i18n }} {{ "next" | i18n }}
</button> </button>
</div> </app-vertical-step>
</app-vertical-step> <app-vertical-step label="Billing" [subLabel]="billingSubLabel">
</app-vertical-stepper> <app-billing
*ngIf="stepper.selectedIndex === 2"
[plan]="plan"
[product]="product"
[orgInfoForm]="orgInfoFormGroup"
(previousStep)="previousStep()"
(onTrialBillingSuccess)="billingSuccess($event)"
></app-billing>
</app-vertical-step>
<app-vertical-step label="Confirmation Details" [applyBorder]="false">
<app-trial-confirmation-details
[email]="email"
[orgLabel]="orgLabel"
></app-trial-confirmation-details>
<div class="tw-mb-3 tw-flex">
<button
type="button"
bitButton
buttonType="primary"
(click)="navigateToOrgVault()"
>
{{ "getStarted" | i18n | titlecase }}
</button>
<button
type="button"
bitButton
buttonType="secondary"
(click)="navigateToOrgInvite()"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
{{ "inviteUsers" | i18n }}
</button>
</div>
</app-vertical-step>
</app-vertical-stepper>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </ng-template>

View File

@ -18,6 +18,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { RouterService } from "./../../core/router.service"; import { RouterService } from "./../../core/router.service";
import { SubscriptionType } from "./secrets-manager/secrets-manager-trial-billing-step.component";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component"; import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
enum ValidOrgParams { enum ValidOrgParams {
@ -44,6 +45,7 @@ enum ValidLayoutParams {
cnetcmpgnteams = "cnetcmpgnteams", cnetcmpgnteams = "cnetcmpgnteams",
abmenterprise = "abmenterprise", abmenterprise = "abmenterprise",
abmteams = "abmteams", abmteams = "abmteams",
secretsManager = "secretsManager",
} }
@Component({ @Component({
@ -77,6 +79,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
ValidOrgParams.individual, ValidOrgParams.individual,
]; ];
layouts = ValidLayoutParams; layouts = ValidLayoutParams;
orgTypes = ValidOrgParams;
referenceData: ReferenceEventRequest; referenceData: ReferenceEventRequest;
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
@ -258,6 +261,15 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
return this.org; return this.org;
} }
get freeTrialText() {
const translationKey =
this.layout === this.layouts.secretsManager
? "startYour7DayFreeTrialOfBitwardenSecretsManagerFor"
: "startYour7DayFreeTrialOfBitwardenFor";
return this.i18nService.t(translationKey, this.org);
}
private setupFamilySponsorship(sponsorshipToken: string) { private setupFamilySponsorship(sponsorshipToken: string) {
if (sponsorshipToken != null) { if (sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], { const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
@ -266,4 +278,6 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
this.routerService.setPreviousUrl(route.toString()); this.routerService.setPreviousUrl(route.toString());
} }
} }
protected readonly SubscriptionType = SubscriptionType;
} }

View File

@ -6,6 +6,9 @@ import { FormFieldModule } from "@bitwarden/components";
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module"; import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
import { RegisterFormModule } from "../../auth/register-form/register-form.module"; import { RegisterFormModule } from "../../auth/register-form/register-form.module";
import { SecretsManagerTrialFreeStepperComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component";
import { SecretsManagerTrialPaidStepperComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component";
import { SecretsManagerTrialComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial.component";
import { PaymentComponent, TaxInfoComponent } from "../../billing"; import { PaymentComponent, TaxInfoComponent } from "../../billing";
import { BillingComponent } from "../../billing/accounts/trial-initiation/billing.component"; import { BillingComponent } from "../../billing/accounts/trial-initiation/billing.component";
import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module"; import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module";
@ -25,11 +28,14 @@ import { LogoCnet5StarsComponent } from "./content/logo-cnet-5-stars.component";
import { LogoCnetComponent } from "./content/logo-cnet.component"; import { LogoCnetComponent } from "./content/logo-cnet.component";
import { LogoForbesComponent } from "./content/logo-forbes.component"; import { LogoForbesComponent } from "./content/logo-forbes.component";
import { LogoUSNewsComponent } from "./content/logo-us-news.component"; import { LogoUSNewsComponent } from "./content/logo-us-news.component";
import { ReviewBlurbComponent } from "./content/review-blurb.component";
import { ReviewLogoComponent } from "./content/review-logo.component"; import { ReviewLogoComponent } from "./content/review-logo.component";
import { SecretsManagerContentComponent } from "./content/secrets-manager-content.component";
import { TeamsContentComponent } from "./content/teams-content.component"; import { TeamsContentComponent } from "./content/teams-content.component";
import { Teams1ContentComponent } from "./content/teams1-content.component"; import { Teams1ContentComponent } from "./content/teams1-content.component";
import { Teams2ContentComponent } from "./content/teams2-content.component"; import { Teams2ContentComponent } from "./content/teams2-content.component";
import { Teams3ContentComponent } from "./content/teams3-content.component"; import { Teams3ContentComponent } from "./content/teams3-content.component";
import { SecretsManagerTrialBillingStepComponent } from "./secrets-manager/secrets-manager-trial-billing-step.component";
import { TrialInitiationComponent } from "./trial-initiation.component"; import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module"; import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
@ -44,6 +50,7 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
EnvironmentSelectorModule, EnvironmentSelectorModule,
PaymentComponent, PaymentComponent,
TaxInfoComponent, TaxInfoComponent,
SecretsManagerTrialBillingStepComponent,
], ],
declarations: [ declarations: [
TrialInitiationComponent, TrialInitiationComponent,
@ -69,6 +76,11 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
LogoForbesComponent, LogoForbesComponent,
LogoUSNewsComponent, LogoUSNewsComponent,
ReviewLogoComponent, ReviewLogoComponent,
SecretsManagerContentComponent,
ReviewBlurbComponent,
SecretsManagerTrialComponent,
SecretsManagerTrialFreeStepperComponent,
SecretsManagerTrialPaidStepperComponent,
], ],
exports: [TrialInitiationComponent], exports: [TrialInitiationComponent],
providers: [TitleCasePipe], providers: [TitleCasePipe],

View File

@ -1,7 +1,10 @@
<ng-template> <ng-template>
<div <div
class="tw-inline-block tw-w-11/12 tw-pl-7" class="tw-inline-block tw-w-11/12 tw-pl-7"
[ngClass]="{ 'tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300': applyBorder }" [ngClass]="{
'tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300': applyBorder,
'tw-pt-6': addSubLabelSpacing
}"
> >
<ng-content></ng-content> <ng-content></ng-content>
</div> </div>

View File

@ -9,4 +9,5 @@ import { Component, Input } from "@angular/core";
export class VerticalStep extends CdkStep { export class VerticalStep extends CdkStep {
@Input() subLabel = ""; @Input() subLabel = "";
@Input() applyBorder = true; @Input() applyBorder = true;
@Input() addSubLabelSpacing = false;
} }

View File

@ -140,7 +140,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
get subscriptionLineItems() { get subscriptionLineItems() {
return this.lineItems.map((lineItem: BillingSubscriptionItemResponse) => ({ return this.lineItems.map((lineItem: BillingSubscriptionItemResponse) => ({
name: lineItem.name, name: lineItem.name,
amount: this.discountPrice(lineItem.amount), amount: this.discountPrice(lineItem.amount, lineItem.productId),
quantity: lineItem.quantity, quantity: lineItem.quantity,
interval: lineItem.interval, interval: lineItem.interval,
sponsoredSubscriptionItem: lineItem.sponsoredSubscriptionItem, sponsoredSubscriptionItem: lineItem.sponsoredSubscriptionItem,
@ -183,7 +183,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
} }
get storageGbPrice() { get storageGbPrice() {
return this.discountPrice(this.sub.plan.PasswordManager.additionalStoragePricePerGb); return this.sub.plan.PasswordManager.additionalStoragePricePerGb;
} }
get seatPrice() { get seatPrice() {
@ -198,14 +198,12 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
return { return {
seatCount: this.sub.smSeats, seatCount: this.sub.smSeats,
maxAutoscaleSeats: this.sub.maxAutoscaleSmSeats, maxAutoscaleSeats: this.sub.maxAutoscaleSmSeats,
seatPrice: this.discountPrice(this.sub.plan.SecretsManager.seatPrice), seatPrice: this.sub.plan.SecretsManager.seatPrice,
maxAutoscaleServiceAccounts: this.sub.maxAutoscaleSmServiceAccounts, maxAutoscaleServiceAccounts: this.sub.maxAutoscaleSmServiceAccounts,
additionalServiceAccounts: additionalServiceAccounts:
this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount, this.sub.smServiceAccounts - this.sub.plan.SecretsManager.baseServiceAccount,
interval: this.sub.plan.isAnnual ? "year" : "month", interval: this.sub.plan.isAnnual ? "year" : "month",
additionalServiceAccountPrice: this.discountPrice( additionalServiceAccountPrice: this.sub.plan.SecretsManager.additionalPricePerServiceAccount,
this.sub.plan.SecretsManager.additionalPricePerServiceAccount,
),
baseServiceAccountCount: this.sub.plan.SecretsManager.baseServiceAccount, baseServiceAccountCount: this.sub.plan.SecretsManager.baseServiceAccount,
}; };
} }
@ -404,9 +402,12 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
} }
}; };
discountPrice = (price: number) => { discountPrice = (price: number, productId: string = null) => {
const discount = const discount =
!!this.customerDiscount && this.customerDiscount.active this.customerDiscount?.active &&
(!productId ||
!this.customerDiscount.appliesTo.length ||
this.customerDiscount.appliesTo.includes(productId))
? price * (this.customerDiscount.percentOff / 100) ? price * (this.customerDiscount.percentOff / 100)
: 0; : 0;

View File

@ -2531,6 +2531,15 @@
} }
} }
}, },
"trialSecretsManagerThankYou": {
"message": "Thanks for signing up for Bitwarden Secrets Manager for $PLAN$!",
"placeholders": {
"plan": {
"content": "$1",
"example": "Teams"
}
}
},
"trialPaidInfoMessage": { "trialPaidInfoMessage": {
"message": "Your $PLAN$ 7 day free trial will be converted to a paid subscription after 7 days.", "message": "Your $PLAN$ 7 day free trial will be converted to a paid subscription after 7 days.",
"placeholders": { "placeholders": {
@ -3471,7 +3480,7 @@
"message": "Set a seat limit for your subscription. Once this limit is reached, you will not be able to invite new members." "message": "Set a seat limit for your subscription. Once this limit is reached, you will not be able to invite new members."
}, },
"limitSmSubscriptionDesc": { "limitSmSubscriptionDesc": {
"message": "Set a seat limit for your Secrets Manger subscription. Once this limit is reached, you will not be able to invite new members." "message": "Set a seat limit for your Secrets Manager subscription. Once this limit is reached, you will not be able to invite new members."
}, },
"maxSeatLimit": { "maxSeatLimit": {
"message": "Seat Limit (optional)", "message": "Seat Limit (optional)",
@ -7234,6 +7243,15 @@
} }
} }
}, },
"startYour7DayFreeTrialOfBitwardenSecretsManagerFor": {
"message": "Start your 7-Day free trial of Bitwarden Secrets Manager for $ORG$",
"placeholders": {
"org": {
"content": "$1",
"example": "Organization name"
}
}
},
"next": { "next": {
"message": "Next" "message": "Next"
}, },
@ -7508,5 +7526,17 @@
}, },
"collectionEnhancementsLearnMore": { "collectionEnhancementsLearnMore": {
"message": "Learn more about collection management" "message": "Learn more about collection management"
},
"organizationInformation": {
"message": "Organization information"
},
"confirmationDetails": {
"message": "Confirmation details"
},
"smFreeTrialThankYou": {
"message": "Thank you for signing up for Bitwarden Secrets Manager!"
},
"smFreeTrialConfirmationEmail": {
"message": "We've sent a confirmation email to your email at "
} }
} }

View File

@ -2,47 +2,47 @@
"folders": [ "folders": [
{ {
"name": "root", "name": "root",
"path": ".", "path": "."
}, },
{ {
"name": "web vault", "name": "web vault",
"path": "apps/web", "path": "apps/web"
}, },
{ {
"name": "web vault (bit)", "name": "web vault (bit)",
"path": "bitwarden_license/bit-web", "path": "bitwarden_license/bit-web"
}, },
{ {
"name": "cli", "name": "cli",
"path": "apps/cli", "path": "apps/cli"
}, },
{ {
"name": "desktop", "name": "desktop",
"path": "apps/desktop", "path": "apps/desktop"
}, },
{ {
"name": "browser", "name": "browser",
"path": "apps/browser", "path": "apps/browser"
}, },
{ {
"name": "libs", "name": "libs",
"path": "libs", "path": "libs"
}, }
], ],
"settings": { "settings": {
"eslint.options": { "eslint.options": {
"overrideConfig": { "overrideConfig": {
"parserOptions": { "parserOptions": {
"project": ["${workspaceFolder}/tsconfig.eslint.json"], "project": ["${workspaceFolder}/tsconfig.eslint.json"]
}, }
}, }
}, },
"debug.javascript.terminalOptions": { "debug.javascript.terminalOptions": {
"sourceMapPathOverrides": { "sourceMapPathOverrides": {
"webpack:///./~/*": "${workspaceFolder:root}/node_modules/*", "webpack:///./~/*": "${workspaceFolder:root}/node_modules/*",
"webpack://?:*/*": "${workspaceFolder}/*", "webpack://?:*/*": "${workspaceFolder}/*",
"webpack://@bitwarden/cli/*": "${workspaceFolder}/*", "webpack://@bitwarden/cli/*": "${workspaceFolder}/*"
}, }
}, },
"jest.disabledWorkspaceFolders": [ "jest.disabledWorkspaceFolders": [
"browser", "browser",
@ -56,14 +56,14 @@
"jest.jestCommandLine": "npx jest", "jest.jestCommandLine": "npx jest",
"angular.enable-strict-mode-prompt": false, "angular.enable-strict-mode-prompt": false,
"typescript.preferences.importModuleSpecifier": "project-relative", "typescript.preferences.importModuleSpecifier": "project-relative",
"javascript.preferences.importModuleSpecifier": "project-relative", "javascript.preferences.importModuleSpecifier": "project-relative"
}, },
"extensions": { "extensions": {
"recommendations": [ "recommendations": [
"orta.vscode-jest", "orta.vscode-jest",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"Angular.ng-template", "Angular.ng-template"
], ]
}, }
} }

View File

@ -76,7 +76,9 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth
import { WebAuthnLoginPrfCryptoService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-crypto.service"; import { WebAuthnLoginPrfCryptoService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-crypto.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service"; import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import { BillingBannerServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-banner.service.abstraction"; import { BillingBannerServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-banner.service.abstraction";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { BillingBannerService } from "@bitwarden/common/billing/services/billing-banner.service"; import { BillingBannerService } from "@bitwarden/common/billing/services/billing-banner.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
@ -864,6 +866,16 @@ import { ModalService } from "./modal.service";
useClass: BillingBannerService, useClass: BillingBannerService,
deps: [StateProvider], deps: [StateProvider],
}, },
{
provide: OrganizationBillingServiceAbstraction,
useClass: OrganizationBillingService,
deps: [
CryptoServiceAbstraction,
EncryptService,
I18nServiceAbstraction,
OrganizationApiServiceAbstraction,
],
},
], ],
}) })
export class JslibServicesModule {} export class JslibServicesModule {}

View File

@ -27,4 +27,5 @@ export class OrganizationCreateRequest {
useSecretsManager: boolean; useSecretsManager: boolean;
additionalSmSeats: number; additionalSmSeats: number;
additionalServiceAccounts: number; additionalServiceAccounts: number;
isFromSecretsManagerTrial: boolean;
} }

View File

@ -0,0 +1,45 @@
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { PaymentMethodType, PlanType } from "../enums";
export type OrganizationInformation = {
name: string;
billingEmail: string;
businessName?: string;
};
export type PlanInformation = {
type: PlanType;
passwordManagerSeats?: number;
subscribeToSecretsManager?: boolean;
isFromSecretsManagerTrial?: boolean;
secretsManagerSeats?: number;
secretsManagerServiceAccounts?: number;
storage?: number;
};
export type BillingInformation = {
postalCode: string;
country: string;
taxId?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
};
export type PaymentInformation = {
paymentMethod: [string, PaymentMethodType];
billing: BillingInformation;
};
export type SubscriptionInformation = {
organization: OrganizationInformation;
plan?: PlanInformation;
payment?: PaymentInformation;
};
export abstract class OrganizationBillingServiceAbstraction {
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
}

View File

@ -40,17 +40,13 @@ export class BillingCustomerDiscount extends BaseResponse {
id: string; id: string;
active: boolean; active: boolean;
percentOff?: number; percentOff?: number;
appliesTo: string[];
constructor(response: any) { constructor(response: any) {
super(response); super(response);
this.id = this.getResponseProperty("Id"); this.id = this.getResponseProperty("Id");
this.active = this.getResponseProperty("Active"); this.active = this.getResponseProperty("Active");
this.percentOff = this.getResponseProperty("PercentOff"); this.percentOff = this.getResponseProperty("PercentOff");
this.appliesTo = this.getResponseProperty("AppliesTo");
} }
discountPrice = (price: number) => {
const discount = this !== null && this.active ? price * (this.percentOff / 100) : 0;
return price - discount;
};
} }

View File

@ -39,7 +39,7 @@ export class BillingSubscriptionResponse extends BaseResponse {
constructor(response: any) { constructor(response: any) {
super(response); super(response);
this.trialEndDate = this.getResponseProperty("TrialStartDate"); this.trialStartDate = this.getResponseProperty("TrialStartDate");
this.trialEndDate = this.getResponseProperty("TrialEndDate"); this.trialEndDate = this.getResponseProperty("TrialEndDate");
this.periodStartDate = this.getResponseProperty("PeriodStartDate"); this.periodStartDate = this.getResponseProperty("PeriodStartDate");
this.periodEndDate = this.getResponseProperty("PeriodEndDate"); this.periodEndDate = this.getResponseProperty("PeriodEndDate");
@ -55,6 +55,7 @@ export class BillingSubscriptionResponse extends BaseResponse {
} }
export class BillingSubscriptionItemResponse extends BaseResponse { export class BillingSubscriptionItemResponse extends BaseResponse {
productId: string;
name: string; name: string;
amount: number; amount: number;
quantity: number; quantity: number;
@ -65,6 +66,7 @@ export class BillingSubscriptionItemResponse extends BaseResponse {
constructor(response: any) { constructor(response: any) {
super(response); super(response);
this.productId = this.getResponseProperty("ProductId");
this.name = this.getResponseProperty("Name"); this.name = this.getResponseProperty("Name");
this.amount = this.getResponseProperty("Amount"); this.amount = this.getResponseProperty("Amount");
this.quantity = this.getResponseProperty("Quantity"); this.quantity = this.getResponseProperty("Quantity");

View File

@ -0,0 +1,143 @@
import { OrganizationApiServiceAbstraction as OrganizationApiService } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { OrganizationKeysRequest } from "../../admin-console/models/request/organization-keys.request";
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { EncString } from "../../platform/models/domain/enc-string";
import { OrgKey } from "../../types/key";
import {
OrganizationBillingServiceAbstraction,
OrganizationInformation,
PaymentInformation,
PlanInformation,
SubscriptionInformation,
} from "../abstractions/organization-billing.service";
import { PlanType } from "../enums";
interface OrganizationKeys {
encryptedKey: EncString;
publicKey: string;
encryptedPrivateKey: EncString;
encryptedCollectionName: EncString;
}
export class OrganizationBillingService implements OrganizationBillingServiceAbstraction {
constructor(
private cryptoService: CryptoService,
private encryptService: EncryptService,
private i18nService: I18nService,
private organizationApiService: OrganizationApiService,
) {}
async purchaseSubscription(subscription: SubscriptionInformation): Promise<OrganizationResponse> {
const request = new OrganizationCreateRequest();
const organizationKeys = await this.makeOrganizationKeys();
this.setOrganizationKeys(request, organizationKeys);
this.setOrganizationInformation(request, subscription.organization);
this.setPlanInformation(request, subscription.plan);
this.setPaymentInformation(request, subscription.payment);
return await this.organizationApiService.create(request);
}
async startFree(subscription: SubscriptionInformation): Promise<OrganizationResponse> {
const request = new OrganizationCreateRequest();
const organizationKeys = await this.makeOrganizationKeys();
this.setOrganizationKeys(request, organizationKeys);
this.setOrganizationInformation(request, subscription.organization);
this.setPlanInformation(request, subscription.plan);
return await this.organizationApiService.create(request);
}
private async makeOrganizationKeys(): Promise<OrganizationKeys> {
const [encryptedKey, key] = await this.cryptoService.makeOrgKey<OrgKey>();
const [publicKey, encryptedPrivateKey] = await this.cryptoService.makeKeyPair(key);
const encryptedCollectionName = await this.encryptService.encrypt(
this.i18nService.t("defaultCollection"),
key,
);
return {
encryptedKey,
publicKey,
encryptedPrivateKey,
encryptedCollectionName,
};
}
private setOrganizationInformation(
request: OrganizationCreateRequest,
information: OrganizationInformation,
): void {
request.name = information.name;
request.businessName = information.businessName;
request.billingEmail = information.billingEmail;
}
private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void {
request.key = keys.encryptedKey.encryptedString;
request.keys = new OrganizationKeysRequest(
keys.publicKey,
keys.encryptedPrivateKey.encryptedString,
);
request.collectionName = keys.encryptedCollectionName.encryptedString;
}
private setPaymentInformation(
request: OrganizationCreateRequest,
information: PaymentInformation,
) {
const [paymentToken, paymentMethodType] = information.paymentMethod;
request.paymentToken = paymentToken;
request.paymentMethodType = paymentMethodType;
const billingInformation = information.billing;
request.billingAddressPostalCode = billingInformation.postalCode;
request.billingAddressCountry = billingInformation.country;
if (billingInformation.taxId) {
request.taxIdNumber = billingInformation.taxId;
request.billingAddressLine1 = billingInformation.addressLine1;
request.billingAddressLine2 = billingInformation.addressLine2;
request.billingAddressCity = billingInformation.city;
request.billingAddressState = billingInformation.state;
}
}
private setPlanInformation(
request: OrganizationCreateRequest,
information: PlanInformation,
): void {
request.planType = information.type;
if (request.planType === PlanType.Free) {
request.useSecretsManager = information.subscribeToSecretsManager;
request.isFromSecretsManagerTrial = information.isFromSecretsManagerTrial;
return;
}
request.additionalSeats = information.passwordManagerSeats;
if (information.subscribeToSecretsManager) {
request.useSecretsManager = true;
request.isFromSecretsManagerTrial = information.isFromSecretsManagerTrial;
request.additionalSmSeats = information.secretsManagerSeats;
request.additionalServiceAccounts = information.secretsManagerServiceAccounts;
}
if (information.storage) {
request.additionalStorageGb = information.storage;
}
}
}