1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-23 11:43:46 +00:00

[PM-142] Add environment selector to trial initiation (#5546)

This commit is contained in:
André Bispo
2023-06-30 16:50:00 +01:00
committed by GitHub
parent 5247168c1e
commit 05c4d172a7
53 changed files with 155 additions and 53 deletions

View File

@@ -0,0 +1,17 @@
<div class="tw-pb-6 tw-pl-6">
<p class="tw-text-xl">{{ "trialThankYou" | i18n : orgLabel }}</p>
<ul class="tw-list-disc">
<li>
<p>
{{ "trialConfirmationEmail" | i18n }}
<span class="tw-font-bold">{{ email }}</span
>.
</p>
</li>
<li>
<p>
{{ "trialPaidInfoMessage" | i18n : orgLabel }}
</p>
</li>
</ul>
</div>

View File

@@ -0,0 +1,10 @@
import { Component, Input } from "@angular/core";
@Component({
selector: "app-trial-confirmation-details",
templateUrl: "confirmation-details.component.html",
})
export class ConfirmationDetailsComponent {
@Input() email: string;
@Input() orgLabel: string;
}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">The Bitwarden Password Manager</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Trusted by millions of individuals, teams, and organizations worldwide for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Store logins, secure notes, and more</li>
<li>Collaborate and share securely</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-abm-enterprise-content",
templateUrl: "abm-enterprise-content.component.html",
})
export class AbmEnterpriseContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">The Bitwarden Password Manager</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Trusted by millions of individuals, teams, and organizations worldwide for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Store logins, secure notes, and more</li>
<li>Collaborate and share securely</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-abm-teams-content",
templateUrl: "abm-teams-content.component.html",
})
export class AbmTeamsContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">Start Your Enterprise Free Trial Now</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Collaborate and share securely</li>
<li>Deploy and manage quickly and easily</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-cnet></app-logo-cnet>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-cnet-enterprise-content",
templateUrl: "cnet-enterprise-content.component.html",
})
export class CnetEnterpriseContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">Start Your Premium Account Now</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Store logins, secure notes, and more</li>
<li>Secure your account with advanced two-step login</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-cnet></app-logo-cnet>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-cnet-individual-content",
templateUrl: "cnet-individual-content.component.html",
})
export class CnetIndividualContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">Start Your Teams Free Trial Now</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Collaborate and share securely</li>
<li>Deploy and manage quickly and easily</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-cnet></app-logo-cnet>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-cnet-teams-content",
templateUrl: "cnet-teams-content.component.html",
})
export class CnetTeamsContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">The Bitwarden Password Manager</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Trusted by millions of individuals, teams, and organizations worldwide for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Store logins, secure notes, and more</li>
<li>Collaborate and share securely</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-default-content",
templateUrl: "default-content.component.html",
})
export class DefaultContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">The Bitwarden Password Manager</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Trusted by millions of individuals, teams, and organizations worldwide for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Store logins, secure notes, and more</li>
<li>Collaborate and share securely</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-enterprise-content",
templateUrl: "enterprise-content.component.html",
})
export class EnterpriseContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">The Bitwarden Password Manager</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Trusted by millions of individuals, teams, and organizations worldwide for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Store logins, secure notes, and more</li>
<li>Collaborate and share securely</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-enterprise1-content",
templateUrl: "enterprise1-content.component.html",
})
export class Enterprise1ContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">Start Your Enterprise Free Trial Now</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Collaborate and share securely</li>
<li>Deploy and manage quickly and easily</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-enterprise2-content",
templateUrl: "enterprise2-content.component.html",
})
export class Enterprise2ContentComponent {}

View File

@@ -0,0 +1,15 @@
<figure>
<figcaption>
<cite>
<img
src="../../images/register-layout/cnet-logo.svg"
class="tw-mx-auto tw-block tw-w-40"
alt="CNET Logo"
/>
</cite>
</figcaption>
<blockquote class="tw-mx-auto tw-mt-2 tw-max-w-xl tw-px-4 tw-text-center">
"No more excuses; start using Bitwarden today. The identity you save could be your own. The
money definitely will be."
</blockquote>
</figure>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-logo-cnet",
templateUrl: "logo-cnet.component.html",
})
export class LogoCnetComponent {}

