1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-11 05:53:42 +00:00

Merge branch 'main' into km/userkey-rotation-v2

This commit is contained in:
Bernd Schoolmann
2025-02-05 10:22:02 +01:00
672 changed files with 10740 additions and 5219 deletions

View File

@@ -1,22 +0,0 @@
{
"env": {
"browser": true
},
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
"**/app/core/*",
"**/reports/*",
"**/app/shared/*",
"**/organizations/settings/*",
"**/organizations/policies/*",
"@bitwarden/web-vault/*",
"src/**/*",
"bitwarden_license"
]
}
]
}
}

View File

@@ -1,3 +0,0 @@
{
"extends": "../../../../../libs/admin-console/.eslintrc.json"
}

View File

@@ -23,17 +23,27 @@ import { ToastService } from "@bitwarden/components";
import { organizationPermissionsGuard } from "./org-permissions.guard";
// Returns a test organization with the specified props.
const orgFactory = (props: Partial<Organization> = {}) =>
Object.assign(
new Organization(),
{
id: "myOrgId",
enabled: true,
type: OrganizationUserType.Admin,
},
props,
);
const targetOrgId = "myOrgId";
// Returns an array of test organizations with the target organization in the middle.
// This more accurately tests the return value of OrganizationService.
const orgStateFactory = (targetOrgProps: Partial<Organization> = {}) => [
orgFactory({ id: "anotherOrg" }),
orgFactory({ id: targetOrgId, ...targetOrgProps }), // target org intentionally nestled in the middle
orgFactory({ id: "andAnotherOrg" }),
];
describe("Organization Permissions Guard", () => {
let router: MockProxy<Router>;
let organizationService: MockProxy<OrganizationService>;
@@ -49,7 +59,7 @@ describe("Organization Permissions Guard", () => {
state = mock<RouterStateSnapshot>();
route = mock<ActivatedRouteSnapshot>({
params: {
organizationId: orgFactory().id,
organizationId: targetOrgId,
},
});
@@ -75,82 +85,79 @@ describe("Organization Permissions Guard", () => {
expect(actual).not.toBe(true);
});
it("permits navigation if no permissions are specified", async () => {
const org = orgFactory();
organizationService.organizations$.calledWith(userId).mockReturnValue(of([org]));
describe("given an enabled organization", () => {
beforeEach(() => {
organizationService.organizations$.calledWith(userId).mockReturnValue(of(orgStateFactory()));
});
const actual = await TestBed.runInInjectionContext(async () =>
organizationPermissionsGuard()(route, state),
);
it("permits navigation if no permissions are specified", async () => {
const actual = await TestBed.runInInjectionContext(async () =>
organizationPermissionsGuard()(route, state),
);
expect(actual).toBe(true);
});
expect(actual).toBe(true);
});
it("permits navigation if the user has permissions", async () => {
const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((_org) => true);
const org = orgFactory();
organizationService.organizations$.calledWith(userId).mockReturnValue(of([org]));
const actual = await TestBed.runInInjectionContext(
async () => await organizationPermissionsGuard(permissionsCallback)(route, state),
);
expect(permissionsCallback).toHaveBeenCalled();
expect(actual).toBe(true);
});
describe("if the user does not have permissions", () => {
it("and there is no Item ID, block navigation", async () => {
it("permits navigation if the user has permissions", async () => {
const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((_org) => false);
state = mock<RouterStateSnapshot>({
root: mock<ActivatedRouteSnapshot>({
queryParamMap: convertToParamMap({}),
}),
});
const org = orgFactory();
organizationService.organizations$.calledWith(userId).mockReturnValue(of([org]));
permissionsCallback.mockImplementation((_org) => true);
const actual = await TestBed.runInInjectionContext(
async () => await organizationPermissionsGuard(permissionsCallback)(route, state),
);
expect(permissionsCallback).toHaveBeenCalled();
expect(actual).not.toBe(true);
expect(permissionsCallback).toHaveBeenCalledWith(orgFactory({ id: targetOrgId }));
expect(actual).toBe(true);
});
it("and there is an Item ID, redirect to the item in the individual vault", async () => {
state = mock<RouterStateSnapshot>({
root: mock<ActivatedRouteSnapshot>({
queryParamMap: convertToParamMap({
itemId: "myItemId",
describe("if the user does not have permissions", () => {
it("and there is no Item ID, block navigation", async () => {
const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((_org) => false);
state = mock<RouterStateSnapshot>({
root: mock<ActivatedRouteSnapshot>({
queryParamMap: convertToParamMap({}),
}),
}),
});
const org = orgFactory();
organizationService.organizations$.calledWith(userId).mockReturnValue(of([org]));
});
const actual = await TestBed.runInInjectionContext(
async () => await organizationPermissionsGuard((_org: Organization) => false)(route, state),
);
const actual = await TestBed.runInInjectionContext(
async () => await organizationPermissionsGuard(permissionsCallback)(route, state),
);
expect(router.createUrlTree).toHaveBeenCalledWith(["/vault"], {
queryParams: { itemId: "myItemId" },
expect(permissionsCallback).toHaveBeenCalledWith(orgFactory({ id: targetOrgId }));
expect(actual).not.toBe(true);
});
it("and there is an Item ID, redirect to the item in the individual vault", async () => {
state = mock<RouterStateSnapshot>({
root: mock<ActivatedRouteSnapshot>({
queryParamMap: convertToParamMap({
itemId: "myItemId",
}),
}),
});
const actual = await TestBed.runInInjectionContext(
async () =>
await organizationPermissionsGuard((_org: Organization) => false)(route, state),
);
expect(router.createUrlTree).toHaveBeenCalledWith(["/vault"], {
queryParams: { itemId: "myItemId" },
});
expect(actual).not.toBe(true);
});
expect(actual).not.toBe(true);
});
});
describe("given a disabled organization", () => {
it("blocks navigation if user is not an owner", async () => {
const org = orgFactory({
const orgs = orgStateFactory({
type: OrganizationUserType.Admin,
enabled: false,
});
organizationService.organizations$.calledWith(userId).mockReturnValue(of([org]));
organizationService.organizations$.calledWith(userId).mockReturnValue(of(orgs));
const actual = await TestBed.runInInjectionContext(
async () => await organizationPermissionsGuard()(route, state),
@@ -160,11 +167,12 @@ describe("Organization Permissions Guard", () => {
});
it("permits navigation if user is an owner", async () => {
const org = orgFactory({
const orgs = orgStateFactory({
type: OrganizationUserType.Owner,
enabled: false,
});
organizationService.organizations$.calledWith(userId).mockReturnValue(of([org]));
organizationService.organizations$.calledWith(userId).mockReturnValue(of(orgs));
const actual = await TestBed.runInInjectionContext(
async () => await organizationPermissionsGuard()(route, state),

View File

@@ -7,7 +7,7 @@ import {
Router,
RouterStateSnapshot,
} from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { firstValueFrom, switchMap } from "rxjs";
import {
canAccessOrgAdmin,
@@ -15,7 +15,9 @@ import {
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
@@ -55,12 +57,12 @@ export function organizationPermissionsGuard(
await syncService.fullSync(false);
}
const userId = await firstValueFrom(accountService.activeAccount$.pipe(map((a) => a?.id)));
const org = await firstValueFrom(
organizationService
.organizations$(userId)
.pipe(map((organizations) => organizations.find((org) => route.params.organizationId))),
accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => organizationService.organizations$(userId)),
getById(route.params.organizationId),
),
);
if (org == null) {

View File

@@ -68,6 +68,10 @@
<bit-label>{{ "limitCollectionDeletionDesc" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="limitCollectionDeletion" />
</bit-form-control>
<bit-form-control *ngIf="limitItemDeletionFeatureFlagIsEnabled">
<bit-label>{{ "limitItemDeletionDesc" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="limitItemDeletion" />
</bit-form-control>
<button
type="submit"
bitButton

View File

@@ -25,6 +25,8 @@ import { OrganizationUpdateRequest } from "@bitwarden/common/admin-console/model
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -53,6 +55,8 @@ export class AccountComponent implements OnInit, OnDestroy {
org: OrganizationResponse;
taxFormPromise: Promise<unknown>;
limitItemDeletionFeatureFlagIsEnabled: boolean;
// FormGroup validators taken from server Organization domain object
protected formGroup = this.formBuilder.group({
orgName: this.formBuilder.control(
@@ -71,6 +75,7 @@ export class AccountComponent implements OnInit, OnDestroy {
protected collectionManagementFormGroup = this.formBuilder.group({
limitCollectionCreation: this.formBuilder.control({ value: false, disabled: false }),
limitCollectionDeletion: this.formBuilder.control({ value: false, disabled: false }),
limitItemDeletion: this.formBuilder.control({ value: false, disabled: false }),
allowAdminAccessToAllCollectionItems: this.formBuilder.control({
value: false,
disabled: false,
@@ -94,11 +99,17 @@ export class AccountComponent implements OnInit, OnDestroy {
private dialogService: DialogService,
private formBuilder: FormBuilder,
private toastService: ToastService,
private configService: ConfigService,
) {}
async ngOnInit() {
this.selfHosted = this.platformUtilsService.isSelfHost();
this.configService
.getFeatureFlag$(FeatureFlag.LimitItemDeletion)
.pipe(takeUntil(this.destroy$))
.subscribe((isAble) => (this.limitItemDeletionFeatureFlagIsEnabled = isAble));
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.route.params
.pipe(
@@ -143,9 +154,11 @@ export class AccountComponent implements OnInit, OnDestroy {
orgName: this.org.name,
billingEmail: this.org.billingEmail,
});
this.collectionManagementFormGroup.patchValue({
limitCollectionCreation: this.org.limitCollectionCreation,
limitCollectionDeletion: this.org.limitCollectionDeletion,
limitItemDeletion: this.org.limitItemDeletion,
allowAdminAccessToAllCollectionItems: this.org.allowAdminAccessToAllCollectionItems,
});
@@ -202,6 +215,7 @@ export class AccountComponent implements OnInit, OnDestroy {
this.collectionManagementFormGroup.value.limitCollectionDeletion;
request.allowAdminAccessToAllCollectionItems =
this.collectionManagementFormGroup.value.allowAdminAccessToAllCollectionItems;
request.limitItemDeletion = this.collectionManagementFormGroup.value.limitItemDeletion;
await this.organizationApiService.updateCollectionManagement(this.organizationId, request);

View File

@@ -83,11 +83,11 @@ export type Permission = {
export const getPermissionList = (): Permission[] => {
const permissions = [
{ perm: CollectionPermission.View, labelId: "canView" },
{ perm: CollectionPermission.ViewExceptPass, labelId: "canViewExceptPass" },
{ perm: CollectionPermission.Edit, labelId: "canEdit" },
{ perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" },
{ perm: CollectionPermission.Manage, labelId: "canManage" },
{ perm: CollectionPermission.View, labelId: "viewItems" },
{ perm: CollectionPermission.ViewExceptPass, labelId: "viewItemsHidePass" },
{ perm: CollectionPermission.Edit, labelId: "editItems" },
{ perm: CollectionPermission.EditExceptPass, labelId: "editItemsHidePass" },
{ perm: CollectionPermission.Manage, labelId: "manageCollection" },
];
return permissions;

View File

@@ -7,8 +7,8 @@ import * as jq from "jquery";
import { Subject, filter, firstValueFrom, map, takeUntil, timeout } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -19,11 +19,13 @@ import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-con
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { StateEventRunnerService } from "@bitwarden/common/platform/state";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -89,6 +91,8 @@ export class AppComponent implements OnDestroy, OnInit {
private stateEventRunnerService: StateEventRunnerService,
private organizationService: InternalOrganizationServiceAbstraction,
private accountService: AccountService,
private apiService: ApiService,
private appIdService: AppIdService,
private processReloadService: ProcessReloadServiceAbstraction,
) {}
@@ -117,24 +121,6 @@ export class AppComponent implements OnDestroy, OnInit {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.ngZone.run(async () => {
switch (message.command) {
case "loggedIn":
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.notificationsService.updateConnection(false);
break;
case "loggedOut":
if (
message.userId == null ||
message.userId === (await firstValueFrom(this.accountService.activeAccount$))
) {
await this.notificationsService.updateConnection(false);
}
break;
case "unlocked":
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.notificationsService.updateConnection(false);
break;
case "authBlocked":
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -148,10 +134,6 @@ export class AppComponent implements OnDestroy, OnInit {
await this.vaultTimeoutService.lock();
break;
case "locked":
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.notificationsService.updateConnection(false);
await this.processReloadService.startProcessReload(this.authService);
break;
case "lockedUrl":

View File

@@ -3,7 +3,6 @@ import { LayoutModule } from "@angular/cdk/layout";
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { InfiniteScrollDirective } from "ngx-infinite-scroll";
import { AppComponent } from "./app.component";
import { CoreModule } from "./core";
@@ -23,7 +22,6 @@ import { WildcardRoutingModule } from "./wildcard-routing.module";
BrowserAnimationsModule,
FormsModule,
CoreModule,
InfiniteScrollDirective,
DragDropModule,
LayoutModule,
OssRoutingModule,

View File

@@ -1,5 +1,3 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable tailwindcss/no-custom-classname -->
<div
class="tw-mx-auto tw-mt-5 tw-flex tw-max-w-lg tw-flex-col tw-items-center tw-justify-center tw-p-8"
>
@@ -14,15 +12,11 @@
<div
class="tw-mt-3 tw-rounded-md tw-border tw-border-solid tw-border-secondary-300 tw-bg-background tw-p-6"
>
<h2 class="tw-mb-6 tw-text-xl tw-font-semibold">{{ "loginInitiated" | i18n }}</h2>
<h2 class="tw-mb-6 tw-text-xl tw-font-semibold">{{ "logInRequestSent" | i18n }}</h2>
<div class="tw-text-light">
<p class="tw-mb-6">{{ "notificationSentDevice" | i18n }}</p>
<p class="tw-mb-6">
{{ "fingerprintMatchInfo" | i18n }}
</p>
</div>
<p class="tw-mb-6">
{{ "notificationSentDeviceComplete" | i18n }}
</p>
<div class="tw-mb-6">
<h4 class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</h4>
@@ -39,7 +33,7 @@
<hr />
<div class="tw-text-light tw-mt-3">
<div class="tw-mt-3">
{{ "loginWithDeviceEnabledNote" | i18n }}
<a routerLink="/login">{{ "viewAllLoginOptions" | i18n }}</a>
</div>
@@ -52,7 +46,7 @@
>
<h2 class="tw-mb-6 tw-text-xl tw-font-semibold">{{ "adminApprovalRequested" | i18n }}</h2>
<div class="tw-text-light">
<div>
<p class="tw-mb-6">{{ "adminApprovalRequestSentToAdmins" | i18n }}</p>
<p class="tw-mb-6">{{ "youWillBeNotifiedOnceApproved" | i18n }}</p>
</div>
@@ -66,7 +60,7 @@
<hr />
<div class="tw-text-light tw-mt-3">
<div class="tw-mt-3">
{{ "troubleLoggingIn" | i18n }}
<a routerLink="/login-initiated">{{ "viewAllLoginOptions" | i18n }}</a>
</div>

View File

@@ -1,158 +0,0 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable tailwindcss/no-custom-classname -->
<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"
[attr.readonly]="queryParamFromOrgInvite ? true : null"
/>
<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">
<auth-password-callout [policy]="enforcedPolicyOptions" *ngIf="enforcedPolicyOptions">
</auth-password-callout>
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
id="register-form_input_master-password"
bitInput
type="password"
formControlName="masterPassword"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<bit-hint>
<span class="tw-font-semibold">{{ "important" | i18n }}</span>
{{ "masterPassImportant" | i18n }} {{ characterMinimumMessage }}
</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="password"
formControlName="confirmMasterPassword"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
</bit-form-field>
</div>
<div class="tw-mb-3">
<bit-form-field>
<bit-label>{{ "masterPassHintLabel" | 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" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<div class="tw-mb-4 tw-flex tw-items-start">
<input
class="mt-1"
type="checkbox"
bitCheckbox
id="checkForBreaches"
name="CheckBreach"
formControlName="checkForBreaches"
/>
<bit-label for="checkForBreaches"> {{ "checkForBreaches" | i18n }}</bit-label>
</div>
<div class="tw-mb-3 tw-flex tw-items-start" *ngIf="showTerms">
<input
class="mt-1"
id="register-form-input-accept-policies"
bitCheckbox
type="checkbox"
formControlName="acceptPolicies"
/>
<bit-label for="register-form-input-accept-policies">
{{ "acceptPolicies" | i18n }}<br />
<a bitLink href="https://bitwarden.com/terms/" target="_blank" rel="noreferrer">{{
"termsOfService" | i18n
}}</a
>,
<a bitLink href="https://bitwarden.com/privacy/" target="_blank" rel="noreferrer">{{
"privacyPolicy" | i18n
}}</a>
</bit-label>
</div>
<div class="tw-space-x-2 tw-pt-2">
<ng-container *ngIf="!accountCreated">
<button
[block]="true"
type="submit"
buttonType="primary"
bitButton
[loading]="form.loading"
>
{{ "createAccount" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="accountCreated">
<button
[block]="true"
type="submit"
buttonType="primary"
bitButton
[loading]="form.loading"
>
{{ "logIn" | i18n }}
</button>
</ng-container>
</div>
<p class="tw-m-0 tw-mt-5 tw-text-sm">
{{ "alreadyHaveAccount" | i18n }}
<a bitLink routerLink="/login">{{ "logIn" | i18n }}</a>
</p>
<bit-error-summary *ngIf="showErrorSummary" [formGroup]="formGroup"></bit-error-summary>
</div>
</form>

View File

@@ -1,115 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Input, OnInit } from "@angular/core";
import { UntypedFormBuilder } from "@angular/forms";
import { Router } from "@angular/router";
import { RegisterComponent as BaseRegisterComponent } from "@bitwarden/angular/auth/components/register.component";
import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstractions/form-validation-errors.service";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
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 { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { RegisterRequest } from "@bitwarden/common/models/request/register.request";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
@Component({
selector: "app-register-form",
templateUrl: "./register-form.component.html",
})
export class RegisterFormComponent extends BaseRegisterComponent implements OnInit {
@Input() queryParamEmail: string;
@Input() queryParamFromOrgInvite: boolean;
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
@Input() referenceDataValue: ReferenceEventRequest;
showErrorSummary = false;
characterMinimumMessage: string;
constructor(
formValidationErrorService: FormValidationErrorsService,
formBuilder: UntypedFormBuilder,
loginStrategyService: LoginStrategyServiceAbstraction,
router: Router,
i18nService: I18nService,
keyService: KeyService,
apiService: ApiService,
platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
environmentService: EnvironmentService,
logService: LogService,
auditService: AuditService,
dialogService: DialogService,
acceptOrgInviteService: AcceptOrganizationInviteService,
toastService: ToastService,
) {
super(
formValidationErrorService,
formBuilder,
loginStrategyService,
router,
i18nService,
keyService,
apiService,
platformUtilsService,
environmentService,
logService,
auditService,
dialogService,
toastService,
);
this.modifyRegisterRequest = async (request: RegisterRequest) => {
// Org invites are deep linked. Non-existent accounts are redirected to the register page.
// Org user id and token are included here only for validation and two factor purposes.
const orgInvite = await acceptOrgInviteService.getOrganizationInvite();
if (orgInvite != null) {
request.organizationUserId = orgInvite.organizationUserId;
request.token = orgInvite.token;
}
// Invite is accepted after login (on deep link redirect).
};
}
async ngOnInit() {
await super.ngOnInit();
this.referenceData = this.referenceDataValue;
if (this.queryParamEmail) {
this.formGroup.get("email")?.setValue(this.queryParamEmail);
}
if (this.enforcedPolicyOptions != null && this.enforcedPolicyOptions.minLength > 0) {
this.characterMinimumMessage = "";
} else {
this.characterMinimumMessage = this.i18nService.t("characterMinimum", this.minimumLength);
}
}
async submit() {
if (
this.enforcedPolicyOptions != null &&
!this.policyService.evaluateMasterPassword(
this.passwordStrengthResult.score,
this.formGroup.value.masterPassword,
this.enforcedPolicyOptions,
)
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordPolicyRequirementsNotMet"),
});
return;
}
await super.submit(false);
}
}

View File

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

View File

@@ -9,6 +9,26 @@
</div>
<app-danger-zone>
<ng-container *ngIf="showSetNewDeviceLoginProtection$ | async">
<button
*ngIf="verifyNewDeviceLogin"
type="button"
bitButton
buttonType="danger"
[bitAction]="setNewDeviceLoginProtection"
>
{{ "turnOffNewDeviceLoginProtection" | i18n }}
</button>
<button
*ngIf="!verifyNewDeviceLogin"
type="button"
bitButton
buttonType="secondary"
[bitAction]="setNewDeviceLoginProtection"
>
{{ "turnOnNewDeviceLoginProtection" | i18n }}
</button>
</ng-container>
<button type="button" bitButton buttonType="danger" (click)="deauthorizeSessions()">
{{ "deauthorizeSessions" | i18n }}
</button>
@@ -32,7 +52,6 @@
</button>
</app-danger-zone>
<ng-template #deauthorizeSessionsTemplate></ng-template>
<ng-template #viewUserApiKeyTemplate></ng-template>
<ng-template #rotateUserApiKeyTemplate></ng-template>
</bit-container>

View File

@@ -1,9 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { combineLatest, firstValueFrom, from, lastValueFrom, map, Observable } from "rxjs";
import { Component, OnInit, OnDestroy } from "@angular/core";
import {
combineLatest,
firstValueFrom,
from,
lastValueFrom,
map,
Observable,
Subject,
takeUntil,
} from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@@ -16,31 +22,35 @@ import { PurgeVaultComponent } from "../../../vault/settings/purge-vault.compone
import { DeauthorizeSessionsComponent } from "./deauthorize-sessions.component";
import { DeleteAccountDialogComponent } from "./delete-account-dialog.component";
import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-dialog.component";
@Component({
selector: "app-account",
templateUrl: "account.component.html",
})
export class AccountComponent implements OnInit {
@ViewChild("deauthorizeSessionsTemplate", { read: ViewContainerRef, static: true })
deauthModalRef: ViewContainerRef;
export class AccountComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
showChangeEmail$: Observable<boolean>;
showPurgeVault$: Observable<boolean>;
showDeleteAccount$: Observable<boolean>;
showChangeEmail$: Observable<boolean> = new Observable();
showPurgeVault$: Observable<boolean> = new Observable();
showDeleteAccount$: Observable<boolean> = new Observable();
showSetNewDeviceLoginProtection$: Observable<boolean> = new Observable();
verifyNewDeviceLogin: boolean = true;
constructor(
private modalService: ModalService,
private accountService: AccountService,
private dialogService: DialogService,
private userVerificationService: UserVerificationService,
private configService: ConfigService,
private organizationService: OrganizationService,
private accountService: AccountService,
) {}
async ngOnInit() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.showSetNewDeviceLoginProtection$ = this.configService.getFeatureFlag$(
FeatureFlag.NewDeviceVerification,
);
const isAccountDeprovisioningEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.AccountDeprovisioning,
);
@@ -83,11 +93,17 @@ export class AccountComponent implements OnInit {
!isAccountDeprovisioningEnabled || !userIsManagedByOrganization,
),
);
this.accountService.accountVerifyNewDeviceLogin$
.pipe(takeUntil(this.destroy$))
.subscribe((verifyDevices) => {
this.verifyNewDeviceLogin = verifyDevices;
});
}
async deauthorizeSessions() {
await this.modalService.openViewRef(DeauthorizeSessionsComponent, this.deauthModalRef);
}
deauthorizeSessions = async () => {
const dialogRef = DeauthorizeSessionsComponent.open(this.dialogService);
await lastValueFrom(dialogRef.closed);
};
purgeVault = async () => {
const dialogRef = PurgeVaultComponent.open(this.dialogService);
@@ -98,4 +114,14 @@ export class AccountComponent implements OnInit {
const dialogRef = DeleteAccountDialogComponent.open(this.dialogService);
await lastValueFrom(dialogRef.closed);
};
setNewDeviceLoginProtection = async () => {
const dialogRef = SetAccountVerifyDevicesDialogComponent.open(this.dialogService);
await lastValueFrom(dialogRef.closed);
};
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -1,14 +1,6 @@
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5 !tw-text-danger">{{ "dangerZone" | i18n }}</h1>
<div class="tw-rounded tw-border tw-border-solid tw-border-danger-600 tw-p-5">
<p>
{{
(accountDeprovisioningEnabled$ | async) && content.children.length === 1
? ("dangerZoneDescSingular" | i18n)
: ("dangerZoneDesc" | i18n)
}}
</p>
<div #content class="tw-flex tw-flex-row tw-gap-2">
<ng-content></ng-content>
</div>

View File

@@ -1,38 +1,21 @@
<div class="modal fade" role="dialog" aria-modal="true" aria-labelledby="deAuthTitle">
<div class="modal-dialog modal-dialog-scrollable" role="document">
<form
class="modal-content"
#form
(ngSubmit)="submit()"
[appApiAction]="formPromise"
ngNativeValidate
>
<div class="modal-header">
<h1 class="modal-title" id="deAuthTitle">{{ "deauthorizeSessions" | i18n }}</h1>
<button
type="button"
class="close"
data-dismiss="modal"
appA11yTitle="{{ 'close' | i18n }}"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>{{ "deauthorizeSessionsDesc" | i18n }}</p>
<bit-callout type="warning">{{ "deauthorizeSessionsWarning" | i18n }}</bit-callout>
<app-user-verification [(ngModel)]="masterPassword" ngDefaultControl name="secret">
</app-user-verification>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-danger btn-submit" [disabled]="form.loading">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span>{{ "deauthorizeSessions" | i18n }}</span>
</button>
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal">
{{ "close" | i18n }}
</button>
</div>
</form>
</div>
</div>
<form [formGroup]="deauthForm" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="'deauthorizeSessions' | i18n">
<ng-container bitDialogContent>
<p bitTypography="body1">{{ "deauthorizeSessionsDesc" | i18n }}</p>
<bit-callout type="warning">{{ "deauthorizeSessionsWarning" | i18n }}</bit-callout>
<app-user-verification-form-input
formControlName="verification"
name="verification"
[(invalidSecret)]="invalidSecret"
></app-user-verification-form-input>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton bitFormButton type="submit" buttonType="danger">
{{ "deauthorizeSessions" | i18n }}
</button>
<button bitButton bitFormButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@@ -8,33 +7,33 @@ import { Verification } from "@bitwarden/common/auth/types/verification";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ToastService } from "@bitwarden/components";
import { DialogService, ToastService } from "@bitwarden/components";
@Component({
selector: "app-deauthorize-sessions",
templateUrl: "deauthorize-sessions.component.html",
})
export class DeauthorizeSessionsComponent {
masterPassword: Verification;
formPromise: Promise<unknown>;
deauthForm = this.formBuilder.group({
verification: undefined as Verification | undefined,
});
invalidSecret: boolean = false;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private formBuilder: FormBuilder,
private userVerificationService: UserVerificationService,
private messagingService: MessagingService,
private logService: LogService,
private toastService: ToastService,
) {}
async submit() {
submit = async () => {
try {
this.formPromise = this.userVerificationService
.buildRequest(this.masterPassword)
.then((request) => this.apiService.postSecurityStamp(request));
await this.formPromise;
const verification: Verification = this.deauthForm.value.verification!;
const request = await this.userVerificationService.buildRequest(verification);
await this.apiService.postSecurityStamp(request);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("sessionsDeauthorized"),
@@ -44,5 +43,9 @@ export class DeauthorizeSessionsComponent {
} catch (e) {
this.logService.error(e);
}
};
static open(dialogService: DialogService) {
return dialogService.open(DeauthorizeSessionsComponent);
}
}

View File

@@ -8,7 +8,6 @@ import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-a
import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, ToastService } from "@bitwarden/components";
@Component({
@@ -22,7 +21,6 @@ export class DeleteAccountDialogComponent {
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private formBuilder: FormBuilder,
private accountApiService: AccountApiService,
private dialogRef: DialogRef,

View File

@@ -0,0 +1,43 @@
<form [formGroup]="setVerifyDevicesForm" [bitSubmit]="submit">
<bit-dialog dialogSize="default" [title]="'newDeviceLoginProtection' | i18n">
<ng-container bitDialogContent>
<p *ngIf="verifyNewDeviceLogin" bitTypography="body1">
{{ "turnOffNewDeviceLoginProtectionModalDesc" | i18n }}
</p>
<p *ngIf="!verifyNewDeviceLogin" bitTypography="body1">
{{ "turnOnNewDeviceLoginProtectionModalDesc" | i18n }}
</p>
<bit-callout *ngIf="verifyNewDeviceLogin && !has2faConfigured" type="warning">{{
"turnOffNewDeviceLoginProtectionWarning" | i18n
}}</bit-callout>
<app-user-verification-form-input
formControlName="verification"
name="verification"
[(invalidSecret)]="invalidSecret"
></app-user-verification-form-input>
</ng-container>
<ng-container bitDialogFooter>
<button
bitButton
*ngIf="verifyNewDeviceLogin"
bitFormButton
type="submit"
buttonType="danger"
>
{{ "disable" | i18n }}
</button>
<button
bitButton
*ngIf="!verifyNewDeviceLogin"
bitFormButton
type="submit"
buttonType="primary"
>
{{ "enable" | i18n }}
</button>
<button bitButton bitFormButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -0,0 +1,122 @@
import { DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { firstValueFrom, Subject, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { SetVerifyDevicesRequest } from "@bitwarden/common/auth/models/request/set-verify-devices.request";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
AsyncActionsModule,
ButtonModule,
CalloutModule,
DialogModule,
DialogService,
FormFieldModule,
IconButtonModule,
RadioButtonModule,
SelectModule,
ToastService,
} from "@bitwarden/components";
@Component({
templateUrl: "./set-account-verify-devices-dialog.component.html",
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
JslibModule,
FormFieldModule,
AsyncActionsModule,
ButtonModule,
IconButtonModule,
SelectModule,
CalloutModule,
RadioButtonModule,
DialogModule,
UserVerificationFormInputComponent,
],
})
export class SetAccountVerifyDevicesDialogComponent implements OnInit, OnDestroy {
// use this subject for all subscriptions to ensure all subscripts are completed
private destroy$ = new Subject<void>();
// the default for new device verification is true
verifyNewDeviceLogin: boolean = true;
has2faConfigured: boolean = false;
setVerifyDevicesForm = this.formBuilder.group({
verification: undefined as Verification | undefined,
});
invalidSecret: boolean = false;
constructor(
private i18nService: I18nService,
private formBuilder: FormBuilder,
private accountApiService: AccountApiService,
private accountService: AccountService,
private userVerificationService: UserVerificationService,
private dialogRef: DialogRef,
private toastService: ToastService,
private apiService: ApiService,
) {
this.accountService.accountVerifyNewDeviceLogin$
.pipe(takeUntil(this.destroy$))
.subscribe((verifyDevices: boolean) => {
this.verifyNewDeviceLogin = verifyDevices;
});
}
async ngOnInit() {
const twoFactorProviders = await this.apiService.getTwoFactorProviders();
this.has2faConfigured = twoFactorProviders.data.length > 0;
}
submit = async () => {
try {
const activeAccount = await firstValueFrom(
this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)),
);
const verification: Verification = this.setVerifyDevicesForm.value.verification!;
const request: SetVerifyDevicesRequest = await this.userVerificationService.buildRequest(
verification,
SetVerifyDevicesRequest,
);
// set verify device opposite what is currently is.
request.verifyDevices = !this.verifyNewDeviceLogin;
await this.accountApiService.setVerifyDevices(request);
await this.accountService.setAccountVerifyNewDeviceLogin(
activeAccount!.id,
request.verifyDevices,
);
this.dialogRef.close();
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("accountNewDeviceLoginProtectionSaved"),
});
} catch (e) {
if (e instanceof ErrorResponse && e.statusCode === 400) {
this.invalidSecret = true;
}
throw e;
}
};
static open(dialogService: DialogService) {
return dialogService.open(SetAccountVerifyDevicesDialogComponent);
}
// closes subscription leaks
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -17,11 +17,9 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { UserKeyRotationService } from "../../key-management/key-rotation/user-key-rotation.service";
@@ -46,8 +44,6 @@ export class ChangePasswordComponent
i18nService: I18nService,
keyService: KeyService,
messagingService: MessagingService,
stateService: StateService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
platformUtilsService: PlatformUtilsService,
policyService: PolicyService,
private auditService: AuditService,
@@ -68,10 +64,8 @@ export class ChangePasswordComponent
i18nService,
keyService,
messagingService,
passwordGenerationService,
platformUtilsService,
policyService,
stateService,
dialogService,
kdfConfigService,
masterPasswordService,

View File

@@ -13,9 +13,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KdfType, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { EmergencyAccessService } from "../../../emergency-access";
@@ -53,8 +51,6 @@ export class EmergencyAccessTakeoverComponent
i18nService: I18nService,
keyService: KeyService,
messagingService: MessagingService,
stateService: StateService,
passwordGenerationService: PasswordGenerationServiceAbstraction,
platformUtilsService: PlatformUtilsService,
policyService: PolicyService,
private emergencyAccessService: EmergencyAccessService,
@@ -70,10 +66,8 @@ export class EmergencyAccessTakeoverComponent
i18nService,
keyService,
messagingService,
passwordGenerationService,
platformUtilsService,
policyService,
stateService,
dialogService,
kdfConfigService,
masterPasswordService,

View File

@@ -14,10 +14,7 @@
{{ "twoStepLoginProviderEnabled" | i18n }}
</app-callout>
<app-callout type="warning">
<p bitTypography="body1">{{ "twoFactorWebAuthnWarning" | i18n }}</p>
<ul class="tw-mb-0">
<li>{{ "twoFactorWebAuthnSupportWeb" | i18n }}</li>
</ul>
<p bitTypography="body1">{{ "twoFactorWebAuthnWarning1" | i18n }}</p>
</app-callout>
<img class="tw-float-right tw-ml-5 mfaType7" alt="FIDO2 WebAuthn logo" />
<ul class="bwi-ul">

View File

@@ -1,149 +0,0 @@
<!-- eslint-disable tailwindcss/no-custom-classname -->
<app-secrets-manager-trial
*ngIf="layout === layouts.secretsManager; else passwordManagerTrial"
></app-secrets-manager-trial>
<ng-template #passwordManagerTrial>
<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"
[queryParamFromOrgInvite]="fromOrgInvite"
[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-teams3-content *ngIf="layout === layouts.teams3"></app-teams3-content>
<app-enterprise-content *ngIf="layout === layouts.enterprise"></app-enterprise-content>
<app-enterprise1-content *ngIf="layout === layouts.enterprise1"></app-enterprise1-content>
<app-enterprise2-content *ngIf="layout === layouts.enterprise2"></app-enterprise2-content>
<app-cnet-enterprise-content
*ngIf="layout === layouts.cnetcmpgnent"
></app-cnet-enterprise-content>
<app-cnet-individual-content
*ngIf="layout === layouts.cnetcmpgnind"
></app-cnet-individual-content>
<app-cnet-teams-content
*ngIf="layout === layouts.cnetcmpgnteams"
></app-cnet-teams-content>
<app-abm-enterprise-content
*ngIf="layout === layouts.abmenterprise"
></app-abm-enterprise-content>
<app-abm-teams-content *ngIf="layout === layouts.abmteams"></app-abm-teams-content>
</div>
</div>
<div 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-gap-5 tw-rounded-t tw-bg-secondary-100">
<h2 class="tw-pb-4 tw-pl-4 tw-pt-5 tw-text-base tw-font-bold tw-uppercase">
{{ freeTrialText }}
</h2>
<environment-selector
class="tw-mr-4 tw-mt-6 tw-flex-shrink-0 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"
[loading]="loading"
(click)="createOrganizationOnTrial()"
>
{{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
</button>
</app-vertical-step>
<app-vertical-step
label="Billing"
[subLabel]="billingSubLabel"
*ngIf="!(enableTrialPayment$ | async)"
>
<app-trial-billing-step
*ngIf="stepper.selectedIndex === 2"
[organizationInfo]="{
name: orgInfoFormGroup.get('name').value,
email: orgInfoFormGroup.get('email').value,
type: trialOrganizationType,
}"
[subscriptionProduct]="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"
></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>
</ng-template>

View File

@@ -1,336 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
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 { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { RouterService } from "../../core";
import { SharedModule } from "../../shared";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
import { OrganizationInvite } from "../organization-invite/organization-invite";
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>;
let routerServiceMock: MockProxy<RouterService>;
let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>;
let organizationBillingServiceMock: MockProxy<OrganizationBillingService>;
let configServiceMock: MockProxy<ConfigService>;
beforeEach(() => {
// only define services directly that we want to mock return values in this component
stateServiceMock = mock<StateService>();
policyApiServiceMock = mock<PolicyApiServiceAbstraction>();
policyServiceMock = mock<PolicyService>();
routerServiceMock = mock<RouterService>();
acceptOrgInviteServiceMock = mock<AcceptOrganizationInviteService>();
organizationBillingServiceMock = mock<OrganizationBillingService>();
configServiceMock = mock<ConfigService>();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
imports: [
SharedModule,
RouterTestingModule.withRoutes([
{ path: "trial", component: TrialInitiationComponent },
{
path: `organizations/${testOrgId}/vault`,
component: BlankComponent,
},
{
path: `organizations/${testOrgId}/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: routerServiceMock,
},
{
provide: AcceptOrganizationInviteService,
useValue: acceptOrgInviteServiceMock,
},
{
provide: OrganizationBillingService,
useValue: organizationBillingServiceMock,
},
{
provide: ConfigService,
useValue: configServiceMock,
},
],
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 there isn't an org invite in deep linked url", async () => {
acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce(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 the deep linked url has an org invite", async () => {
// Set up service method mocks
acceptOrgInviteServiceMock.getOrganizationInvite.mockResolvedValueOnce({
organizationId: testOrgId,
token: "token",
email: "testEmail",
organizationUserId: "123",
} as OrganizationInvite);
policyApiServiceMock.getPoliciesByToken.mockReturnValueOnce(
Promise.resolve([
{
id: "345",
organizationId: testOrgId,
type: 1,
data: {
minComplexity: 4,
minLength: 10,
requireLower: null,
requireNumbers: null,
requireSpecial: null,
requireUpper: null,
},
enabled: true,
},
] as Policy[]),
);
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();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
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, "members"]);
}));
});
});
});
export class VerticalStepperStubComponent extends VerticalStepperComponent {}
export class BlankComponent {} // For router tests

View File

@@ -1,353 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
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 { 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 { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import {
OrganizationInformation,
PlanInformation,
OrganizationBillingServiceAbstraction as OrganizationBillingService,
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import {
OrganizationCreatedEvent,
SubscriptionProduct,
TrialOrganizationType,
} from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
import { OrganizationInvite } from "../organization-invite/organization-invite";
import { RouterService } from "./../../core/router.service";
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
export enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
teamsStarter = "teamsStarter",
individual = "individual",
premium = "premium",
free = "free",
}
enum ValidLayoutParams {
default = "default",
teams = "teams",
teams1 = "teams1",
teams2 = "teams2",
teams3 = "teams3",
enterprise = "enterprise",
enterprise1 = "enterprise1",
enterprise2 = "enterprise2",
cnetcmpgnent = "cnetcmpgnent",
cnetcmpgnind = "cnetcmpgnind",
cnetcmpgnteams = "cnetcmpgnteams",
abmenterprise = "abmenterprise",
abmteams = "abmteams",
secretsManager = "secretsManager",
}
@Component({
selector: "app-trial",
templateUrl: "trial-initiation.component.html",
})
export class TrialInitiationComponent implements OnInit, OnDestroy {
email = "";
fromOrgInvite = false;
org = "";
orgInfoSubLabel = "";
orgId = "";
orgLabel = "";
billingSubLabel = "";
layout = "default";
plan: PlanType;
productTier: ProductTierType;
accountCreateOnly = true;
useTrialStepper = false;
loading = false;
policies: Policy[];
enforcedPolicyOptions: MasterPasswordPolicyOptions;
trialFlowOrgs: string[] = [
ValidOrgParams.teams,
ValidOrgParams.teamsStarter,
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;
} else {
// Matches "_ga_QBRN562QQQ=value1.value2.session" and captures values and session.
const regex = /_ga_QBRN562QQQ=([^.]+)\.([^.]+)\.(\d+)/;
const match = document.cookie.match(regex);
if (match) {
this.referenceData.session = match[3];
}
}
}
private destroy$ = new Subject<void>();
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
FeatureFlag.TrialPaymentOptional,
);
constructor(
private route: ActivatedRoute,
protected router: Router,
private formBuilder: UntypedFormBuilder,
private titleCasePipe: TitleCasePipe,
private logService: LogService,
private policyApiService: PolicyApiServiceAbstraction,
private policyService: PolicyService,
private i18nService: I18nService,
private routerService: RouterService,
private acceptOrgInviteService: AcceptOrganizationInviteService,
private organizationBillingService: OrganizationBillingService,
private configService: ConfigService,
) {}
async ngOnInit(): Promise<void> {
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => {
this.referenceData = new ReferenceEventRequest();
if (qParams.email != null && qParams.email.indexOf("@") > -1) {
this.email = qParams.email;
this.fromOrgInvite = qParams.fromOrgInvite === "true";
}
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.orgDisplayName);
this.useTrialStepper = true;
this.referenceData.flow = qParams.org;
if (this.org === ValidOrgParams.families) {
this.plan = PlanType.FamiliesAnnually;
this.productTier = ProductTierType.Families;
} else if (this.org === ValidOrgParams.teamsStarter) {
this.plan = PlanType.TeamsStarter;
this.productTier = ProductTierType.TeamsStarter;
} else if (this.org === ValidOrgParams.teams) {
this.plan = PlanType.TeamsAnnually;
this.productTier = ProductTierType.Teams;
} else if (this.org === ValidOrgParams.enterprise) {
this.plan = PlanType.EnterpriseAnnually;
this.productTier = ProductTierType.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);
this.referenceData.initiationPath = this.accountCreateOnly
? "Registration form"
: "Password Manager trial from marketing website";
});
// If there's a deep linked org invite, use it to get the password policies
const orgInvite = await this.acceptOrgInviteService.getOrganizationInvite();
if (orgInvite != null) {
await this.initPasswordPolicies(orgInvite);
}
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.orgDisplayName) +
" 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");
}
}
async createOrganizationOnTrial() {
this.loading = true;
const organization: OrganizationInformation = {
name: this.orgInfoFormGroup.get("name").value,
billingEmail: this.orgInfoFormGroup.get("email").value,
initiationPath: "Password Manager trial from marketing website",
};
const plan: PlanInformation = {
type: this.plan,
passwordManagerSeats: 1,
};
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
organization,
plan,
});
this.orgId = response?.id;
this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`;
this.loading = false;
this.verticalStepper.next();
}
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();
}
createdOrganization(event: OrganizationCreatedEvent) {
this.orgId = event.organizationId;
this.billingSubLabel = event.planDescription;
this.verticalStepper.next();
}
navigateToOrgVault() {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["organizations", this.orgId, "vault"]);
}
navigateToOrgInvite() {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["organizations", this.orgId, "members"]);
}
previousStep() {
this.verticalStepper.previous();
}
get orgDisplayName() {
if (this.org === "teamsStarter") {
return "Teams Starter";
}
return this.org;
}
get freeTrialText() {
const translationKey =
this.layout === this.layouts.secretsManager
? "startYour7DayFreeTrialOfBitwardenSecretsManagerFor"
: "startYour7DayFreeTrialOfBitwardenFor";
return this.i18nService.t(translationKey, this.org);
}
get trialOrganizationType(): TrialOrganizationType {
switch (this.productTier) {
case ProductTierType.Free:
return null;
default:
return this.productTier;
}
}
private setupFamilySponsorship(sponsorshipToken: string) {
if (sponsorshipToken != null) {
const route = this.router.createUrlTree(["setup/families-for-enterprise"], {
queryParams: { plan: sponsorshipToken },
});
this.routerService.setPreviousUrl(route.toString());
}
}
private async initPasswordPolicies(invite: OrganizationInvite): Promise<void> {
if (invite == null) {
return;
}
try {
this.policies = await this.policyApiService.getPoliciesByToken(
invite.organizationId,
invite.token,
invite.email,
invite.organizationUserId,
);
} catch (e) {
this.logService.error(e);
}
if (this.policies != null) {
this.policyService
.masterPasswordPolicyOptions$(this.policies)
.pipe(takeUntil(this.destroy$))
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPolicyOptions = enforcedPasswordPolicyOptions;
});
}
}
protected readonly SubscriptionProduct = SubscriptionProduct;
}

View File

@@ -118,7 +118,13 @@
) | currency: "$"
}}
</b>
<span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span>
<span class="tw-text-xs tw-px-0">
/{{
selectableProduct.productTier === productTypes.Families
? "month"
: ("monthPerMember" | i18n)
}}</span
>
<b class="tw-text-sm tw-font-semibold">
<ng-container
*ngIf="selectableProduct.PasswordManager.hasAdditionalSeatsOption"

View File

@@ -1045,10 +1045,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.estimatedTax = invoice.taxAmount;
})
.catch((error) => {
const translatedMessage = this.i18nService.t(error.message);
this.toastService.showToast({
title: "",
variant: "error",
message: this.i18nService.t(error.message),
message:
!translatedMessage || translatedMessage === "" ? error.message : translatedMessage,
});
});
}

View File

@@ -433,7 +433,11 @@
<p class="tw-text-muted tw-italic tw-mb-3 tw-block" bitTypography="body2">
{{ paymentDesc }}
</p>
<app-payment *ngIf="createOrganization || upgradeRequiresPaymentMethod"></app-payment>
<app-payment
*ngIf="createOrganization || upgradeRequiresPaymentMethod"
[showAccountCredit]="false"
>
</app-payment>
<app-manage-tax-information
class="tw-my-4"
[showTaxIdField]="showTaxIdField"

View File

@@ -294,6 +294,10 @@
</ng-template>
<ng-template #setupSelfHost>
<ng-container *ngIf="userOrg.hasReseller && resellerSeatsRemainingMessage">
<h2 bitTypography="h2" class="tw-mt-7">{{ "manageSubscription" | i18n }}</h2>
<p bitTypography="body1">{{ resellerSeatsRemainingMessage }}</p>
</ng-container>
<ng-container *ngIf="showSelfHost">
<h2 bitTypography="h2" class="tw-mt-7">
{{ "selfHostingTitleProper" | i18n }}

View File

@@ -4,13 +4,17 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, lastValueFrom, Observable, Subject } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationApiKeyType } from "@bitwarden/common/admin-console/enums";
import {
OrganizationApiKeyType,
OrganizationUserStatusType,
} from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
@@ -61,12 +65,15 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
showSubscription = true;
showSelfHost = false;
organizationIsManagedByConsolidatedBillingMSP = false;
resellerSeatsRemainingMessage: string;
protected readonly subscriptionHiddenIcon = SubscriptionHiddenIcon;
protected readonly teamsStarter = ProductTierType.TeamsStarter;
private destroy$ = new Subject<void>();
private seatsRemainingMessage: string;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
@@ -79,6 +86,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
private configService: ConfigService,
private toastService: ToastService,
private billingApiService: BillingApiServiceAbstraction,
private organizationUserApiService: OrganizationUserApiService,
) {}
async ngOnInit() {
@@ -104,6 +112,28 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy
}
}
}
if (this.userOrg.hasReseller) {
const allUsers = await this.organizationUserApiService.getAllUsers(this.userOrg.id);
const userCount = allUsers.data.filter((user) =>
[
OrganizationUserStatusType.Invited,
OrganizationUserStatusType.Accepted,
OrganizationUserStatusType.Confirmed,
].includes(user.status),
).length;
const remainingSeats = this.userOrg.seats - userCount;
const seatsRemaining = this.i18nService.t(
"seatsRemaining",
remainingSeats.toString(),
this.userOrg.seats.toString(),
);
this.resellerSeatsRemainingMessage = seatsRemaining;
}
}
ngOnDestroy() {

View File

@@ -23,7 +23,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SyncService } from "@bitwarden/common/platform/sync";
import { DialogService, ToastService } from "@bitwarden/components";
import { FreeTrial } from "../../../core/types/free-trial";
import { TrialFlowService } from "../../services/trial-flow.service";
import {
AddCreditDialogResult,
@@ -33,6 +32,7 @@ import {
AdjustPaymentDialogComponent,
AdjustPaymentDialogResultType,
} from "../../shared/adjust-payment-dialog/adjust-payment-dialog.component";
import { FreeTrial } from "../../types/free-trial";
@Component({
templateUrl: "./organization-payment-method.component.html",

View File

@@ -16,11 +16,11 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService } from "@bitwarden/components";
import { FreeTrial } from "../../core/types/free-trial";
import {
ChangePlanDialogResultType,
openChangePlanDialog,
} from "../organizations/change-plan-dialog.component";
import { FreeTrial } from "../types/free-trial";
@Injectable({ providedIn: "root" })
export class TrialFlowService {

View File

@@ -24,8 +24,8 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SyncService } from "@bitwarden/common/platform/sync";
import { DialogService, ToastService } from "@bitwarden/components";
import { FreeTrial } from "../../core/types/free-trial";
import { TrialFlowService } from "../services/trial-flow.service";
import { FreeTrial } from "../types/free-trial";
import { AddCreditDialogResult, openAddCreditDialog } from "./add-credit-dialog.component";
import {

View File

@@ -25,13 +25,13 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { ToastService } from "@bitwarden/components";
import { AcceptOrganizationInviteService } from "../../../auth/organization-invite/accept-organization.service";
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";
export type InitiationPath =

View File

@@ -1,17 +1,4 @@
<app-vertical-stepper #stepper linear>
<app-vertical-step
label="{{ 'createAccount' | i18n | titlecase }}"
[editable]="false"
[subLabel]="subLabels.createAccount"
[addSubLabelSpacing]="true"
>
<app-register-form
[referenceDataValue]="referenceEventRequest"
[isInTrialFlow]="true"
(createdAccount)="accountCreated($event)"
>
</app-register-form>
</app-vertical-step>
<app-vertical-step
label="{{ 'organizationInformation' | i18n | titlecase }}"
[subLabel]="subLabels.organizationInfo"

View File

@@ -1,17 +1,4 @@
<app-vertical-stepper #stepper linear>
<app-vertical-step
label="{{ 'createAccount' | i18n | titlecase }}"
[editable]="false"
[subLabel]="createAccountLabel"
[addSubLabelSpacing]="true"
>
<app-register-form
[referenceDataValue]="referenceEventRequest"
[isInTrialFlow]="true"
(createdAccount)="accountCreated($event)"
>
</app-register-form>
</app-vertical-step>
<app-vertical-step
label="{{ 'organizationInformation' | i18n | titlecase }}"
[subLabel]="subLabels.organizationInfo"

View File

@@ -19,7 +19,16 @@ import {
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
import { ValidOrgParams } from "../trial-initiation.component";
export enum ValidOrgParams {
families = "families",
enterprise = "enterprise",
teams = "teams",
teamsStarter = "teamsStarter",
individual = "individual",
premium = "premium",
free = "free",
}
const trialFlowOrgs = [
ValidOrgParams.teams,

View File

@@ -6,12 +6,11 @@ import { InputPasswordComponent } from "@bitwarden/auth/angular";
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 { SecretsManagerTrialFreeStepperComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component";
import { SecretsManagerTrialPaidStepperComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component";
import { SecretsManagerTrialComponent } from "../../auth/trial-initiation/secrets-manager/secrets-manager-trial.component";
import { TaxInfoComponent } from "../../billing";
import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component";
import { SecretsManagerTrialFreeStepperComponent } from "../../billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component";
import { SecretsManagerTrialPaidStepperComponent } from "../../billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component";
import { SecretsManagerTrialComponent } from "../../billing/trial-initiation/secrets-manager/secrets-manager-trial.component";
import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module";
import { SharedModule } from "../../shared";
@@ -39,7 +38,6 @@ import { TeamsContentComponent } from "./content/teams-content.component";
import { Teams1ContentComponent } from "./content/teams1-content.component";
import { Teams2ContentComponent } from "./content/teams2-content.component";
import { Teams3ContentComponent } from "./content/teams3-content.component";
import { TrialInitiationComponent } from "./trial-initiation.component";
import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module";
@NgModule({
@@ -48,7 +46,6 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
CdkStepperModule,
VerticalStepperModule,
FormFieldModule,
RegisterFormModule,
OrganizationCreateModule,
EnvironmentSelectorModule,
TaxInfoComponent,
@@ -56,7 +53,6 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
InputPasswordComponent,
],
declarations: [
TrialInitiationComponent,
CompleteTrialInitiationComponent,
EnterpriseContentComponent,
TeamsContentComponent,
@@ -87,7 +83,7 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul
SecretsManagerTrialFreeStepperComponent,
SecretsManagerTrialPaidStepperComponent,
],
exports: [TrialInitiationComponent, CompleteTrialInitiationComponent],
exports: [CompleteTrialInitiationComponent],
providers: [TitleCasePipe],
})
export class TrialInitiationModule {}

Some files were not shown because too many files have changed in this diff Show More