[PM-1655] Trial Registration Layout (#9091)

* add messaging for finish sign up component

* Add product enum for finish sign up components

* Allow confirmation details component to display secret manager confirmation

* add FinishSignUp component

- Started as exact copy of trial initiation component
- Consolidated with secrets manager trial components

* Integration finish sign up component into routing

- Use anon layout component
- Add resolver to pass the accurate title to the layout

* migrate to product tier type

* use existing ProductType enum

* migrate to accept org service

* fix query param parsing for free trial text

* migrate finish sign up to complete trial naming

* migrate fully to productTier

* fix import of free trial resolver

* increase max width of anon layout

* add auth-input component

* refactor component makeup

* export the users password if needed to auto login the user

* handle login situations where a stepper isn't used

* fix type check

* allow max width of anon layout to be configurable

* remove account created toast

* update productTier query param in text resolver

* set maxWidth for secrets manager trial route

* parse product query param as an int

* properly show registration error

* update routes to be from the root rather than relative

* install updated prettier and apply fixes

* fix missing password in test

---------

Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com>
This commit is contained in:
Nick Krantz 2024-07-30 07:11:40 -05:00 committed by GitHub
parent 339768947b
commit 8f437dc773
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 602 additions and 2 deletions

View File

@ -148,6 +148,7 @@ describe("DefaultRegistrationFinishService", () => {
localMasterKeyHash: "localMasterKeyHash", localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG, kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint", hint: "hint",
password: "password",
}; };
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;

View File

@ -0,0 +1,80 @@
<div *ngIf="!useTrialStepper">
<auth-input-password
[email]="email"
[masterPasswordPolicyOptions]="enforcedPolicyOptions"
(onPasswordFormSubmit)="handlePasswordSubmit($event)"
[buttonText]="'createAccount' | i18n"
></auth-input-password>
</div>
<div *ngIf="useTrialStepper">
<app-vertical-stepper #stepper linear (selectionChange)="verticalStepChange($event)">
<app-vertical-step label="Create Account" [editable]="false" [subLabel]="email">
<auth-input-password
[email]="email"
[masterPasswordPolicyOptions]="enforcedPolicyOptions"
(onPasswordFormSubmit)="handlePasswordSubmit($event)"
[buttonText]="'createAccount' | i18n"
></auth-input-password>
</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.controls.name.invalid"
(click)="conditionallyCreateOrganization()"
>
{{ "next" | i18n }}
</button>
</app-vertical-step>
<app-vertical-step label="Billing" [subLabel]="billingSubLabel" *ngIf="!isSecretsManagerFree">
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: orgInfoFormGroup.value.name,
email: orgInfoFormGroup.value.billingEmail,
type: trialOrganizationType,
}"
[subscriptionProduct]="
product === ProductType.SecretsManager
? SubscriptionProduct.SecretsManager
: SubscriptionProduct.PasswordManager
"
(steppedBack)="previousStep()"
(organizationCreated)="createdOrganization($event)"
>
</app-trial-billing-step>
</app-vertical-step>
<app-vertical-step label="Confirmation Details" [applyBorder]="false">
<app-trial-confirmation-details
[email]="email"
[orgLabel]="orgLabel"
[product]="this.product"
></app-trial-confirmation-details>
<div class="tw-mb-3 tw-flex">
<a
type="button"
bitButton
buttonType="primary"
[routerLink]="
product === ProductType.SecretsManager
? ['/sm', orgId]
: ['/organizations', orgId, 'vault']
"
>
{{ "getStarted" | i18n | titlecase }}
</a>
<a
type="button"
bitButton
buttonType="secondary"
[routerLink]="['/organizations', orgId, 'members']"
class="tw-ml-3 tw-inline-flex tw-items-center tw-px-3"
>
{{ "inviteUsers" | i18n }}
</a>
</div>
</app-vertical-step>
</app-vertical-stepper>
</div>

View File

