1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-06 10:33:57 +00:00

[SG-360] Remove the /modules/ folder (#3225)

* Move Web's SharedModule to /app/shared/

This commit relocates `SharedModule` from `/app/modules` to `/app/shared` to
align with [ADR #11](https://adr.bitwarden.com/decisions/0011-angular-folder-structure)

All other changes are just to adjust imports that reference `SharedModule`.

* Move /modules/pipes to /shared/pipes

This commit relocates `PipesModule` from `/app/modules` to `/app/shared` to
align with [ADR #11](https://adr.bitwarden.com/decisions/0011-angular-folder-structure)

All other changes are just to adjust imports that reference `PipesModule`.

* Move LooseComponentsModule to /shared/

This commit relocates `LooseComponentsModule` from `/app/modules` to `/app/shared` to
align with [ADR #11](https://adr.bitwarden.com/decisions/0011-angular-folder-structure)

All other changes are just to adjust imports that reference `LooseComponentsModule`.

* Move VerticalStepperModule to /shared/

This commit relocates `VerticalStepperModule` from `/app/modules` to `/app/shared` to
align with [ADR #11](https://adr.bitwarden.com/decisions/0011-angular-folder-structure)

All other changes are just to adjust imports that reference `VerticalStepperModule`.

* Move TrialInitiationModule to /shared/

This commit relocates `TrialInitiationModule` & `RegisterFormModule` from `/app/modules` to `/app/shared` to
align with [ADR #11](https://adr.bitwarden.com/decisions/0011-angular-folder-structure)

All other changes are just to adjust imports that reference `TrialInitiationModule` or `RegisterFormModule`.

* Move /modules/organization to /organization

This commit relocates all modules in `/app/modules/organization` to `/app/organization` to
align with [ADR #11](https://adr.bitwarden.com/decisions/0011-angular-folder-structure)

All other changes are just to adjust imports that reference the moved modules.

* Move /modules/vault/ to /vault

This commit relocates the IndividualVaultModule to `/app/modules/vault`, and the OrganizationVaultModule to `/app/organization/vault` to
align with [ADR #11](https://adr.bitwarden.com/decisions/0011-angular-folder-structure)

All other changes are just to adjust imports that reference the moved modules.

* Move VaultFiltersModule to /vault

This commit relocates the `VaultFilterModule` to `/app/vault/vault-filter`, and the OrganizationVaultFilterComponent to `/app/organization/vault/vault-filter` to
align with [ADR #11](https://adr.bitwarden.com/decisions/0011-angular-folder-structure)

All other changes are just to adjust imports that reference the moved modules.

* Remove the /modules/ folder from desktop

This commit relocates the `VaultFilterModule` to `/app/vault/vault-filter`, and the OrganizationVaultFilterComponent to `/app/organization/vault/vault-filter` to
align with [ADR #11](https://adr.bitwarden.com/decisions/0011-angular-folder-structure)

All other changes are just to adjust imports that reference the moved modules.

* Move Libs' VaultFiltersComponent to /vault/

This commit moves the lib's logic for `VaultFiltersModule` from
`/modules/` to `/vault/`

All other changes are just to adjust imports that reference the moved
files.

* Rename VaultModule -> SharedVaultModule

* Rename IndividualVaultModule -> VaultModule

* Rename OrganizationVaultModule -> VaultModule

* Rename OrganizationVaultFilterComponent

Rename OrganizationVaultFilterComponent to VaultFilterComponent

* Seperate the two VaultFilterComponents

This commit seperate the `OrganizationVaultFilterComponent` from the `VaultFilerModule`, which is only used by the individual vault.
A `VaultFilterSharedModule` was created to declare shared components and provide shared services between the two implementations.
This was done to align with best practices for NgModules.

* [r] Move VerticalStepperModule to /account/

More specifically, /account/trial/

* [r] Declare PaymentComponent in LooseComponentsModule

`PaymentComponent` is not reused across domains and should not be
declared in `SharedModule`.

I've moved it to `LooseComponentsModule` for now, but later it will need
to be exported from a `SettingsModule`.

* [r] Declare TaxInfoComponent in LooseComponentsModule

* [r] Reloacte Pipes out of /shared/

* [r] Extract locales out of SharedModule

* [r] Add documentation to shared module

* [r] Cleanup imports

* [r] Use an index.ts file for /shared/

* [r] Add eslint rule restricting access to /shared/

Co-authored-by: Hinton <hinton@users.noreply.github.com>
This commit is contained in:
Addison Beck
2022-08-08 15:08:35 -04:00
committed by GitHub
parent 56ce85c69c
commit 95bb429281
120 changed files with 289 additions and 293 deletions

View File

@@ -0,0 +1,129 @@
<form
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
class="tw-container tw-mx-auto"
[formGroup]="formGroup"
>
<div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
<input id="register-form_input_email" bitInput type="email" formControlName="email" />
<bit-hint>{{ "emailAddressDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "name" | i18n }}</bit-label>
<input id="register-form_input_name" bitInput type="text" formControlName="name" />
<bit-hint>{{ "yourNameDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
<div class="tw-mb-3">
<app-callout
type="info"
[enforcedPolicyOptions]="enforcedPolicyOptions"
*ngIf="enforcedPolicyOptions"
>
</app-callout>
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
id="register-form_input_master-password"
bitInput
type="{{ showPassword ? 'text' : 'password' }}"
formControlName="masterPassword"
/>
<button type="button" bitSuffix bitButton (click)="togglePassword()">
<i
aria-hidden="true"
class="bwi bwi-lg bwi-eye"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
></i>
</button>
<bit-hint>
<span class="tw-font-semibold">Important:</span>
{{ "masterPassImportant" | i18n }}
</bit-hint>
</bit-form-field>
<app-password-strength
[password]="formGroup.get('masterPassword')?.value"
[email]="formGroup.get('email')?.value"
[name]="formGroup.get('name')?.value"
[showText]="true"
(passwordStrengthResult)="getStrengthResult($event)"
>
</app-password-strength>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "reTypeMasterPass" | i18n }}</bit-label>
<input
id="register-form_input_confirm-master-password"
bitInput
type="{{ showPassword ? 'text' : 'password' }}"
formControlName="confirmMasterPassword"
/>
<button type="button" bitSuffix bitButton (click)="togglePassword()">
<i
aria-hidden="true"
class="bwi bwi-lg bwi-eye"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
></i>
</button>
</bit-form-field>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "masterPassHint" | i18n }}</bit-label>
<input id="register-form_input_hint" bitInput type="text" formControlName="hint" />
<bit-hint>{{ "masterPassHintDesc" | i18n }}</bit-hint>
</bit-form-field>
</div>
<div [hidden]="!showCaptcha()">
<iframe id="hcaptcha_iframe" height="80"></iframe>
</div>
<div class="tw-mb-3 tw-flex tw-items-start" *ngIf="showTerms">
<div class="tw-flex tw-h-6 tw-items-center">
<input
id="register-form_input_accept-policies"
class="tw-border-gray-300 tw-w-4 tw-rounded tw-border"
bitInput
type="checkbox"
formControlName="acceptPolicies"
/>
</div>
<bit-label class="ml-2">
{{ "acceptPolicies" | i18n }}<br />
<a href="https://bitwarden.com/terms/" target="_blank" rel="noopener">{{
"termsOfService" | i18n
}}</a
>,
<a href="https://bitwarden.com/privacy/" target="_blank" rel="noopener">{{
"privacyPolicy" | i18n
}}</a>
</bit-label>
</div>
<div class="tw-mb-3 tw-flex">
<bit-submit-button [loading]="form.loading">{{ "createAccount" | i18n }}</bit-submit-button>
<a
bitButton
buttonType="secondary"
routerLink="/login"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
<i class="bwi bwi-sign-in tw-mr-2"></i>
{{ "logIn" | i18n }}
</a>
</div>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary>
</div>
</form>

View File

@@ -0,0 +1,87 @@
import { Component, Input } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/components/register.component";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthService } from "@bitwarden/common/abstractions/auth.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
import { FormValidationErrorsService } from "@bitwarden/common/abstractions/formValidationErrors.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PasswordGenerationService } from "@bitwarden/common/abstractions/passwordGeneration.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/models/domain/masterPasswordPolicyOptions";
@Component({
selector: "app-register-form",
templateUrl: "./register-form.component.html",
})
export class RegisterFormComponent extends BaseRegisterComponent {
@Input() queryParamEmail: string;
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
showErrorSummary = false;
constructor(
formValidationErrorService: FormValidationErrorsService,
formBuilder: UntypedFormBuilder,
authService: AuthService,
router: Router,
i18nService: I18nService,
cryptoService: CryptoService,
apiService: ApiService,
stateService: StateService,
platformUtilsService: PlatformUtilsService,
passwordGenerationService: PasswordGenerationService,
private policyService: PolicyService,
environmentService: EnvironmentService,
logService: LogService
) {
super(
formValidationErrorService,
formBuilder,
authService,
router,
i18nService,
cryptoService,
apiService,
stateService,
platformUtilsService,
passwordGenerationService,
environmentService,
logService
);
}
async ngOnInit() {
await super.ngOnInit();
if (this.queryParamEmail) {
this.formGroup.get("email")?.setValue(this.queryParamEmail);
}
}
async submit() {
if (
this.enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
this.passwordStrengthResult.score,
this.formGroup.get("masterPassword")?.value,
this.enforcedPolicyOptions
)
) {
this.platformUtilsService.showToast(
"error",
this.i18nService.t("errorOccurred"),
this.i18nService.t("masterPasswordPolicyRequirementsNotMet")
);
return;
}
await super.submit(false);
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from "@angular/core";
import { SharedModule } from "../../shared";
import { RegisterFormComponent } from "./register-form.component";
@NgModule({
imports: [SharedModule],
declarations: [RegisterFormComponent],
exports: [RegisterFormComponent],
})
export class RegisterFormModule {}

View File

@@ -0,0 +1,48 @@
<form #form [formGroup]="formGroup" [appApiAction]="formPromise" (ngSubmit)="submit()">
<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" *ngFor="let selectablePlan of selectablePlans">
<label class="tw- tw-block tw-text-main" for="interval{{ selectablePlan.type }}">
<input
checked
class="tw-h-4 tw-w-4 tw-align-middle"
id="interval{{ selectablePlan.type }}"
name="plan"
type="radio"
[value]="selectablePlan.type"
formControlName="plan"
/>
<ng-container *ngIf="selectablePlan.isAnnual">
{{ "annual" | i18n }} -
{{
(selectablePlan.basePrice === 0 ? selectablePlan.seatPrice : selectablePlan.basePrice)
| currency: "$"
}}
/{{ "yr" | i18n }}
</ng-container>
<ng-container *ngIf="!selectablePlan.isAnnual">
{{ "monthly" | i18n }} -
{{
(selectablePlan.basePrice === 0 ? selectablePlan.seatPrice : selectablePlan.basePrice)
| currency: "$"
}}
/{{ "monthAbbr" | i18n }}
</ng-container>
</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">
<bit-submit-button [loading]="form.loading">{{ "startTrial" | i18n }}</bit-submit-button>
<button bitButton type="button" buttonType="secondary" (click)="stepBack()">Back</button>
</div>
</div>
</form>

View File

@@ -0,0 +1,70 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { UntypedFormBuilder, FormGroup } from "@angular/forms";
import { Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CryptoService } from "@bitwarden/common/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/abstractions/messaging.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
import { SyncService } from "@bitwarden/common/abstractions/sync.service";
import { ProductType } from "@bitwarden/common/enums/productType";
import { OrganizationPlansComponent } from "../../settings/organization-plans.component";
@Component({
selector: "app-billing",
templateUrl: "./billing.component.html",
})
export class BillingComponent extends OrganizationPlansComponent {
@Input() orgInfoForm: FormGroup;
@Output() previousStep = new EventEmitter();
constructor(
apiService: ApiService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
cryptoService: CryptoService,
router: Router,
syncService: SyncService,
policyService: PolicyService,
organizationService: OrganizationService,
logService: LogService,
messagingService: MessagingService,
formBuilder: UntypedFormBuilder
) {
super(
apiService,
i18nService,
platformUtilsService,
cryptoService,
router,
syncService,
policyService,
organizationService,
logService,
messagingService,
formBuilder
);
}
async ngOnInit() {
const additionalSeats = this.product == ProductType.Families ? 0 : 1;
this.formGroup.patchValue({
name: this.orgInfoForm.get("name")?.value,
billingEmail: this.orgInfoForm.get("email")?.value,
additionalSeats: additionalSeats,
plan: this.plan,
product: this.product,
});
this.isInTrialFlow = true;
await super.ngOnInit();
}
stepBack() {
this.previousStep.emit();
}
}

View File

@@ -0,0 +1,17 @@
<div class="tw-pl-6 tw-pb-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,11 @@
<h1 class="!tw-text-alt2">You've chosen Bitwarden for Enterprise</h1>
<div class="tw-pt-24">
<h2>What you can do with Bitwarden for Enterprise</h2>
</div>
<div class="tw-mt-12 tw-text-3xl tw-text-main">
<p class="tw-mt-2.5 tw-mb-20">Collaborate and share securely</p>
<p class="tw-mt-2.5 tw-mb-20">Deploy and manage quickly and easily</p>
<p class="tw-mt-2.5 tw-mb-20">Access anywhere on any device</p>
<p class="tw-mt-2.5 tw-mb-20">Create your account to get started</p>
</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,13 @@
<h1 class="!tw-text-alt2">You've chosen Bitwarden for Families</h1>
<div class="tw-pt-24">
<h2>
Trusted by millions of individuals, teams, and organizations worldwide for secure password
storage and sharing.
</h2>
</div>
<div class="tw-mt-12 tw-text-3xl tw-text-main">
<p class="tw-mt-2.5 tw-mb-20">Collaborate and share securely</p>
<p class="tw-mt-2.5 tw-mb-20">Deploy and manage quickly and easily</p>
<p class="tw-mt-2.5 tw-mb-20">Access anywhere on any device</p>
<p class="tw-mt-2.5 tw-mb-20">Create your account to get started</p>
</div>

View File

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

View File

@@ -0,0 +1,10 @@
<h1 class="!tw-text-alt2">You've chosen Bitwarden for Teams</h1>
<div class="tw-pt-24">
<h2>What you can do with Btiwarden for Teams</h2>
</div>
<div class="tw-mt-12 tw-text-3xl tw-text-main">
<p class="tw-mt-2.5 tw-mb-20">Collaborate and share securely</p>
<p class="tw-mt-2.5 tw-mb-20">Deploy and manage quickly and easily</p>
<p class="tw-mt-2.5 tw-mb-20">Access anywhere on any device</p>
<p class="tw-mt-2.5 tw-mb-20">Create your account to get started</p>
</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,94 @@
<div *ngIf="accountCreateOnly" class="">
<h1 class="tw-mt-12 tw-text-center tw-text-xl" *ngIf="!layout">{{ "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"
></app-register-form>
</div>
</div>
<div *ngIf="!accountCreateOnly">
<div
class="tw-absolute tw--z-10 tw--mt-48 tw-h-96 tw-w-full tw--skew-y-3 tw-bg-background-alt2"
></div>
<div class="tw-min-w-4xl tw-mx-auto tw-flex tw-max-w-screen-xl 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"
/>
<!-- This is to for illustrative purposes and content will be replaced by marketing -->
<div class="tw-pt-12">
<!-- Teams Body -->
<app-teams-content *ngIf="org === 'teams'"></app-teams-content>
<!-- Enterprise Body -->
<app-enterprise-content *ngIf="org === 'enterprise'"></app-enterprise-content>
<!-- Families Body -->
<app-families-content *ngIf="org === 'families'"></app-families-content>
</div>
</div>
<div class="tw-w-1/2">
<div class="tw-pt-56">
<div class="tw-rounded tw-border tw-border-solid tw-border-secondary-300 tw-bg-background">
<div class="tw-flex tw-h-12 tw-w-full tw-items-center tw-rounded-t tw-bg-secondary-100">
<h2 class="tw-mb-0 tw-pl-4 tw-text-base tw-font-bold tw-uppercase">
Start your 7-Day free trial of Bitwarden for {{ org }}
</h2>
</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)"
></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
bitButton
buttonType="primary"
[disabled]="orgInfoFormGroup.get('name').hasError('required')"
cdkStepperNext
>
Next
</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 bitButton buttonType="primary" (click)="navigateToOrgVault()">
Get Started
</button>
<button
bitButton
buttonType="secondary"
(click)="navigateToOrgInvite()"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
Invite Users
</button>
</div>
</app-vertical-step>
</app-vertical-stepper>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,284 @@
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 { Substitute } from "@fluffy-spoon/substitute";
import { BehaviorSubject } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/pipes/i18n.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
import { StateService as BaseStateService } from "@bitwarden/common/abstractions/state.service";
import { PlanType } from "@bitwarden/common/enums/planType";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/models/domain/masterPasswordPolicyOptions";
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: any;
let apiServiceMock: any;
let policyServiceMock: any;
beforeEach(() => {
// only mock functions we use in this component
stateServiceMock = {
getOrganizationInvitation: jest.fn(),
};
apiServiceMock = {
getPoliciesByToken: jest.fn(),
};
policyServiceMock = {
getMasterPasswordPolicyOptions: jest.fn(),
};
TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: "trial", component: TrialInitiationComponent },
{
path: `organizations/${testOrgId}/vault`,
component: BlankComponent,
},
{
path: `organizations/${testOrgId}/manage/people`,
component: BlankComponent,
},
]),
],
declarations: [TrialInitiationComponent, I18nPipe],
providers: [
UntypedFormBuilder,
{
provide: ActivatedRoute,
useValue: {
queryParams: mockQueryParams.asObservable(),
},
},
{ provide: BaseStateService, useValue: stateServiceMock },
{ provide: PolicyService, useValue: policyServiceMock },
{ provide: ApiService, useValue: apiServiceMock },
{ provide: LogService, useClass: Substitute.for<LogService>() },
{ provide: I18nService, useClass: Substitute.for<I18nService>() },
{ provide: TitleCasePipe, useClass: Substitute.for<TitleCasePipe>() },
{
provide: VerticalStepperComponent,
useClass: VerticalStepperStubComponent,
},
],
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({
organizationId: testOrgId,
token: "token",
email: "testEmail",
organizationUserId: "123",
});
apiServiceMock.getPoliciesByToken.mockReturnValueOnce({
data: [
{
id: "345",
organizationId: testOrgId,
type: 1,
data: [
{
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
},
],
enabled: true,
},
],
});
policyServiceMock.getMasterPasswordPolicyOptions.mockReturnValueOnce({
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.detectChanges();
component.ngOnInit();
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 set the org to be families and plan to FamiliesAnnually if org param is invalid ", fakeAsync(async () => {
mockQueryParams.next({ org: "hahahaha" });
tick(); // wait for resolution
fixture.detectChanges();
component.ngOnInit();
expect(component.org).toBe("families");
expect(component.plan).toBe(PlanType.FamiliesAnnually);
}));
});
// 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", "people"]);
}));
});
});
});
export class VerticalStepperStubComponent extends VerticalStepperComponent {}
export class BlankComponent {} // For router tests