View File

@@ -0,0 +1,15 @@
<figure>
<figcaption>
<cite>
<img
src="../../images/register-layout/forbes-logo.svg"
class="tw-mx-auto tw-block tw-w-40"
alt="Forbes Logo"
/>
</cite>
</figcaption>
<blockquote class="tw-mx-auto tw-mt-2 tw-max-w-xl tw-px-4 tw-text-center">
“Bitwarden boasts the backing of some of the world's best security experts and an attractive,
easy-to-use interface”
</blockquote>
</figure>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-logo-forbes",
templateUrl: "logo-forbes.component.html",
})
export class LogoForbesComponent {}

View File

@@ -0,0 +1,5 @@
<img
src="../../images/register-layout/usnews-360-badge.svg"
class="tw-mx-auto tw-block tw-w-48"
alt="US News 360 Reviews Best Password Manager"
/>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-logo-us-news",
templateUrl: "logo-us-news.component.html",
})
export class LogoUSNewsComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">The Bitwarden Password Manager</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Trusted by millions of individuals, teams, and organizations worldwide for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Store logins, secure notes, and more</li>
<li>Collaborate and share securely</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-teams-content",
templateUrl: "teams-content.component.html",
})
export class TeamsContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">Start Your Teams Free Trial Now</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Collaborate and share securely</li>
<li>Deploy and manage quickly and easily</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-teams1-content",
templateUrl: "teams1-content.component.html",
})
export class Teams1ContentComponent {}

View File

@@ -0,0 +1,17 @@
<h1 class="tw-text-4xl !tw-text-alt2">Start Your Free Trial Now</h1>
<div class="tw-pt-32">
<h2 class="tw-text-2xl">
Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password
storage and sharing.
</h2>
</div>
<ul class="tw-mt-12 tw-flex tw-flex-col tw-gap-10 tw-text-2xl tw-text-main">
<li>Collaborate and share securely</li>
<li>Deploy and manage quickly and easily</li>
<li>Access anywhere on any device</li>
<li>Create your account to get started</li>
</ul>
<div class="tw-mt-28 tw-flex tw-flex-col tw-items-center tw-gap-5">
<app-logo-forbes></app-logo-forbes>
<app-logo-us-news></app-logo-us-news>
</div>

View File

@@ -0,0 +1,7 @@
import { Component } from "@angular/core";
@Component({
selector: "app-teams2-content",
templateUrl: "teams2-content.component.html",
})
export class Teams2ContentComponent {}

View File

@@ -0,0 +1,125 @@
<!-- eslint-disable tailwindcss/no-custom-classname -->
<div *ngIf="accountCreateOnly" class="">
<h1 class="tw-mt-12 tw-text-center tw-text-xl">{{ "createAccount" | i18n }}</h1>
<div
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"
>
<app-register-form
[queryParamEmail]="email"
[enforcedPolicyOptions]="enforcedPolicyOptions"
[referenceDataValue]="referenceData"
></app-register-form>
</div>
</div>
<div *ngIf="!accountCreateOnly">
<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">
<!-- 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-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 class="tw-w-1/2">
<div *ngIf="!useTrialStepper">
<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"
>
<app-register-form
[queryParamEmail]="email"
[enforcedPolicyOptions]="enforcedPolicyOptions"
[referenceDataValue]="referenceData"
></app-register-form>
</div>
</div>
<div class="tw-pt-44" *ngIf="useTrialStepper">
<div class="tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background">
<div class="tw-flex tw-h-auto tw-w-full 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">
{{ "startYour7DayFreeTrialOfBitwardenFor" | i18n : org }}
</h2>
<environment-selector
[hasFlags]="true"
class="tw-mr-4 tw-mt-6 tw-text-end"
></environment-selector>
</div>
<app-vertical-stepper #stepper linear (selectionChange)="stepSelectionChange($event)">
<app-vertical-step label="Create Account" [editable]="false" [subLabel]="email">
<app-register-form
[isInTrialFlow]="true"
(createdAccount)="createdAccount($event)"
[referenceDataValue]="referenceData"
></app-register-form>
</app-vertical-step>
<app-vertical-step label="Organization Information" [subLabel]="orgInfoSubLabel">
<app-org-info [nameOnly]="true" [formGroup]="orgInfoFormGroup"></app-org-info>
<button
type="button"
bitButton
buttonType="primary"
[disabled]="orgInfoFormGroup.get('name').invalid"
cdkStepperNext
>
{{ "next" | i18n }}
</button>
</app-vertical-step>
<app-vertical-step label="Billing" [subLabel]="billingSubLabel">
<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>