@ -0,0 +1,318 @@
import { StepperSelectionEvent } from "@angular/cdk/stepper";
import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { PasswordInputResult, RegistrationFinishService } from "@bitwarden/auth/angular";
import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { ProductTierType, ProductType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
import {
OrganizationCreatedEvent,
SubscriptionProduct,
TrialOrganizationType,
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
import { RouterService } from "../../../core/router.service";
import { AcceptOrganizationInviteService } from "../../organization-invite/accept-organization.service";
import { VerticalStepperComponent } from "../vertical-stepper/vertical-stepper.component";
@Component({
selector: "app-complete-trial-initiation",
templateUrl: "complete-trial-initiation.component.html",
})
export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
/** Password Manager or Secrets Manager */
product: ProductType;
/** The tier of product being subscribed to */
productTier: ProductTierType;
/** Product types that display steppers for Password Manager */
stepperProductTypes: ProductTierType[] = [
ProductTierType.Teams,
ProductTierType.Enterprise,
ProductTierType.Families,
];
/** Display multi-step trial flow when true */
useTrialStepper = false;
/** True, registering a password is in progress */
submitting = false;
/** Valid product types, used to filter out invalid query parameters */
validProducts = [ProductType.PasswordManager, ProductType.SecretsManager];
orgInfoSubLabel = "";
orgId = "";
orgLabel = "";
billingSubLabel = "";
enforcedPolicyOptions: MasterPasswordPolicyOptions;
/** User's email address associated with the trial */
email = "";
/** Token from the backend associated with the email verification */
emailVerificationToken: string;
orgInfoFormGroup = this.formBuilder.group({
name: ["", { validators: [Validators.required, Validators.maxLength(50)], updateOn: "change" }],
billingEmail: [""],
});
private destroy$ = new Subject<void>();
protected readonly SubscriptionProduct = SubscriptionProduct;
protected readonly ProductType = ProductType;
constructor(
protected router: Router,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private logService: LogService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private i18nService: I18nService,
private routerService: RouterService,
private organizationBillingService: OrganizationBillingService,
private acceptOrganizationInviteService: AcceptOrganizationInviteService,
private toastService: ToastService,
private registrationFinishService: RegistrationFinishService,
private validationService: ValidationService,
private loginStrategyService: LoginStrategyServiceAbstraction,
) {}
async ngOnInit(): Promise<void> {
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => {
// Retrieve email from query params
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
this.email = qParams.email;
this.orgInfoFormGroup.controls.billingEmail.setValue(qParams.email);
}
// Show email validation toast when coming from email
if (qParams.fromEmail && qParams.fromEmail === "true") {
this.toastService.showToast({
title: null,
message: this.i18nService.t("emailVerifiedV2"),
variant: "success",
});
}
if (qParams.token != null) {
this.emailVerificationToken = qParams.token;
}
const product = parseInt(qParams.product);
// Get product from query params, default to password manager
this.product = this.validProducts.includes(product) ? product : ProductType.PasswordManager;
const productTierParam = parseInt(qParams.productTier) as ProductTierType;
/** Only show the trial stepper for a subset of types */
const showPasswordManagerStepper = this.stepperProductTypes.includes(productTierParam);
/** All types of secret manager should see the trial stepper */
const showSecretsManagerStepper = this.product === ProductType.SecretsManager;
if ((showPasswordManagerStepper || showSecretsManagerStepper) && !isNaN(productTierParam)) {
this.productTier = productTierParam;
this.orgLabel = this.planTypeDisplay;
this.useTrialStepper = true;
}
// 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.acceptOrganizationInviteService.getOrganizationInvite();
let policies: Policy[] | null = null;
if (invite != null) {
try {
policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
}
if (policies !== null) {
this.policyService
.masterPasswordPolicyOptions$(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();
}
/** Handle manual stepper change */
verticalStepChange(event: StepperSelectionEvent) {
if (event.selectedIndex === 1 && this.orgInfoFormGroup.controls.name.value === "") {
this.orgInfoSubLabel = this.planInfoLabel;
} else if (event.previouslySelectedIndex === 1) {
this.orgInfoSubLabel = this.orgInfoFormGroup.controls.name.value;
}
}
/** Update local details from organization created event */
createdOrganization(event: OrganizationCreatedEvent) {
this.orgId = event.organizationId;
this.billingSubLabel = event.planDescription;
this.verticalStepper.next();
}
/** Move the user to the previous step */
previousStep() {
this.verticalStepper.previous();
}
get isSecretsManagerFree() {
return this.product === ProductType.SecretsManager && this.productTier === ProductTierType.Free;
}
get planTypeDisplay() {
switch (this.productTier) {
case ProductTierType.Teams:
return "Teams";
case ProductTierType.Enterprise:
return "Enterprise";
case ProductTierType.Families:
return "Families";
default:
return "";
}
}
get planInfoLabel() {
switch (this.productTier) {
case ProductTierType.Teams:
return this.i18nService.t("enterTeamsOrgInfo");
case ProductTierType.Enterprise:
return this.i18nService.t("enterEnterpriseOrgInfo");
case ProductTierType.Families:
return this.i18nService.t("enterFamiliesOrgInfo");
default:
return "";
}
}
get trialOrganizationType(): TrialOrganizationType {
if (this.productTier === ProductTierType.Free) {
return null;
}
return this.productTier;
}
/** Create an organization unless the trial is for secrets manager */
async conditionallyCreateOrganization(): Promise<void> {
if (!this.isSecretsManagerFree) {
this.verticalStepper.next();
return;
}
const response = await this.organizationBillingService.startFree({
organization: {
name: this.orgInfoFormGroup.value.name,
billingEmail: this.orgInfoFormGroup.value.billingEmail,
},
plan: {
type: 0,
subscribeToSecretsManager: true,
isFromSecretsManagerTrial: true,
},
});
this.orgId = response.id;
this.verticalStepper.next();
}
/**
* Complete the users registration with their password.
*
* When a the trial stepper isn't used, redirect the user to the login page.
*/
async handlePasswordSubmit(passwordInputResult: PasswordInputResult) {
if (!this.useTrialStepper) {
await this.finishRegistration(passwordInputResult);
this.submitting = false;
await this.router.navigate(["/login"], { queryParams: { email: this.email } });
return;
}
const captchaToken = await this.finishRegistration(passwordInputResult);
if (captchaToken == null) {
this.submitting = false;
return;
}
await this.logIn(passwordInputResult.password, captchaToken);
this.submitting = false;
this.verticalStepper.next();
}
private setupFamilySponsorship(sponsorshipToken: string) {
if (sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
queryParams: { plan: sponsorshipToken },
});
this.routerService.setPreviousUrl(route.toString());
}
}
/** Logs the user in based using the token received by the `finishRegistration` method */
private async logIn(masterPassword: string, captchaBypassToken: string): Promise<void> {
const credentials = new PasswordLoginCredentials(
this.email,
masterPassword,
captchaBypassToken,
null,
);
await this.loginStrategyService.logIn(credentials);
}
finishRegistration(passwordInputResult: PasswordInputResult) {
this.submitting = true;
return this.registrationFinishService
.finishRegistration(this.email, passwordInputResult, this.emailVerificationToken)
.catch((e) => {
this.validationService.showError(e);
this.submitting = false;
return null;
});
}
}

View File

@ -0,0 +1,58 @@
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { ProductTierType, ProductType } from "@bitwarden/common/billing/enums";
import { freeTrialTextResolver } from "./free-trial-text.resolver";
const route = {
queryParams: {},
} as ActivatedRouteSnapshot;
const routerStateSnapshot = {} as RouterStateSnapshot;
describe("freeTrialTextResolver", () => {
[
{
param: ProductType.PasswordManager,
keyBase: "startYour7DayFreeTrialOfBitwardenPasswordManager",
},
{
param: ProductType.SecretsManager,
keyBase: "startYour7DayFreeTrialOfBitwardenSecretsManager",
},
{
param: `${ProductType.PasswordManager},${ProductType.SecretsManager}`,
keyBase: "startYour7DayFreeTrialOfBitwarden",
},
].forEach(({ param, keyBase }) => {
describe(`when product is ${param}`, () => {
beforeEach(() => {
route.queryParams.product = `${param}`;
});
it("returns teams trial text", () => {
route.queryParams.productTier = ProductTierType.Teams;
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(`${keyBase}ForTeams`);
});
it("returns enterprise trial text", () => {
route.queryParams.productTier = ProductTierType.Enterprise;
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(`${keyBase}ForEnterprise`);
});
it("returns families trial text", () => {
route.queryParams.productTier = ProductTierType.Families;
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(`${keyBase}ForFamilies`);
});
it("returns default trial text", () => {
route.queryParams.productTier = "";
expect(freeTrialTextResolver(route, routerStateSnapshot)).toBe(keyBase);
});
});
});
});

View File

@ -0,0 +1,43 @@
import { ActivatedRouteSnapshot, ResolveFn } from "@angular/router";
import { ProductType, ProductTierType } from "@bitwarden/common/billing/enums";
export const freeTrialTextResolver: ResolveFn<string | null> = (
route: ActivatedRouteSnapshot,
): string | null => {
const { product, productTier } = route.queryParams;
const products: ProductType[] = (product ?? "").split(",").map((p: string) => parseInt(p));
const onlyPasswordManager = products.length === 1 && products[0] === ProductType.PasswordManager;
const onlySecretsManager = products.length === 1 && products[0] === ProductType.SecretsManager;
const forTeams = parseInt(productTier) === ProductTierType.Teams;
const forEnterprise = parseInt(productTier) === ProductTierType.Enterprise;
const forFamilies = parseInt(productTier) === ProductTierType.Families;
switch (true) {
case onlyPasswordManager && forTeams:
return "startYour7DayFreeTrialOfBitwardenPasswordManagerForTeams";
case onlyPasswordManager && forEnterprise:
return "startYour7DayFreeTrialOfBitwardenPasswordManagerForEnterprise";
case onlyPasswordManager && forFamilies:
return "startYour7DayFreeTrialOfBitwardenPasswordManagerForFamilies";
case onlyPasswordManager:
return "startYour7DayFreeTrialOfBitwardenPasswordManager";
case onlySecretsManager && forTeams:
return "startYour7DayFreeTrialOfBitwardenSecretsManagerForTeams";
case onlySecretsManager && forEnterprise:
return "startYour7DayFreeTrialOfBitwardenSecretsManagerForEnterprise";
case onlySecretsManager && forFamilies:
return "startYour7DayFreeTrialOfBitwardenSecretsManagerForFamilies";
case onlySecretsManager:
return "startYour7DayFreeTrialOfBitwardenSecretsManager";
case forTeams:
return "startYour7DayFreeTrialOfBitwardenForTeams";
case forEnterprise:
return "startYour7DayFreeTrialOfBitwardenForEnterprise";
case forFamilies:
return "startYour7DayFreeTrialOfBitwardenForFamilies";
default:
return "startYour7DayFreeTrialOfBitwarden";
}
};

View File

@ -1,5 +1,10 @@
<div class="tw-pb-6 tw-pl-6"> <div class="tw-pb-6 tw-pl-6">
<p class="tw-text-xl">{{ "trialThankYou" | i18n: orgLabel }}</p> <p class="tw-text-xl" *ngIf="product === Product.PasswordManager">
{{ "trialThankYou" | i18n: orgLabel }}
</p>
<p class="tw-text-xl" *ngIf="product === Product.SecretsManager">
{{ "smFreeTrialThankYou" | i18n }}
</p>
<ul class="tw-list-disc"> <ul class="tw-list-disc">
<li> <li>
<p> <p>

View File

@ -1,5 +1,7 @@
import { Component, Input } from "@angular/core"; import { Component, Input } from "@angular/core";
import { ProductType } from "@bitwarden/common/billing/enums";
@Component({ @Component({
selector: "app-trial-confirmation-details", selector: "app-trial-confirmation-details",
templateUrl: "confirmation-details.component.html", templateUrl: "confirmation-details.component.html",
@ -7,4 +9,7 @@ import { Component, Input } from "@angular/core";
export class ConfirmationDetailsComponent { export class ConfirmationDetailsComponent {
@Input() email: string; @Input() email: string;
@Input() orgLabel: string; @Input() orgLabel: string;
@Input() product?: ProductType = ProductType.PasswordManager;
protected readonly Product = ProductType;
} }

View File

@ -2,6 +2,7 @@ import { CdkStepperModule } from "@angular/cdk/stepper";
import { TitleCasePipe } from "@angular/common"; import { TitleCasePipe } from "@angular/common";
import { NgModule } from "@angular/core"; import { NgModule } from "@angular/core";
import { InputPasswordComponent } from "@bitwarden/auth/angular";
import { FormFieldModule } from "@bitwarden/components"; 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";
@ -14,6 +15,7 @@ import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiati
import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module"; import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module";
import { SharedModule } from "../../shared"; import { SharedModule } from "../../shared";
import { CompleteTrialInitiationComponent } from "./complete-trial-initiation/complete-trial-initiation.component";
import { ConfirmationDetailsComponent } from "./confirmation-details.component"; import { ConfirmationDetailsComponent } from "./confirmation-details.component";
import { AbmEnterpriseContentComponent } from "./content/abm-enterprise-content.component"; import { AbmEnterpriseContentComponent } from "./content/abm-enterprise-content.component";
import { AbmTeamsContentComponent } from "./content/abm-teams-content.component"; import { AbmTeamsContentComponent } from "./content/abm-teams-content.component";
@ -51,9 +53,11 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
PaymentComponent, PaymentComponent,
TaxInfoComponent, TaxInfoComponent,
TrialBillingStepComponent, TrialBillingStepComponent,
InputPasswordComponent,
], ],
declarations: [ declarations: [
TrialInitiationComponent, TrialInitiationComponent,
CompleteTrialInitiationComponent,
EnterpriseContentComponent, EnterpriseContentComponent,
TeamsContentComponent, TeamsContentComponent,
ConfirmationDetailsComponent, ConfirmationDetailsComponent,
@ -82,7 +86,7 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
SecretsManagerTrialFreeStepperComponent, SecretsManagerTrialFreeStepperComponent,
SecretsManagerTrialPaidStepperComponent, SecretsManagerTrialPaidStepperComponent,
], ],
exports: [TrialInitiationComponent], exports: [TrialInitiationComponent, CompleteTrialInitiationComponent],
providers: [TitleCasePipe], providers: [TitleCasePipe],
}) })
export class TrialInitiationModule {} export class TrialInitiationModule {}