View File

@@ -0,0 +1,151 @@
import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common";
import { Component, OnInit, ViewChild } from "@angular/core";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { first } from "rxjs";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/abstractions/policy/policy.service.abstraction";
import { StateService } from "@bitwarden/common/abstractions/state.service";
import { PlanType } from "@bitwarden/common/enums/planType";
import { ProductType } from "@bitwarden/common/enums/productType";
import { PolicyData } from "@bitwarden/common/models/data/policyData";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/models/domain/masterPasswordPolicyOptions";
import { Policy } from "@bitwarden/common/models/domain/policy";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
@Component({
selector: "app-trial",
templateUrl: "trial-initiation.component.html",
})
export class TrialInitiationComponent implements OnInit {
email = "";
org = "";
orgInfoSubLabel = "";
orgId = "";
orgLabel = "";
billingSubLabel = "";
plan: PlanType;
product: ProductType;
accountCreateOnly = true;
policies: Policy[];
enforcedPolicyOptions: MasterPasswordPolicyOptions;
validOrgs: string[] = ["teams", "enterprise", "families"];
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
orgInfoFormGroup = this.formBuilder.group({
name: ["", [Validators.required]],
email: [""],
});
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
) {}
async ngOnInit(): Promise<void> {
this.route.queryParams.pipe(first()).subscribe((qParams) => {
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
this.email = qParams.email;
}
if (!qParams.org) {
return;
}
if (this.validOrgs.includes(qParams.org)) {
this.org = qParams.org;
} else {
this.org = "families";
}
this.orgLabel = this.titleCasePipe.transform(this.org);
this.accountCreateOnly = false;
if (this.org === "families") {
this.plan = PlanType.FamiliesAnnually;
this.product = ProductType.Families;
} else if (this.org === "teams") {
this.plan = PlanType.TeamsAnnually;
this.product = ProductType.Teams;
} else if (this.org === "enterprise") {
this.plan = PlanType.EnterpriseAnnually;
this.product = ProductType.Enterprise;
}
});
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.enforcedPolicyOptions = await this.policyService.getMasterPasswordPolicyOptions(
this.policies
);
}
}
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", "people"]);
}
previousStep() {
this.verticalStepper.previous();
}
}