View File

@@ -0,0 +1,311 @@
import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common";
import { NO_ERRORS_SCHEMA } from "@angular/core";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { FormBuilder, UntypedFormBuilder } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
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 { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
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";
import { RouterService } from "../../core";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
describe("TrialInitiationComponent", () => {
let component: TrialInitiationComponent;
let fixture: ComponentFixture<TrialInitiationComponent>;
const mockQueryParams = new BehaviorSubject<any>({ org: "enterprise" });
const testOrgId = "91329456-5b9f-44b3-9279-6bb9ee6a0974";
const formBuilder: FormBuilder = new FormBuilder();
let routerSpy: jest.SpyInstance;
let stateServiceMock: MockProxy<StateService>;
let policyApiServiceMock: MockProxy<PolicyApiServiceAbstraction>;
let policyServiceMock: MockProxy<PolicyService>;
beforeEach(() => {
// only define services directly that we want to mock return values in this component
stateServiceMock = mock<StateService>();
policyApiServiceMock = mock<PolicyApiServiceAbstraction>();
policyServiceMock = mock<PolicyService>();
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: "trial", component: TrialInitiationComponent },
{
path: `organizations/${testOrgId}/vault`,
component: BlankComponent,
},
{
path: `organizations/${testOrgId}/manage/members`,
component: BlankComponent,
},
]),
],
declarations: [TrialInitiationComponent, I18nPipe],
providers: [
UntypedFormBuilder,
{
provide: ActivatedRoute,
useValue: {
queryParams: mockQueryParams.asObservable(),
},
},
{ provide: StateService, useValue: stateServiceMock },
{ provide: PolicyService, useValue: policyServiceMock },
{ provide: PolicyApiServiceAbstraction, useValue: policyApiServiceMock },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: I18nService, useValue: mock<I18nService>() },
{ provide: TitleCasePipe, useValue: mock<TitleCasePipe>() },
{
provide: VerticalStepperComponent,
useClass: VerticalStepperStubComponent,
},
{
provide: RouterService,
useValue: mock<RouterService>(),
},
],
schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component)
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
// These tests demonstrate mocking service calls
describe("onInit() enforcedPolicyOptions", () => {
it("should not set enforcedPolicyOptions if state service returns no invite", async () => {
stateServiceMock.getOrganizationInvitation.mockReturnValueOnce(null);
// Need to recreate component with new service mock
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
await component.ngOnInit();
expect(component.enforcedPolicyOptions).toBe(undefined);
});
it("should set enforcedPolicyOptions if state service returns an invite", async () => {
// Set up service method mocks
stateServiceMock.getOrganizationInvitation.mockReturnValueOnce(
Promise.resolve({
organizationId: testOrgId,
token: "token",
email: "testEmail",
organizationUserId: "123",
})
);
policyApiServiceMock.getPoliciesByToken.mockReturnValueOnce(
Promise.resolve({
data: [
{
id: "345",
organizationId: testOrgId,
type: 1,
data: [
{
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
},
],
enabled: true,
},
],
} as ListResponse<PolicyResponse>)
);
policyServiceMock.masterPasswordPolicyOptions$.mockReturnValue(
of({
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
} as MasterPasswordPolicyOptions)
);
// Need to recreate component with new service mocks
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
await component.ngOnInit();
expect(component.enforcedPolicyOptions).toMatchObject({
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
});
});
});
// These tests demonstrate route params
describe("Route params", () => {
it("should set org variable to be enterprise and plan to EnterpriseAnnually if org param is enterprise", fakeAsync(() => {
mockQueryParams.next({ org: "enterprise" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.org).toBe("enterprise");
expect(component.plan).toBe(PlanType.EnterpriseAnnually);
}));
it("should not set org variable if no org param is provided", fakeAsync(() => {
mockQueryParams.next({});
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.org).toBe("");
expect(component.accountCreateOnly).toBe(true);
}));
it("should not set the org if org param is invalid ", fakeAsync(async () => {
mockQueryParams.next({ org: "hahahaha" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.org).toBe("");
expect(component.accountCreateOnly).toBe(true);
}));
it("should set the layout variable if layout param is valid ", fakeAsync(async () => {
mockQueryParams.next({ layout: "teams1" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
expect(component.layout).toBe("teams1");
expect(component.accountCreateOnly).toBe(false);
}));
it("should not set the layout variable and leave as 'default' if layout param is invalid ", fakeAsync(async () => {
mockQueryParams.next({ layout: "asdfasdf" });
tick(); // wait for resolution
fixture = TestBed.createComponent(TrialInitiationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.ngOnInit();
expect(component.layout).toBe("default");
expect(component.accountCreateOnly).toBe(true);
}));
});
// These tests demonstrate the use of a stub component
describe("createAccount()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("should set email and call verticalStepper.next()", fakeAsync(() => {
const verticalStepperNext = jest.spyOn(component.verticalStepper, "next");
component.createdAccount("test@email.com");
expect(verticalStepperNext).toHaveBeenCalled();
expect(component.email).toBe("test@email.com");
}));
});
describe("billingSuccess()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("should set orgId and call verticalStepper.next()", () => {
const verticalStepperNext = jest.spyOn(component.verticalStepper, "next");
component.billingSuccess({ orgId: testOrgId });
expect(verticalStepperNext).toHaveBeenCalled();
expect(component.orgId).toBe(testOrgId);
});
});
describe("stepSelectionChange()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("on step 2 should show organization copy text", () => {
component.stepSelectionChange({
selectedIndex: 1,
previouslySelectedIndex: 0,
} as StepperSelectionEvent);
expect(component.orgInfoSubLabel).toContain("Enter your");
expect(component.orgInfoSubLabel).toContain(" organization information");
});
it("going from step 2 to 3 should set the orgInforSubLabel to be the Org name from orgInfoFormGroup", () => {
component.orgInfoFormGroup = formBuilder.group({
name: ["Hooli"],
email: [""],
});
component.stepSelectionChange({
selectedIndex: 2,
previouslySelectedIndex: 1,
} as StepperSelectionEvent);
expect(component.orgInfoSubLabel).toContain("Hooli");
});
});
describe("previousStep()", () => {
beforeEach(() => {
component.verticalStepper = TestBed.createComponent(VerticalStepperStubComponent)
.componentInstance as VerticalStepperComponent;
});
it("should call verticalStepper.previous()", fakeAsync(() => {
const verticalStepperPrevious = jest.spyOn(component.verticalStepper, "previous");
component.previousStep();
expect(verticalStepperPrevious).toHaveBeenCalled();
}));
});
// These tests demonstrate router navigation
describe("navigation methods", () => {
beforeEach(() => {
component.orgId = testOrgId;
const router = TestBed.inject(Router);
fixture.detectChanges();
routerSpy = jest.spyOn(router, "navigate");
});
describe("navigateToOrgVault", () => {
it("should call verticalStepper.previous()", fakeAsync(() => {
component.navigateToOrgVault();
expect(routerSpy).toHaveBeenCalledWith(["organizations", testOrgId, "vault"]);
}));
});
describe("navigateToOrgVault", () => {
it("should call verticalStepper.previous()", fakeAsync(() => {
component.navigateToOrgInvite();
expect(routerSpy).toHaveBeenCalledWith(["organizations", testOrgId, "manage", "members"]);
}));
});
});
});
export class VerticalStepperStubComponent extends VerticalStepperComponent {}
export class BlankComponent {} // For router tests