View File

@ -47,6 +47,8 @@ import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emerg
import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component"; import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component";
import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module"; import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module";
import { SsoComponent } from "./auth/sso.component"; import { SsoComponent } from "./auth/sso.component";
import { CompleteTrialInitiationComponent } from "./auth/trial-initiation/complete-trial-initiation/complete-trial-initiation.component";
import { freeTrialTextResolver } from "./auth/trial-initiation/complete-trial-initiation/resolver/free-trial-text.resolver";
import { TrialInitiationComponent } from "./auth/trial-initiation/trial-initiation.component"; import { TrialInitiationComponent } from "./auth/trial-initiation/trial-initiation.component";
import { TwoFactorAuthComponent } from "./auth/two-factor-auth.component"; import { TwoFactorAuthComponent } from "./auth/two-factor-auth.component";
import { TwoFactorComponent } from "./auth/two-factor.component"; import { TwoFactorComponent } from "./auth/two-factor.component";
@ -400,6 +402,28 @@ const routes: Routes = [
titleId: "removeMasterPassword", titleId: "removeMasterPassword",
} satisfies DataProperties & AnonLayoutWrapperData, } satisfies DataProperties & AnonLayoutWrapperData,
}, },
{
path: "trial-initiation",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
component: CompleteTrialInitiationComponent,
resolve: {
pageTitle: freeTrialTextResolver,
},
data: {
maxWidth: "3xl",
} satisfies AnonLayoutWrapperData,
},
{
path: "secrets-manager-trial-initiation",
canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()],
component: CompleteTrialInitiationComponent,
resolve: {
pageTitle: freeTrialTextResolver,
},
data: {
maxWidth: "3xl",
} satisfies AnonLayoutWrapperData,
},
], ],
}, },
{ {

View File

@ -8414,6 +8414,51 @@
"manageBillingFromProviderPortalMessage": { "manageBillingFromProviderPortalMessage": {
"message": "Manage billing from the Provider Portal" "message": "Manage billing from the Provider Portal"
}, },
"startYour7DayFreeTrialOfBitwarden": {
"message": "Start your 7-Day free trial of Bitwarden"
},
"startYour7DayFreeTrialOfBitwardenForTeams": {
"message": "Start your 7-Day free trial of Bitwarden for Teams"
},
"startYour7DayFreeTrialOfBitwardenForFamilies": {
"message": "Start your 7-Day free trial of Bitwarden for Families"
},
"startYour7DayFreeTrialOfBitwardenForEnterprise": {
"message": "Start your 7-Day free trial of Bitwarden for Enterprise"
},
"startYour7DayFreeTrialOfBitwardenSecretsManager": {
"message": "Start your 7-Day free trial of Bitwarden Secrets Manager"
},
"startYour7DayFreeTrialOfBitwardenSecretsManagerForTeams": {
"message": "Start your 7-Day free trial of Bitwarden Secrets Manager for Teams"
},
"startYour7DayFreeTrialOfBitwardenSecretsManagerForFamilies": {
"message": "Start your 7-Day free trial of Bitwarden Secrets Manager for Families"
},
"startYour7DayFreeTrialOfBitwardenSecretsManagerForEnterprise": {
"message": "Start your 7-Day free trial of Bitwarden Secrets Manager for Enterprise"
},
"startYour7DayFreeTrialOfBitwardenPasswordManager": {
"message": "Start your 7-Day free trial of Bitwarden Password Manager"
},
"startYour7DayFreeTrialOfBitwardenPasswordManagerForTeams": {
"message": "Start your 7-Day free trial of Bitwarden Password Manager for Teams"
},
"startYour7DayFreeTrialOfBitwardenPasswordManagerForFamilies": {
"message": "Start your 7-Day free trial of Bitwarden Password Manager for Families"
},
"startYour7DayFreeTrialOfBitwardenPasswordManagerForEnterprise": {
"message": "Start your 7-Day free trial of Bitwarden Password Manager for Enterprise"
},
"enterTeamsOrgInfo": {
"message": "Enter your Teams organization information"
},
"enterFamiliesOrgInfo": {
"message": "Enter your Families organization information"
},
"enterEnterpriseOrgInfo": {
"message": "Enter your Enterprise organization information"
},
"viewItemsIn": { "viewItemsIn": {
"message": "View items in $NAME$", "message": "View items in $NAME$",
"description": "Button to view the contents of a folder or collection", "description": "Button to view the contents of a folder or collection",

View File

@ -3,6 +3,7 @@
[subtitle]="pageSubtitle" [subtitle]="pageSubtitle"
[icon]="pageIcon" [icon]="pageIcon"
[showReadonlyHostname]="showReadonlyHostname" [showReadonlyHostname]="showReadonlyHostname"
[maxWidth]="maxWidth"
> >
<router-outlet></router-outlet> <router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet> <router-outlet slot="secondary" name="secondary"></router-outlet>

View File

@ -13,6 +13,7 @@ export interface AnonLayoutWrapperData {
pageSubtitle?: string; pageSubtitle?: string;
pageIcon?: Icon; pageIcon?: Icon;
showReadonlyHostname?: boolean; showReadonlyHostname?: boolean;
maxWidth?: "md" | "3xl";
} }
@Component({ @Component({
@ -27,6 +28,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
protected pageSubtitle: string; protected pageSubtitle: string;
protected pageIcon: Icon; protected pageIcon: Icon;
protected showReadonlyHostname: boolean; protected showReadonlyHostname: boolean;
protected maxWidth: "md" | "3xl";
constructor( constructor(
private router: Router, private router: Router,
@ -75,6 +77,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
} }
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
this.maxWidth = firstChildRouteData["maxWidth"];
} }
private listenForServiceDataChanges() { private listenForServiceDataChanges() {

View File

@ -15,6 +15,7 @@
</div> </div>
<div <div
class="tw-mb-auto tw-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]" class="tw-mb-auto tw-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
[ngClass]="{ 'tw-max-w-md': maxWidth === 'md', 'tw-max-w-3xl': maxWidth === '3xl' }"
> >
<div <div
class="tw-rounded-xl tw-mb-9 tw-mx-auto tw-w-full sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8" class="tw-rounded-xl tw-mb-9 tw-mx-auto tw-w-full sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-300 sm:tw-p-8"

View File

@ -23,6 +23,12 @@ export class AnonLayoutComponent {
@Input() subtitle: string; @Input() subtitle: string;
@Input() icon: Icon; @Input() icon: Icon;
@Input() showReadonlyHostname: boolean; @Input() showReadonlyHostname: boolean;
/**
* Max width of the layout content
*
* @default 'md'
*/
@Input() maxWidth: "md" | "3xl" = "md";
protected logo: Icon; protected logo: Icon;
@ -45,6 +51,7 @@ export class AnonLayoutComponent {
} }
async ngOnInit() { async ngOnInit() {
this.maxWidth = this.maxWidth ?? "md";
this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname(); this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname();
this.version = await this.platformUtilsService.getApplicationVersion(); this.version = await this.platformUtilsService.getApplicationVersion();
this.theme = await firstValueFrom(this.themeStateService.selectedTheme$); this.theme = await firstValueFrom(this.themeStateService.selectedTheme$);

View File

@ -190,6 +190,7 @@ export class InputPasswordComponent {
localMasterKeyHash, localMasterKeyHash,
kdfConfig, kdfConfig,
hint: this.formGroup.controls.hint.value, hint: this.formGroup.controls.hint.value,
password,
}); });
}; };
} }

View File

@ -7,4 +7,5 @@ export interface PasswordInputResult {
localMasterKeyHash: string; localMasterKeyHash: string;
kdfConfig: PBKDF2KdfConfig; kdfConfig: PBKDF2KdfConfig;
hint: string; hint: string;
password: string;
} }

View File

@ -57,6 +57,7 @@ describe("DefaultRegistrationFinishService", () => {
localMasterKeyHash: "localMasterKeyHash", localMasterKeyHash: "localMasterKeyHash",
kdfConfig: DEFAULT_KDF_CONFIG, kdfConfig: DEFAULT_KDF_CONFIG,
hint: "hint", hint: "hint",
password: "password",
}; };
userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;

View File

@ -110,6 +110,7 @@ describe("DefaultSetPasswordJitService", () => {
localMasterKeyHash: "localMasterKeyHash", localMasterKeyHash: "localMasterKeyHash",
hint: "hint", hint: "hint",
kdfConfig: DEFAULT_KDF_CONFIG, kdfConfig: DEFAULT_KDF_CONFIG,
password: "password",
}; };
credentials = { credentials = {

View File

@ -4,3 +4,4 @@ export * from "./plan-type.enum";
export * from "./transaction-type.enum"; export * from "./transaction-type.enum";
export * from "./bitwarden-product-type.enum"; export * from "./bitwarden-product-type.enum";
export * from "./product-tier-type.enum"; export * from "./product-tier-type.enum";
export * from "./product-type.enum";