View File

@@ -0,0 +1,40 @@
import { CdkStepperModule } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common";
import { NgModule } from "@angular/core";
import { FormFieldModule } from "@bitwarden/components";
import { OrganizationCreateModule } from "../../organizations/create/organization-create.module";
import { LooseComponentsModule, SharedModule } from "../../shared";
import { RegisterFormModule } from "../register-form/register-form.module";
import { BillingComponent } from "./billing.component";
import { ConfirmationDetailsComponent } from "./confirmation-details.component";
import { EnterpriseContentComponent } from "./enterprise-content.component";
import { FamiliesContentComponent } from "./families-content.component";
import { TeamsContentComponent } from "./teams-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,
],
declarations: [
TrialInitiationComponent,
EnterpriseContentComponent,
FamiliesContentComponent,
TeamsContentComponent,
ConfirmationDetailsComponent,
BillingComponent,
],
exports: [TrialInitiationComponent],
providers: [TitleCasePipe],
})
export class TrialInitiationModule {}

View File

@@ -0,0 +1,45 @@
<div class="tw-m-2.5 tw-h-16 tw-text-center">
<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,29 @@
import { CdkStepper } from "@angular/cdk/stepper";
import { Component, Input } from "@angular/core";
@Component({
selector: "app-vertical-stepper",
templateUrl: "vertical-stepper.component.html",
providers: [{ provide: CdkStepper, useExisting: VerticalStepperComponent }],
})
export class VerticalStepperComponent extends CdkStepper {
@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 {}