View File

@@ -0,0 +1,245 @@
import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common";
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { first, Subject, takeUntil } from "rxjs";
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 { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ProductType } from "@bitwarden/common/enums";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
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";
import { RouterService } from "./../../core/router.service";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
individual = "individual",
premium = "premium",
free = "free",
}
enum ValidLayoutParams {
default = "default",
teams = "teams",
teams1 = "teams1",
teams2 = "teams2",
enterprise = "enterprise",
enterprise1 = "enterprise1",
enterprise2 = "enterprise2",
cnetcmpgnent = "cnetcmpgnent",
cnetcmpgnind = "cnetcmpgnind",
cnetcmpgnteams = "cnetcmpgnteams",
abmenterprise = "abmenterprise",
abmteams = "abmteams",
}
@Component({
selector: "app-trial",
templateUrl: "trial-initiation.component.html",
})
export class TrialInitiationComponent implements OnInit, OnDestroy {
email = "";
org = "";
orgInfoSubLabel = "";
orgId = "";
orgLabel = "";
billingSubLabel = "";
layout = "default";
plan: PlanType;
product: ProductType;
accountCreateOnly = true;
useTrialStepper = false;
policies: Policy[];
enforcedPolicyOptions: MasterPasswordPolicyOptions;
trialFlowOrgs: string[] = [
ValidOrgParams.teams,
ValidOrgParams.enterprise,
ValidOrgParams.families,
];
routeFlowOrgs: string[] = [
ValidOrgParams.free,
ValidOrgParams.premium,
ValidOrgParams.individual,
];
layouts = ValidLayoutParams;
referenceData: ReferenceEventRequest;
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
orgInfoFormGroup = this.formBuilder.group({
name: ["", { validators: [Validators.required, Validators.maxLength(50)], updateOn: "change" }],
email: [""],
});
private set referenceDataId(referenceId: string) {
if (referenceId != null) {
this.referenceData.id = referenceId;
} else {
this.referenceData.id = ("; " + document.cookie)
.split("; reference=")
.pop()
.split(";")
.shift();
}
if (this.referenceData.id === "") {
this.referenceData.id = null;
}
}
private destroy$ = new Subject<void>();
constructor(
private route: ActivatedRoute,
protected router: Router,
private formBuilder: UntypedFormBuilder,
private titleCasePipe: TitleCasePipe,
private stateService: StateService,
private logService: LogService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private i18nService: I18nService,
private routerService: RouterService
) {}
async ngOnInit(): Promise<void> {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.route.queryParams.pipe(first()).subscribe((qParams) => {
this.referenceData = new ReferenceEventRequest();
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
this.email = qParams.email;
}
this.referenceDataId = qParams.reference;
if (Object.values(ValidLayoutParams).includes(qParams.layout)) {
this.layout = qParams.layout;
this.accountCreateOnly = false;
}
if (this.trialFlowOrgs.includes(qParams.org)) {
this.org = qParams.org;
this.orgLabel = this.titleCasePipe.transform(this.org);
this.useTrialStepper = true;
this.referenceData.flow = qParams.org;
if (this.org === ValidOrgParams.families) {
this.plan = PlanType.FamiliesAnnually;
this.product = ProductType.Families;
} else if (this.org === ValidOrgParams.teams) {
this.plan = PlanType.TeamsAnnually;
this.product = ProductType.Teams;
} else if (this.org === ValidOrgParams.enterprise) {
this.plan = PlanType.EnterpriseAnnually;
this.product = ProductType.Enterprise;
}
} else if (this.routeFlowOrgs.includes(qParams.org)) {
this.referenceData.flow = qParams.org;
const route = this.router.createUrlTree(["create-organization"], {
queryParams: { plan: qParams.org },
});
this.routerService.setPreviousUrl(route.toString());
}
// 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.stateService.getOrganizationInvitation();
if (invite != null) {
try {
const policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId
);
if (policies.data != null) {
const policiesData = policies.data.map((p) => new PolicyData(p));
this.policies = policiesData.map((p) => new Policy(p));
}
} catch (e) {
this.logService.error(e);
}
}
if (this.policies != null) {
this.policyService
.masterPasswordPolicyOptions$(this.policies)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPolicyOptions = enforcedPasswordPolicyOptions;
});
}
this.orgInfoFormGroup.controls.name.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.orgInfoFormGroup.controls.name.markAsTouched();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
stepSelectionChange(event: StepperSelectionEvent) {
// Set org info sub label
if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") {
this.orgInfoSubLabel =
"Enter your " + this.titleCasePipe.transform(this.org) + " organization information";
} else if (event.previouslySelectedIndex === 1) {
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value;
}
//set billing sub label
if (event.selectedIndex === 2) {
this.billingSubLabel = this.i18nService.t("billingTrialSubLabel");
}
}
createdAccount(email: string) {
this.email = email;
this.orgInfoFormGroup.get("email")?.setValue(email);
this.verticalStepper.next();
}
billingSuccess(event: any) {
this.orgId = event?.orgId;
this.billingSubLabel = event?.subLabelText;
this.verticalStepper.next();
}
navigateToOrgVault() {
this.router.navigate(["organizations", this.orgId, "vault"]);
}
navigateToOrgInvite() {
this.router.navigate(["organizations", this.orgId, "manage", "members"]);
}
previousStep() {
this.verticalStepper.previous();
}
private setupFamilySponsorship(sponsorshipToken: string) {
if (sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
queryParams: { plan: sponsorshipToken },
});
this.routerService.setPreviousUrl(route.toString());
}
}
}

View File

@@ -0,0 +1,68 @@
import { CdkStepperModule } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormFieldModule } from "@bitwarden/components";
import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module";
import { RegisterFormModule } from "../../auth/register-form/register-form.module";
import { BillingComponent } from "../../billing/accounts/trial-initiation/billing.component";
import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module";
import { LooseComponentsModule, SharedModule } from "../../shared";
import { ConfirmationDetailsComponent } from "./confirmation-details.component";
import { AbmEnterpriseContentComponent } from "./content/abm-enterprise-content.component";
import { AbmTeamsContentComponent } from "./content/abm-teams-content.component";
import { CnetEnterpriseContentComponent } from "./content/cnet-enterprise-content.component";
import { CnetIndividualContentComponent } from "./content/cnet-individual-content.component";
import { CnetTeamsContentComponent } from "./content/cnet-teams-content.component";
import { DefaultContentComponent } from "./content/default-content.component";
import { EnterpriseContentComponent } from "./content/enterprise-content.component";
import { Enterprise1ContentComponent } from "./content/enterprise1-content.component";
import { Enterprise2ContentComponent } from "./content/enterprise2-content.component";
import { LogoCnetComponent } from "./content/logo-cnet.component";
import { LogoForbesComponent } from "./content/logo-forbes.component";
import { LogoUSNewsComponent } from "./content/logo-us-news.component";
import { TeamsContentComponent } from "./content/teams-content.component";
import { Teams1ContentComponent } from "./content/teams1-content.component";
import { Teams2ContentComponent } from "./content/teams2-content.component";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
@NgModule({
imports: [
SharedModule,
CdkStepperModule,
VerticalStepperModule,
FormFieldModule,
RegisterFormModule,
OrganizationCreateModule,
LooseComponentsModule,
EnvironmentSelectorModule,
],
declarations: [
TrialInitiationComponent,
EnterpriseContentComponent,
TeamsContentComponent,
ConfirmationDetailsComponent,
BillingComponent,
DefaultContentComponent,
EnterpriseContentComponent,
Enterprise1ContentComponent,
Enterprise2ContentComponent,
TeamsContentComponent,
Teams1ContentComponent,
Teams2ContentComponent,
CnetEnterpriseContentComponent,
CnetIndividualContentComponent,
CnetTeamsContentComponent,
AbmEnterpriseContentComponent,
AbmTeamsContentComponent,
LogoCnetComponent,
LogoForbesComponent,
LogoUSNewsComponent,
],
exports: [TrialInitiationComponent],
providers: [TitleCasePipe],
})
export class TrialInitiationModule {}

View File

@@ -0,0 +1,47 @@
<!-- eslint-disable tailwindcss/no-custom-classname -->
<div class="tw-m-2.5 tw-h-16 tw-text-center">
<button
type="button"
(click)="selectStep()"
[disabled]="disabled"
class="tw-flex tw-w-full tw-items-center tw-border-none tw-bg-transparent"
[ngClass]="{
'hover:tw-bg-secondary-100': !disabled && step.editable
}"
[attr.aria-expanded]="selected"
>
<span
class="tw-mr-3.5 tw-w-9 tw-rounded-full tw-font-bold tw-leading-9"
*ngIf="!step.completed"
[ngClass]="{
'tw-bg-primary-500 tw-text-contrast': selected,
'tw-bg-secondary-300 tw-text-main': !selected && !disabled && step.editable,
'tw-bg-transparent tw-text-muted': disabled
}"
>
{{ stepNumber }}
</span>
<span
class="tw-mr-3.5 tw-w-9 tw-rounded-full tw-bg-primary-500 tw-font-bold tw-leading-9 tw-text-contrast"
*ngIf="step.completed"
>
<i class="bwi bwi-fw bwi-check tw-p-1" aria-hidden="true"></i>
</span>
<div
class="tw-txt-main tw-mt-3.5 tw-h-12 tw-text-left tw-leading-snug"
[ngClass]="{
'tw-font-bold': selected
}"
>
<p
class="main-label text tw-mb-1 tw-text-main"
[ngClass]="{
'tw-mt-1': !step.subLabel
}"
>
{{ step.label }}
</p>
<p class="sub-label small tw-text-muted">{{ step.subLabel }}</p>
</div>
</button>
</div>

View File

@@ -0,0 +1,20 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { VerticalStep } from "./vertical-step.component";
@Component({
selector: "app-vertical-step-content",
templateUrl: "vertical-step-content.component.html",
})
export class VerticalStepContentComponent {
@Output() onSelectStep = new EventEmitter<void>();
@Input() disabled = false;
@Input() selected = false;
@Input() step: VerticalStep;
@Input() stepNumber: number;
selectStep() {
this.onSelectStep.emit();
}
}

View File

@@ -0,0 +1,8 @@
<ng-template>
<div
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 }"
>
<ng-content></ng-content>
</div>
</ng-template>

View File

@@ -0,0 +1,12 @@
import { CdkStep } from "@angular/cdk/stepper";
import { Component, Input } from "@angular/core";
@Component({
selector: "app-vertical-step",
templateUrl: "vertical-step.component.html",
providers: [{ provide: CdkStep, useExisting: VerticalStep }],
})
export class VerticalStep extends CdkStep {
@Input() subLabel = "";
@Input() applyBorder = true;
}

View File

@@ -0,0 +1,22 @@
<div>
<ul class="tw-flex tw-list-none tw-flex-col tw-flex-wrap tw-p-5">
<li *ngFor="let step of steps; let i = index; let isLast = last">
<app-vertical-step-content
[disabled]="isStepDisabled(i)"
[selected]="selectedIndex === i"
[step]="step"
[stepNumber]="i + 1"
(onSelectStep)="selectStepByIndex(i)"
></app-vertical-step-content>
<div
class="tw-inline-block tw-pl-7"
*ngIf="selectedIndex === i"
[ngTemplateOutlet]="selected ? selected.content : null"
></div>
<div
class="tw-ml-8 tw-h-6 tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300"
*ngIf="!isLast && !(selectedIndex === i)"
></div>
</li>
</ul>
</div>

View File

@@ -0,0 +1,33 @@
import { CdkStepper } from "@angular/cdk/stepper";
import { Component, Input, QueryList } from "@angular/core";
import { VerticalStep } from "./vertical-step.component";
@Component({
selector: "app-vertical-stepper",
templateUrl: "vertical-stepper.component.html",
providers: [{ provide: CdkStepper, useExisting: VerticalStepperComponent }],
})
export class VerticalStepperComponent extends CdkStepper {
readonly steps: QueryList<VerticalStep>;
@Input()
activeClass = "active";
isNextButtonHidden() {
return !(this.steps.length === this.selectedIndex + 1);
}
isStepDisabled(index: number) {
if (this.selectedIndex !== index) {
return this.selectedIndex === index - 1
? !this.steps.find((_, i) => i == index - 1)?.completed
: true;
}
return false;
}
selectStepByIndex(index: number): void {
this.selectedIndex = index;
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../../shared";
import { VerticalStepContentComponent } from "./vertical-step-content.component";
import { VerticalStep } from "./vertical-step.component";
import { VerticalStepperComponent } from "./vertical-stepper.component";
@NgModule({
imports: [SharedModule],
declarations: [VerticalStepperComponent, VerticalStep, VerticalStepContentComponent],
exports: [VerticalStepperComponent, VerticalStep],
})
export class VerticalStepperModule {}