1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 01:23:24 +00:00

Merge branch 'main' into tools/pm-18793/port-credential-generator-service-to-providers

This commit is contained in:
✨ Audrey ✨
2025-04-01 16:36:44 -04:00
544 changed files with 8197 additions and 6034 deletions

View File

@@ -5,6 +5,7 @@ import { firstValueFrom, Subject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -48,6 +49,7 @@ export class VaultFilterComponent
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
protected configService: ConfigService,
protected accountService: AccountService,
) {
super(
vaultFilterService,
@@ -58,6 +60,7 @@ export class VaultFilterComponent
billingApiService,
dialogService,
configService,
accountService,
);
}
@@ -86,8 +89,8 @@ export class VaultFilterComponent
const collapsedNodes = await firstValueFrom(this.vaultFilterService.collapsedFilterNodes$);
collapsedNodes.delete("AllCollections");
await this.vaultFilterService.setCollapsedFilterNodes(collapsedNodes);
const userId = await firstValueFrom(this.activeUserId$);
await this.vaultFilterService.setCollapsedFilterNodes(collapsedNodes, userId);
}
protected async addCollectionFilter(): Promise<VaultFilterSection> {

View File

@@ -54,7 +54,7 @@
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>
<div [class]="hideVaultFilters ? 'tw-w-4/5' : 'tw-w-3/4'">
<div [class]="hideVaultFilters ? 'tw-w-full' : 'tw-w-3/4'">
<bit-toggle-group
*ngIf="showAddAccessToggle && activeFilter.selectedCollectionNode"
[selected]="addAccessStatus$ | async"

View File

@@ -45,7 +45,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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";
@@ -196,7 +195,6 @@ export class VaultComponent implements OnInit, OnDestroy {
private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>();
protected addAccessStatus$ = new BehaviorSubject<AddAccessStatusType>(0);
private resellerManagedOrgAlert: boolean;
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
private readonly unpaidSubscriptionDialog$ = this.accountService.activeAccount$.pipe(
@@ -264,10 +262,6 @@ export class VaultComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
FeatureFlag.ResellerManagedOrgAlert,
);
this.trashCleanupWarning = this.i18nService.t(
this.platformUtilsService.isSelfHost()
? "trashCleanupWarningSelfHosted"
@@ -654,7 +648,7 @@ export class VaultComponent implements OnInit, OnDestroy {
);
this.resellerWarning$ = organization$.pipe(
filter((org) => org.isOwner && this.resellerManagedOrgAlert),
filter((org) => org.isOwner),
switchMap((org) =>
from(this.billingApiService.getOrganizationBillingMetadata(org.id)).pipe(
map((metadata) => ({ org, metadata })),

View File

@@ -59,7 +59,7 @@ export function isEnterpriseOrgGuard(showError: boolean = true): CanActivateFn {
content: { key: "onlyAvailableForEnterpriseOrganization" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
icon: "bwi-arrow-circle-up",
icon: "bwi-plus-circle",
});
if (upgradeConfirmed) {
await router.navigate(["organizations", org.id, "billing", "subscription"], {

View File

@@ -58,7 +58,7 @@ export function isPaidOrgGuard(): CanActivateFn {
content: { key: "upgradeOrganizationCloseSecurityGapsDesc" },
acceptButtonText: { key: "upgradeOrganization" },
type: "info",
icon: "bwi-arrow-circle-up",
icon: "bwi-plus-circle",
});
if (upgradeConfirmed) {
await router.navigate(["organizations", org.id, "billing", "subscription"], {

View File

@@ -64,7 +64,7 @@
</ng-container>
</bit-nav-group>
<bit-nav-item
icon="bwi-providers"
icon="bwi-msp"
[text]="'integrations' | i18n"
route="integrations"
*ngIf="integrationPageEnabled$ | async"

View File

@@ -118,7 +118,10 @@ export class OrganizationLayoutComponent implements OnInit {
),
);
this.hideNewOrgButton$ = this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg);
this.hideNewOrgButton$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
);
const provider$ = this.organization$.pipe(
switchMap((organization) => this.providerService.get$(organization.providerId)),

View File

@@ -111,7 +111,7 @@
<ng-container *ngIf="loaded && usePlaceHolderEvents">
<div
class="tw-relative tw--top-72 tw-bg-[#ffffff] tw-bg-opacity-90 tw-pb-5 tw-flex tw-items-center tw-justify-center"
class="tw-relative tw--top-72 tw-bg-[#ffffff] tw-bg-opacity-90 tw-pb-5 tw-flex tw-items-center tw-justify-center tw-h-[19rem]"
>
<div
class="tw-bg-[#ffffff] tw-max-w-xl tw-flex-col tw-justify-center tw-text-center tw-p-5 tw-px-10 tw-rounded tw-border-0 tw-border-b tw-border-secondary-300 tw-border-solid tw-mt-5"

View File

@@ -23,7 +23,7 @@
<input bitInput appAutofocus type="text" formControlName="name" />
<bit-hint>{{ "characterMaximum" | i18n: 100 }}</bit-hint>
</bit-form-field>
<bit-form-field>
<bit-form-field *ngIf="isExternalIdVisible$ | async">
<bit-label>{{ "externalId" | i18n }}</bit-label>
<input bitInput type="text" formControlName="externalId" />
<bit-hint>{{ "externalIdDesc" | i18n }}</bit-hint>

View File

@@ -29,7 +29,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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -215,6 +217,10 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.groupDetails$,
]).pipe(map(([allowAdminAccess, groupDetails]) => !allowAdminAccess && groupDetails != null));
protected isExternalIdVisible$ = this.configService
.getFeatureFlag$(FeatureFlag.SsoExternalIdVisibility)
.pipe(map((isEnabled) => !isEnabled || !!this.groupForm.get("externalId")?.value));
constructor(
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
@@ -231,6 +237,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private collectionAdminService: CollectionAdminService,
private toastService: ToastService,
private configService: ConfigService,
) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
}

View File

@@ -3,11 +3,13 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Subject, takeUntil } from "rxjs";
import { Subject, switchMap, takeUntil } from "rxjs";
import { PasswordStrengthV2Component } from "@bitwarden/angular/tools/password-strength/password-strength-v2.component";
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 { 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -81,12 +83,16 @@ export class ResetPasswordComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private formBuilder: FormBuilder,
private dialogRef: DialogRef<ResetPasswordDialogResult>,
private accountService: AccountService,
) {}
async ngOnInit() {
this.policyService
.masterPasswordPolicyOptions$()
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
takeUntil(this.destroy$),
)
.subscribe(
(enforcedPasswordPolicyOptions) =>
(this.enforcedPolicyOptions = enforcedPasswordPolicyOptions),

View File

@@ -43,6 +43,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -168,15 +169,18 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.canUseSecretsManager$ = organization$.pipe(map((org) => org.useSecretsManager));
const policies$ = organization$.pipe(
switchMap((organization) => {
const policies$ = combineLatest([
this.accountService.activeAccount$.pipe(getUserId),
organization$,
]).pipe(
switchMap(([userId, organization]) => {
if (organization.isProviderUser) {
return from(this.policyApiService.getPolicies(organization.id)).pipe(
map((response) => Policy.fromListResponse(response)),
);
}
return this.policyService.policies$;
return this.policyService.policies$(userId);
}),
);

View File

@@ -50,7 +50,7 @@ export class DeleteManagedMemberWarningService {
key: "deleteManagedUserWarningDesc",
},
type: "danger",
icon: "bwi-exclamation-circle",
icon: "bwi-exclamation-triangle",
acceptButtonText: { key: "continue" },
cancelButtonText: { key: "cancel" },
});

View File

@@ -2,7 +2,7 @@
{{ "keyConnectorPolicyRestriction" | i18n }}
</bit-callout>
<bit-callout type="success" [title]="'prerequisite' | i18n" icon="bwi-lightbulb">
<bit-callout type="info" [title]="'prerequisite' | i18n">
{{ "accountRecoverySingleOrgRequirementDesc" | i18n }}
</bit-callout>

View File

@@ -42,12 +42,14 @@
{{ "learnMoreAboutApi" | i18n }}
</a>
</p>
<button type="button" bitButton buttonType="secondary" (click)="viewApiKey()">
{{ "viewApiKey" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="rotateApiKey()">
{{ "rotateApiKey" | i18n }}
</button>
<div class="tw-flex tw-gap-2">
<button type="button" bitButton buttonType="secondary" (click)="viewApiKey()">
{{ "viewApiKey" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="rotateApiKey()">
{{ "rotateApiKey" | i18n }}
</button>
</div>
</ng-container>
<form
*ngIf="org && !loading"

View File

@@ -28,13 +28,13 @@
</p>
<app-user-verification formControlName="secret"> </app-user-verification>
</div>
<div bitDialogFooter>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="danger" [disabled]="!loaded">
{{ "deleteOrganization" | i18n }}
</button>
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</ng-container>
</bit-dialog>
</form>

View File

@@ -13,6 +13,8 @@ import {
Subject,
switchMap,
takeUntil,
tap,
filter,
} from "rxjs";
import { first } from "rxjs/operators";
@@ -189,10 +191,29 @@ export class CollectionDialogComponent implements OnInit, OnDestroy {
this.formGroup.updateValueAndValidity();
}
this.organizationSelected.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((_) => {
this.organizationSelected.markAsTouched();
this.formGroup.updateValueAndValidity();
});
this.organizationSelected.valueChanges
.pipe(
tap((_) => {
if (this.organizationSelected.errors?.cannotCreateCollections) {
this.buttonDisplayName = ButtonType.Upgrade;
} else {
this.buttonDisplayName = ButtonType.Save;
}
}),
filter(() => this.organizationSelected.errors?.cannotCreateCollections),
switchMap((value) => this.findOrganizationById(value)),
takeUntil(this.destroy$),
)
.subscribe((org) => {
this.orgExceedingCollectionLimit = org;
this.organizationSelected.markAsTouched();
this.formGroup.updateValueAndValidity();
});
}
async findOrganizationById(orgId: string): Promise<Organization | undefined> {
const organizations = await firstValueFrom(this.organizations$);
return organizations.find((org) => org.id === orgId);
}
async loadOrg(orgId: string) {

View File

@@ -4,3 +4,4 @@ export * from "./webauthn-login";
export * from "./set-password-jit";
export * from "./registration";
export * from "./two-factor-auth";
export * from "./link-sso.service";

View File

@@ -0,0 +1,154 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
} from "@bitwarden/generator-legacy";
import { LinkSsoService } from "./link-sso.service";
describe("LinkSsoService", () => {
let sut: LinkSsoService;
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
let mockApiService: MockProxy<ApiService>;
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
let mockEnvironmentService: MockProxy<EnvironmentService>;
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
const mockEnvironment$ = new BehaviorSubject<any>({
getIdentityUrl: jest.fn().mockReturnValue("https://identity.bitwarden.com"),
});
beforeEach(() => {
// Create mock implementations
mockSsoLoginService = mock<SsoLoginServiceAbstraction>();
mockApiService = mock<ApiService>();
mockCryptoFunctionService = mock<CryptoFunctionService>();
mockEnvironmentService = mock<EnvironmentService>();
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
mockPlatformUtilsService = mock<PlatformUtilsService>();
// Set up environment service to return our mock environment
mockEnvironmentService.environment$ = mockEnvironment$;
// Set up API service mocks
const mockResponse = { Token: "mockSsoToken" };
mockApiService.preValidateSso.mockResolvedValue(new SsoPreValidateResponse(mockResponse));
mockApiService.getSsoUserIdentifier.mockResolvedValue("mockUserIdentifier");
// Set up password generation service mock
mockPasswordGenerationService.generatePassword.mockImplementation(
async (options: PasswordGeneratorOptions) => {
return "mockGeneratedPassword";
},
);
// Set up crypto function service mock
mockCryptoFunctionService.hash.mockResolvedValue(new Uint8Array([1, 2, 3, 4]));
// Create the service under test with mock dependencies
sut = new LinkSsoService(
mockSsoLoginService,
mockApiService,
mockCryptoFunctionService,
mockEnvironmentService,
mockPasswordGenerationService,
mockPlatformUtilsService,
);
// Mock Utils.fromBufferToUrlB64
jest.spyOn(Utils, "fromBufferToUrlB64").mockReturnValue("mockCodeChallenge");
// Mock window.location
Object.defineProperty(window, "location", {
value: {
origin: "https://bitwarden.com",
},
writable: true,
});
});
afterEach(() => {
jest.clearAllMocks();
});
describe("linkSso", () => {
it("throws an error when identifier is null", async () => {
await expect(sut.linkSso(null as unknown as string)).rejects.toThrow(
"SSO identifier is required",
);
});
it("throws an error when identifier is empty", async () => {
await expect(sut.linkSso("")).rejects.toThrow("SSO identifier is required");
});
it("calls preValidateSso with the provided identifier", async () => {
await sut.linkSso("org123");
expect(mockApiService.preValidateSso).toHaveBeenCalledWith("org123");
});
it("generates a password for code verifier", async () => {
await sut.linkSso("org123");
expect(mockPasswordGenerationService.generatePassword).toHaveBeenCalledWith({
type: "password",
length: 64,
uppercase: true,
lowercase: true,
number: true,
special: false,
});
});
it("sets the code verifier in the ssoLoginService", async () => {
await sut.linkSso("org123");
expect(mockSsoLoginService.setCodeVerifier).toHaveBeenCalledWith("mockGeneratedPassword");
});
it("generates a state and sets it in the ssoLoginService", async () => {
await sut.linkSso("org123");
const expectedState =
"mockGeneratedPassword_returnUri='/settings/organizations'_identifier=org123";
expect(mockSsoLoginService.setSsoState).toHaveBeenCalledWith(expectedState);
});
it("gets the SSO user identifier from the API", async () => {
await sut.linkSso("org123");
expect(mockApiService.getSsoUserIdentifier).toHaveBeenCalled();
});
it("launches the authorize URL with the correct parameters", async () => {
await sut.linkSso("org123");
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
expect.stringContaining("https://identity.bitwarden.com/connect/authorize"),
{ sameWindow: true },
);
const launchUriArg = mockPlatformUtilsService.launchUri.mock.calls[0][0];
expect(launchUriArg).toContain("client_id=web");
expect(launchUriArg).toContain(
"redirect_uri=https%3A%2F%2Fbitwarden.com%2Fsso-connector.html",
);
expect(launchUriArg).toContain("response_type=code");
expect(launchUriArg).toContain("code_challenge=mockCodeChallenge");
expect(launchUriArg).toContain("ssoToken=mockSsoToken");
expect(launchUriArg).toContain("user_identifier=mockUserIdentifier");
});
});
});

View File

@@ -0,0 +1,91 @@
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
} from "@bitwarden/generator-legacy";
/**
* Provides a service for linking SSO.
*/
export class LinkSsoService {
constructor(
private ssoLoginService: SsoLoginServiceAbstraction,
private apiService: ApiService,
private cryptoFunctionService: CryptoFunctionService,
private environmentService: EnvironmentService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
) {}
/**
* Links SSO to an organization.
* Ported from the SsoComponent
* @param identifier The identifier of the organization to link to.
*/
async linkSso(identifier: string) {
if (identifier == null || identifier === "") {
throw new Error("SSO identifier is required");
}
const redirectUri = window.location.origin + "/sso-connector.html";
const clientId = "web";
const returnUri = "/settings/organizations";
const response = await this.apiService.preValidateSso(identifier);
const passwordOptions: PasswordGeneratorOptions = {
type: "password",
length: 64,
uppercase: true,
lowercase: true,
number: true,
special: false,
};
const codeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(codeVerifier, "sha256");
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
await this.ssoLoginService.setCodeVerifier(codeVerifier);
let state = await this.passwordGenerationService.generatePassword(passwordOptions);
state += `_returnUri='${returnUri}'`;
state += `_identifier=${identifier}`;
// Save state
await this.ssoLoginService.setSsoState(state);
const env = await firstValueFrom(this.environmentService.environment$);
let authorizeUrl =
env.getIdentityUrl() +
"/connect/authorize?" +
"client_id=" +
clientId +
"&redirect_uri=" +
encodeURIComponent(redirectUri) +
"&" +
"response_type=code&scope=api offline_access&" +
"state=" +
state +
"&code_challenge=" +
codeChallenge +
"&" +
"code_challenge_method=S256&response_mode=query&" +
"domain_hint=" +
encodeURIComponent(identifier) +
"&ssoToken=" +
encodeURIComponent(response.token);
const userIdentifier = await this.apiService.getSsoUserIdentifier();
authorizeUrl += `&user_identifier=${encodeURIComponent(userIdentifier)}`;
this.platformUtilsService.launchUri(authorizeUrl, { sameWindow: true });
}
}

View File

@@ -8,11 +8,15 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ResetPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/reset-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
// FIXME: remove `src` and fix import
@@ -38,6 +42,8 @@ describe("WebLoginComponentService", () => {
let passwordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
beforeEach(() => {
acceptOrganizationInviteService = mock<AcceptOrganizationInviteService>();
@@ -50,6 +56,7 @@ describe("WebLoginComponentService", () => {
passwordGenerationService = mock<PasswordGenerationServiceAbstraction>();
platformUtilsService = mock<PlatformUtilsService>();
ssoLoginService = mock<SsoLoginServiceAbstraction>();
accountService = mockAccountServiceWith(mockUserId);
TestBed.configureTestingModule({
providers: [
@@ -65,6 +72,7 @@ describe("WebLoginComponentService", () => {
{ provide: PasswordGenerationServiceAbstraction, useValue: passwordGenerationService },
{ provide: PlatformUtilsService, useValue: platformUtilsService },
{ provide: SsoLoginServiceAbstraction, useValue: ssoLoginService },
{ provide: AccountService, useValue: accountService },
],
});
service = TestBed.inject(WebLoginComponentService);

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, switchMap } from "rxjs";
import {
DefaultLoginComponentService,
@@ -12,7 +12,9 @@ import {
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -39,6 +41,7 @@ export class WebLoginComponentService
platformUtilsService: PlatformUtilsService,
ssoLoginService: SsoLoginServiceAbstraction,
private router: Router,
private accountService: AccountService,
) {
super(
cryptoFunctionService,
@@ -93,7 +96,10 @@ export class WebLoginComponentService
resetPasswordPolicy[1] && resetPasswordPolicy[0].autoEnrollEnabled;
const enforcedPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(policies),
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId)),
),
);
return {

View File

@@ -10,9 +10,12 @@ import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/mod
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
@@ -30,6 +33,8 @@ describe("WebRegistrationFinishService", () => {
let policyApiService: MockProxy<PolicyApiServiceAbstraction>;
let logService: MockProxy<LogService>;
let policyService: MockProxy<PolicyService>;
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
beforeEach(() => {
keyService = mock<KeyService>();
@@ -38,6 +43,7 @@ describe("WebRegistrationFinishService", () => {
policyApiService = mock<PolicyApiServiceAbstraction>();
logService = mock<LogService>();
policyService = mock<PolicyService>();
accountService = mockAccountServiceWith(mockUserId);
service = new WebRegistrationFinishService(
keyService,
@@ -46,6 +52,7 @@ describe("WebRegistrationFinishService", () => {
policyApiService,
logService,
policyService,
accountService,
);
});

View File

@@ -12,6 +12,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { RegisterFinishRequest } from "@bitwarden/common/auth/models/request/registration/register-finish.request";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
@@ -30,6 +31,7 @@ export class WebRegistrationFinishService
private policyApiService: PolicyApiServiceAbstraction,
private logService: LogService,
private policyService: PolicyService,
private accountService: AccountService,
) {
super(keyService, accountApiService);
}
@@ -68,7 +70,7 @@ export class WebRegistrationFinishService
}
const masterPasswordPolicyOpts: MasterPasswordPolicyOptions = await firstValueFrom(
this.policyService.masterPasswordPolicyOptions$(policies),
this.policyService.masterPasswordPolicyOptions$(null, policies),
);
return masterPasswordPolicyOpts;

View File

@@ -39,10 +39,12 @@
</div>
</ng-container>
<button type="submit" bitButton buttonType="primary" bitFormButton>
{{ (tokenSent ? "changeEmail" : "continue") | i18n }}
</button>
<button type="button" bitButton *ngIf="tokenSent" (click)="reset()">
{{ "cancel" | i18n }}
</button>
<div class="tw-flex tw-gap-2">
<button type="submit" bitButton buttonType="primary" bitFormButton>
{{ (tokenSent ? "changeEmail" : "continue") | i18n }}
</button>
<button type="button" bitButton *ngIf="tokenSent" (click)="reset()">
{{ "cancel" | i18n }}
</button>
</div>
</form>

View File

@@ -0,0 +1,197 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import mock, { MockProxy } from "jest-mock-extended/lib/Mock";
import { firstValueFrom, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { ChangeEmailComponent } from "@bitwarden/web-vault/app/auth/settings/account/change-email.component";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
describe("ChangeEmailComponent", () => {
let component: ChangeEmailComponent;
let fixture: ComponentFixture<ChangeEmailComponent>;
let apiService: MockProxy<ApiService>;
let accountService: FakeAccountService;
let keyService: MockProxy<KeyService>;
let kdfConfigService: MockProxy<KdfConfigService>;
beforeEach(async () => {
apiService = mock<ApiService>();
keyService = mock<KeyService>();
kdfConfigService = mock<KdfConfigService>();
accountService = mockAccountServiceWith("UserId" as UserId);
await TestBed.configureTestingModule({
declarations: [ChangeEmailComponent],
imports: [ReactiveFormsModule, SharedModule],
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: ApiService, useValue: apiService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: KeyService, useValue: keyService },
{ provide: MessagingService, useValue: mock<MessagingService>() },
{ provide: KdfConfigService, useValue: kdfConfigService },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: FormBuilder, useClass: FormBuilder },
],
}).compileComponents();
fixture = TestBed.createComponent(ChangeEmailComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("creates component", () => {
expect(component).toBeTruthy();
});
describe("ngOnInit", () => {
beforeEach(() => {
apiService.getTwoFactorProviders.mockResolvedValue({
data: [{ type: TwoFactorProviderType.Email, enabled: true } as TwoFactorProviderResponse],
} as ListResponse<TwoFactorProviderResponse>);
});
it("initializes userId", async () => {
await component.ngOnInit();
expect(component.userId).toBe("UserId");
});
it("errors if there is no active user", async () => {
// clear active account
await firstValueFrom(accountService.activeAccount$);
accountService.activeAccountSubject.next(null);
await expect(() => component.ngOnInit()).rejects.toThrow("Null or undefined account");
});
it("initializes showTwoFactorEmailWarning", async () => {
await component.ngOnInit();
expect(component.showTwoFactorEmailWarning).toBe(true);
});
});
describe("submit", () => {
beforeEach(() => {
component.formGroup.controls.step1.setValue({
masterPassword: "password",
newEmail: "test@example.com",
});
keyService.getOrDeriveMasterKey
.calledWith("password", "UserId")
.mockResolvedValue("getOrDeriveMasterKey" as any);
keyService.hashMasterKey
.calledWith("password", "getOrDeriveMasterKey" as any)
.mockResolvedValue("existingHash");
});
it("throws if userId is null on submit", async () => {
component.userId = undefined;
await expect(component.submit()).rejects.toThrow("Can't find user");
});
describe("step 1", () => {
it("does not submit if step 1 is invalid", async () => {
component.formGroup.controls.step1.setValue({
masterPassword: "",
newEmail: "",
});
await component.submit();
expect(apiService.postEmailToken).not.toHaveBeenCalled();
});
it("sends email token in step 1 if tokenSent is false", async () => {
await component.submit();
expect(apiService.postEmailToken).toHaveBeenCalledWith({
newEmail: "test@example.com",
masterPasswordHash: "existingHash",
});
// should activate step 2
expect(component.tokenSent).toBe(true);
expect(component.formGroup.controls.step1.disabled).toBe(true);
expect(component.formGroup.controls.token.enabled).toBe(true);
});
});
describe("step 2", () => {
beforeEach(() => {
component.tokenSent = true;
component.formGroup.controls.step1.disable();
component.formGroup.controls.token.enable();
component.formGroup.controls.token.setValue("token");
kdfConfigService.getKdfConfig$
.calledWith("UserId" as any)
.mockReturnValue(of("kdfConfig" as any));
keyService.userKey$.calledWith("UserId" as any).mockReturnValue(of("userKey" as any));
keyService.makeMasterKey
.calledWith("password", "test@example.com", "kdfConfig" as any)
.mockResolvedValue("newMasterKey" as any);
keyService.hashMasterKey
.calledWith("password", "newMasterKey" as any)
.mockResolvedValue("newMasterKeyHash");
// Important: make sure this is called with new master key, not existing
keyService.encryptUserKeyWithMasterKey
.calledWith("newMasterKey" as any, "userKey" as any)
.mockResolvedValue(["userKey" as any, { encryptedString: "newEncryptedUserKey" } as any]);
});
it("does not post email if token is missing on submit", async () => {
component.formGroup.controls.token.setValue("");
await component.submit();
expect(apiService.postEmail).not.toHaveBeenCalled();
});
it("throws if kdfConfig is missing on submit", async () => {
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
await expect(component.submit()).rejects.toThrow("Missing kdf config");
});
it("throws if userKey can't be found", async () => {
keyService.userKey$.mockReturnValue(of(null));
await expect(component.submit()).rejects.toThrow("Can't find UserKey");
});
it("throws if encryptedUserKey is missing", async () => {
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(["userKey" as any, null as any]);
await expect(component.submit()).rejects.toThrow("Missing Encrypted User Key");
});
it("submits if step 2 is valid", async () => {
await component.submit();
// validate that hashes are correct
expect(apiService.postEmail).toHaveBeenCalledWith({
masterPasswordHash: "existingHash",
newMasterPasswordHash: "newMasterKeyHash",
token: "token",
newEmail: "test@example.com",
key: "newEncryptedUserKey",
});
});
});
});
});

View File

@@ -1,17 +1,16 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { EmailTokenRequest } from "@bitwarden/common/auth/models/request/email-token.request";
import { EmailRequest } from "@bitwarden/common/auth/models/request/email.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
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 { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
@@ -22,8 +21,9 @@ import { KdfConfigService, KeyService } from "@bitwarden/key-management";
export class ChangeEmailComponent implements OnInit {
tokenSent = false;
showTwoFactorEmailWarning = false;
userId: UserId | undefined;
protected formGroup = this.formBuilder.group({
formGroup = this.formBuilder.group({
step1: this.formBuilder.group({
masterPassword: ["", [Validators.required]],
newEmail: ["", [Validators.required, Validators.email]],
@@ -32,26 +32,30 @@ export class ChangeEmailComponent implements OnInit {
});
constructor(
private accountService: AccountService,
private apiService: ApiService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private keyService: KeyService,
private messagingService: MessagingService,
private logService: LogService,
private stateService: StateService,
private formBuilder: FormBuilder,
private kdfConfigService: KdfConfigService,
private toastService: ToastService,
) {}
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const twoFactorProviders = await this.apiService.getTwoFactorProviders();
this.showTwoFactorEmailWarning = twoFactorProviders.data.some(
(p) => p.type === TwoFactorProviderType.Email && p.enabled,
);
}
protected submit = async () => {
submit = async () => {
if (this.userId == null) {
throw new Error("Can't find user");
}
// This form has multiple steps, so we need to mark all the groups as touched.
this.formGroup.controls.step1.markAllAsTouched();
@@ -65,37 +69,54 @@ export class ChangeEmailComponent implements OnInit {
}
const step1Value = this.formGroup.controls.step1.value;
const newEmail = step1Value.newEmail.trim().toLowerCase();
const newEmail = step1Value.newEmail?.trim().toLowerCase();
const masterPassword = step1Value.masterPassword;
if (newEmail == null || masterPassword == null) {
throw new Error("Missing email or password");
}
const existingHash = await this.keyService.hashMasterKey(
masterPassword,
await this.keyService.getOrDeriveMasterKey(masterPassword, this.userId),
);
if (!this.tokenSent) {
const request = new EmailTokenRequest();
request.newEmail = newEmail;
request.masterPasswordHash = await this.keyService.hashMasterKey(
step1Value.masterPassword,
await this.keyService.getOrDeriveMasterKey(step1Value.masterPassword),
);
request.masterPasswordHash = existingHash;
await this.apiService.postEmailToken(request);
this.activateStep2();
} else {
const token = this.formGroup.value.token;
if (token == null) {
throw new Error("Missing token");
}
const request = new EmailRequest();
request.token = this.formGroup.value.token;
request.token = token;
request.newEmail = newEmail;
request.masterPasswordHash = await this.keyService.hashMasterKey(
step1Value.masterPassword,
await this.keyService.getOrDeriveMasterKey(step1Value.masterPassword),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const newMasterKey = await this.keyService.makeMasterKey(
step1Value.masterPassword,
newEmail,
kdfConfig,
);
request.masterPasswordHash = existingHash;
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
if (kdfConfig == null) {
throw new Error("Missing kdf config");
}
const newMasterKey = await this.keyService.makeMasterKey(masterPassword, newEmail, kdfConfig);
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
step1Value.masterPassword,
masterPassword,
newMasterKey,
);
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey);
request.key = newUserKey[1].encryptedString;
const userKey = await firstValueFrom(this.keyService.userKey$(this.userId));
if (userKey == null) {
throw new Error("Can't find UserKey");
}
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey, userKey);
const encryptedUserKey = newUserKey[1]?.encryptedString;
if (encryptedUserKey == null) {
throw new Error("Missing Encrypted User Key");
}
request.key = encryptedUserKey;
await this.apiService.postEmail(request);
this.reset();

View File

@@ -121,7 +121,7 @@
[(ngModel)]="masterPasswordHint"
/>
</div>
<button type="submit" buttonType="primary" bitButton [loading]="form.loading">
<button type="submit" buttonType="primary" bitButton [loading]="loading">
{{ "changeMasterPassword" | i18n }}
</button>
</form>

View File

@@ -12,7 +12,9 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { PasswordRequest } from "@bitwarden/common/auth/models/request/password.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
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";
@@ -35,11 +37,13 @@ export class ChangePasswordComponent
extends BaseChangePasswordComponent
implements OnInit, OnDestroy
{
loading = false;
rotateUserKey = false;
currentMasterPassword: string;
masterPasswordHint: string;
checkForBreaches = true;
characterMinimumMessage = "";
userkeyRotationV2 = false;
constructor(
i18nService: I18nService,
@@ -56,9 +60,10 @@ export class ChangePasswordComponent
private userVerificationService: UserVerificationService,
private keyRotationService: UserKeyRotationService,
kdfConfigService: KdfConfigService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
toastService: ToastService,
private configService: ConfigService,
) {
super(
i18nService,
@@ -75,6 +80,8 @@ export class ChangePasswordComponent
}
async ngOnInit() {
this.userkeyRotationV2 = await this.configService.getFeatureFlag(FeatureFlag.UserKeyRotationV2);
if (!(await this.userVerificationService.hasMasterPassword())) {
// 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
@@ -137,6 +144,130 @@ export class ChangePasswordComponent
}
async submit() {
if (this.userkeyRotationV2) {
this.loading = true;
await this.submitNew();
this.loading = false;
} else {
await this.submitOld();
}
}
async submitNew() {
if (this.currentMasterPassword == null || this.currentMasterPassword === "") {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("masterPasswordRequired"),
});
return;
}
if (
this.masterPasswordHint != null &&
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase()
) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("hintEqualsPassword"),
});
return;
}
this.leakedPassword = false;
if (this.checkForBreaches) {
this.leakedPassword = (await this.auditService.passwordLeaked(this.masterPassword)) > 0;
}
if (!(await this.strongPassword())) {
return;
}
try {
if (this.rotateUserKey) {
await this.syncService.fullSync(true);
const user = await firstValueFrom(this.accountService.activeAccount$);
await this.keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
this.currentMasterPassword,
this.masterPassword,
user,
this.masterPasswordHint,
);
} else {
await this.updatePassword(this.masterPassword);
}
} catch (e) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e.message,
});
}
}
// todo: move this to a service
// https://bitwarden.atlassian.net/browse/PM-17108
private async updatePassword(newMasterPassword: string) {
const currentMasterPassword = this.currentMasterPassword;
const { userId, email } = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => ({ userId: a?.id, email: a?.email }))),
);
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
const currentMasterKey = await this.keyService.makeMasterKey(
currentMasterPassword,
email,
kdfConfig,
);
const decryptedUserKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
currentMasterKey,
userId,
);
if (decryptedUserKey == null) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("invalidMasterPassword"),
});
return;
}
const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig);
const newMasterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
newMasterKey,
decryptedUserKey,
);
const request = new PasswordRequest();
request.masterPasswordHash = await this.keyService.hashMasterKey(
this.currentMasterPassword,
currentMasterKey,
);
request.masterPasswordHint = this.masterPasswordHint;
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
newMasterPassword,
newMasterKey,
);
request.key = newMasterKeyEncryptedUserKey[1].encryptedString;
try {
await this.masterPasswordApiService.postPassword(request);
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("masterPasswordChanged"),
message: this.i18nService.t("masterPasswordChangedDesc"),
});
this.messagingService.send("logout");
} catch {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("errorOccurred"),
});
}
}
async submitOld() {
if (
this.masterPasswordHint != null &&
this.masterPasswordHint.toLowerCase() === this.masterPassword.toLowerCase()
@@ -242,6 +373,6 @@ export class ChangePasswordComponent
private async updateKey() {
const user = await firstValueFrom(this.accountService.activeAccount$);
await this.keyRotationService.rotateUserKeyAndEncryptedData(this.masterPassword, user);
await this.keyRotationService.rotateUserKeyAndEncryptedDataLegacy(this.masterPassword, user);
}
}

View File

@@ -25,13 +25,13 @@
<bit-label> {{ "dontAskFingerprintAgain" | i18n }}</bit-label>
</bit-form-control>
</div>
<div bitDialogFooter>
<ng-container bitDialogFooter>
<button type="submit" buttonType="primary" bitButton bitFormButton>
<span>{{ "confirm" | i18n }}</span>
</button>
<button bitButton bitFormButton buttonType="secondary" type="button" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</ng-container>
</bit-dialog>
</form>

View File

@@ -42,13 +42,13 @@
</div>
</div>
</div>
<div bitDialogFooter>
<ng-container bitDialogFooter>
<button type="submit" bitButton bitFormButton buttonType="primary">
{{ "save" | i18n }}
</button>
<button bitButton bitFormButton type="button" buttonType="secondary" bitDialogClose>
{{ "cancel" | i18n }}
</button>
</div>
</ng-container>
</bit-dialog>
</form>

View File

@@ -3,11 +3,12 @@
import { DialogConfig, DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, OnDestroy, OnInit, Inject, Input } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { takeUntil } from "rxjs";
import { switchMap, takeUntil } from "rxjs";
import { ChangePasswordComponent } from "@bitwarden/angular/auth/components/change-password.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -79,9 +80,12 @@ export class EmergencyAccessTakeoverComponent
const policies = await this.emergencyAccessService.getGrantorPolicies(
this.params.emergencyAccessId,
);
this.policyService
.masterPasswordPolicyOptions$(policies)
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)),
takeUntil(this.destroy$),
)
.subscribe((enforcedPolicyOptions) => (this.enforcedPolicyOptions = enforcedPolicyOptions));
}

View File

@@ -17,8 +17,9 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService } from "@bitwarden/components";
import { ChangeLoginPasswordService, TaskService } from "@bitwarden/vault";
import { ChangeLoginPasswordService } from "@bitwarden/vault";
import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component";
@@ -55,6 +56,7 @@ describe("EmergencyViewDialogComponent", () => {
{ provide: DialogRef, useValue: { close } },
{ provide: DIALOG_DATA, useValue: { cipher: mockCipher } },
{ provide: AccountService, useValue: accountService },
{ provide: TaskService, useValue: mock<TaskService>() },
],
})
.overrideComponent(EmergencyViewDialogComponent, {
@@ -71,10 +73,6 @@ describe("EmergencyViewDialogComponent", () => {
},
add: {
providers: [
{
provide: TaskService,
useValue: mock<TaskService>(),
},
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{
provide: ChangeLoginPasswordService,

View File

@@ -13,8 +13,6 @@ import {
ChangeLoginPasswordService,
CipherViewComponent,
DefaultChangeLoginPasswordService,
DefaultTaskService,
TaskService,
} from "@bitwarden/vault";
import { WebViewPasswordHistoryService } from "../../../../vault/services/web-view-password-history.service";
@@ -39,7 +37,6 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService {
providers: [
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: PremiumUpgradePromptService, useClass: PremiumUpgradePromptNoop },
{ provide: TaskService, useClass: DefaultTaskService },
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
],
})

View File

@@ -30,13 +30,13 @@
</p>
</bit-callout>
</div>
<div bitDialogFooter>
<ng-container bitDialogFooter>
<button type="submit" buttonType="primary" *ngIf="!clientSecret" bitButton bitFormButton>
<span>{{ (data.isRotation ? "rotateApiKey" : "viewApiKey") | i18n }}</span>
</button>
<button type="button" bitButton bitFormButton bitDialogClose>
{{ "close" | i18n }}
</button>
</div>
</ng-container>
</bit-dialog>
</form>

View File

@@ -8,11 +8,11 @@
<p bitTypography="body1">
{{ "userApiKeyDesc" | i18n }}
</p>
<button type="button" bitButton buttonType="secondary" (click)="viewUserApiKey()">
{{ "viewApiKey" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="rotateUserApiKey()">
{{ "rotateApiKey" | i18n }}
</button>
<ng-template #viewUserApiKeyTemplate></ng-template>
<ng-template #rotateUserApiKeyTemplate></ng-template>
<div class="tw-flex tw-gap-2">
<button type="button" bitButton buttonType="secondary" (click)="viewUserApiKey()">
{{ "viewApiKey" | i18n }}
</button>
<button type="button" bitButton buttonType="secondary" (click)="rotateUserApiKey()">
{{ "rotateApiKey" | i18n }}
</button>
</div>

View File

@@ -1,6 +1,6 @@
// 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 { Component, OnInit } from "@angular/core";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -15,11 +15,6 @@ import { ApiKeyComponent } from "./api-key.component";
templateUrl: "security-keys.component.html",
})
export class SecurityKeysComponent implements OnInit {
@ViewChild("viewUserApiKeyTemplate", { read: ViewContainerRef, static: true })
viewUserApiKeyModalRef: ViewContainerRef;
@ViewChild("rotateUserApiKeyTemplate", { read: ViewContainerRef, static: true })
rotateUserApiKeyModalRef: ViewContainerRef;
showChangeKdf = true;
constructor(

View File

@@ -25,6 +25,7 @@ import { TwoFactorDuoResponse } from "@bitwarden/common/auth/models/response/two
import { TwoFactorEmailResponse } from "@bitwarden/common/auth/models/response/two-factor-email.response";
import { TwoFactorWebAuthnResponse } from "@bitwarden/common/auth/models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response/two-factor-yubi-key.response";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
@@ -109,13 +110,17 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy {
this.providers.sort((a: any, b: any) => a.sort - b.sort);
this.policyService
.policyAppliesToActiveUser$(PolicyType.TwoFactorAuthentication)
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.TwoFactorAuthentication, userId),
),
takeUntil(this.destroy$),
)
.subscribe((policyAppliesToActiveUser) => {
this.twoFactorAuthPolicyAppliesToActiveUser = policyAppliesToActiveUser;
});
await this.load();
}

View File

@@ -1,10 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, HostBinding, OnDestroy, OnInit } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { Subject, switchMap, takeUntil } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DialogService } from "@bitwarden/components";
import { WebauthnLoginAdminService } from "../../core";
@@ -35,6 +37,7 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy {
private webauthnService: WebauthnLoginAdminService,
private dialogService: DialogService,
private policyService: PolicyService,
private accountService: AccountService,
) {}
@HostBinding("attr.aria-busy")
@@ -57,9 +60,14 @@ export class WebauthnLoginSettingsComponent implements OnInit, OnDestroy {
requireSsoPolicyEnabled = false;
ngOnInit(): void {
this.policyService
.policyAppliesToActiveUser$(PolicyType.RequireSso)
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.RequireSso, userId),
),
takeUntil(this.destroy$),
)
.subscribe((enabled) => {
this.requireSsoPolicyEnabled = enabled;
});

View File

@@ -1,45 +0,0 @@
<bit-dialog dialogSize="large">
<span bitDialogTitle>
{{ "twoStepOptions" | i18n }}
</span>
<ng-container bitDialogContent>
<div *ngFor="let p of providers" class="tw-m-2">
<div class="tw-flex tw-items-center tw-justify-center tw-gap-4">
<div class="tw-flex tw-items-center tw-justify-center tw-min-w-[120px]">
<auth-two-factor-icon [provider]="p.type" />
</div>
<div class="tw-flex-1">
<h3 bitTypography="h3">{{ p.name }}</h3>
<p bitTypography="body1">{{ p.description }}</p>
</div>
<div class="tw-min-w-20">
<button bitButton type="button" buttonType="secondary" (click)="choose(p)">
{{ "select" | i18n }}
</button>
</div>
</div>
<hr />
</div>
<div class="tw-m-2" (click)="recover()">
<div class="tw-flex tw-items-center tw-justify-center tw-gap-4">
<div class="tw-flex tw-items-center tw-justify-center tw-min-w-[120px]">
<auth-two-factor-icon provider="rc" />
</div>
<div class="tw-flex-1">
<h3 bitTypography="h3">{{ "recoveryCodeTitle" | i18n }}</h3>
<p bitTypography="body1">{{ "recoveryCodeDesc" | i18n }}</p>
</div>
<div class="tw-min-w-20">
<button bitButton type="button" buttonType="secondary" (click)="recover()">
{{ "select" | i18n }}
</button>
</div>
</div>
</div>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton type="button" buttonType="secondary" bitDialogClose>
{{ "close" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -1,52 +0,0 @@
import { DialogRef } from "@angular/cdk/dialog";
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { TwoFactorOptionsComponentV1 as BaseTwoFactorOptionsComponentV1 } from "@bitwarden/angular/auth/components/two-factor-options-v1.component";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService } from "@bitwarden/components";
export enum TwoFactorOptionsDialogResult {
Provider = "Provider selected",
Recover = "Recover selected",
}
export type TwoFactorOptionsDialogResultType = {
result: TwoFactorOptionsDialogResult;
type: TwoFactorProviderType;
};
@Component({
selector: "app-two-factor-options",
templateUrl: "two-factor-options-v1.component.html",
})
export class TwoFactorOptionsComponentV1 extends BaseTwoFactorOptionsComponentV1 {
constructor(
twoFactorService: TwoFactorService,
router: Router,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
environmentService: EnvironmentService,
private dialogRef: DialogRef,
) {
super(twoFactorService, router, i18nService, platformUtilsService, window, environmentService);
}
async choose(p: any) {
await super.choose(p);
this.dialogRef.close({ result: TwoFactorOptionsDialogResult.Provider, type: p.type });
}
async recover() {
await super.recover();
this.dialogRef.close({ result: TwoFactorOptionsDialogResult.Recover });
}
static open(dialogService: DialogService) {
return dialogService.open<TwoFactorOptionsDialogResultType>(TwoFactorOptionsComponentV1);
}
}

View File

@@ -1,106 +0,0 @@
<form [bitSubmit]="submitForm" [formGroup]="formGroup" autocomplete="off">
<div class="tw-min-w-96">
<ng-container
*ngIf="
selectedProviderType === providerType.Email ||
selectedProviderType === providerType.Authenticator
"
>
<p bitTypography="body1" *ngIf="selectedProviderType === providerType.Authenticator">
{{ "enterVerificationCodeApp" | i18n }}
</p>
<p bitTypography="body1" *ngIf="selectedProviderType === providerType.Email">
{{ "enterVerificationCodeEmail" | i18n: twoFactorEmail }}
</p>
<bit-form-field>
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input bitInput type="text" formControlName="token" appAutofocus appInputVerbatim />
<bit-hint *ngIf="selectedProviderType === providerType.Email">
<a
bitLink
href="#"
appStopClick
(click)="sendEmail(true)"
*ngIf="selectedProviderType === providerType.Email"
>
{{ "sendVerificationCodeEmailAgain" | i18n }}
</a></bit-hint
>
</bit-form-field>
</ng-container>
<ng-container *ngIf="selectedProviderType === providerType.Yubikey">
<p bitTypography="body1" class="tw-text-center">{{ "insertYubiKey" | i18n }}</p>
<picture>
<source srcset="../../images/yubikey.avif" type="image/avif" />
<source srcset="../../images/yubikey.webp" type="image/webp" />
<img src="../../images/yubikey.jpg" class="tw-rounded img-fluid tw-mb-3" alt="" />
</picture>
<bit-form-field>
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input type="password" bitInput formControlName="token" appAutofocus appInputVerbatim />
</bit-form-field>
</ng-container>
<ng-container *ngIf="selectedProviderType === providerType.WebAuthn">
<div id="web-authn-frame" class="tw-mb-3">
<iframe id="webauthn_iframe" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
</ng-container>
<!-- Duo -->
<ng-container *ngIf="isDuoProvider">
<p
bitTypography="body1"
*ngIf="selectedProviderType === providerType.OrganizationDuo"
class="tw-mb-0"
>
{{ "duoRequiredByOrgForAccount" | i18n }}
</p>
<p bitTypography="body1">{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}</p>
</ng-container>
<bit-form-control *ngIf="selectedProviderType != null">
<bit-label>{{ "rememberMe" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="remember" />
</bit-form-control>
<ng-container *ngIf="selectedProviderType == null">
<p bitTypography="body1">{{ "noTwoStepProviders" | i18n }}</p>
<p bitTypography="body1">{{ "noTwoStepProviders2" | i18n }}</p>
</ng-container>
<hr />
<div [hidden]="!showCaptcha()">
<iframe id="hcaptcha_iframe" height="80" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<!-- Buttons -->
<div class="tw-flex tw-flex-col tw-space-y-2.5 tw-mb-3">
<button
type="submit"
buttonType="primary"
bitButton
bitFormButton
*ngIf="
selectedProviderType != null &&
!isDuoProvider &&
selectedProviderType !== providerType.WebAuthn
"
>
<span> <i class="bwi bwi-sign-in" aria-hidden="true"></i> {{ "continue" | i18n }} </span>
</button>
<button
(click)="launchDuoFrameless()"
type="button"
buttonType="primary"
bitButton
bitFormButton
*ngIf="isDuoProvider"
>
<span> {{ "launchDuo" | i18n }} </span>
</button>
<a routerLink="/login" bitButton buttonType="secondary">
{{ "cancel" | i18n }}
</a>
</div>
<div class="text-center">
<a bitLink href="#" appStopClick (click)="anotherMethod()">{{
"useAnotherTwoStepMethod" | i18n
}}</a>
</div>
</div>
</form>

View File

@@ -1,164 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Inject, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { Subject, takeUntil, lastValueFrom } from "rxjs";
import { TwoFactorComponentV1 as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor-v1.component";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
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 { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { DialogService, ToastService } from "@bitwarden/components";
import {
TwoFactorOptionsDialogResult,
TwoFactorOptionsComponentV1,
TwoFactorOptionsDialogResultType,
} from "./two-factor-options-v1.component";
@Component({
selector: "app-two-factor",
templateUrl: "two-factor-v1.component.html",
})
export class TwoFactorComponentV1 extends BaseTwoFactorComponent implements OnInit, OnDestroy {
@ViewChild("twoFactorOptions", { read: ViewContainerRef, static: true })
twoFactorOptionsModal: ViewContainerRef;
formGroup = this.formBuilder.group({
token: [
"",
{
validators: [Validators.required],
updateOn: "submit",
},
],
remember: [false],
});
private destroy$ = new Subject<void>();
constructor(
loginStrategyService: LoginStrategyServiceAbstraction,
router: Router,
i18nService: I18nService,
apiService: ApiService,
platformUtilsService: PlatformUtilsService,
stateService: StateService,
environmentService: EnvironmentService,
private dialogService: DialogService,
route: ActivatedRoute,
logService: LogService,
twoFactorService: TwoFactorService,
appIdService: AppIdService,
loginEmailService: LoginEmailServiceAbstraction,
userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
ssoLoginService: SsoLoginServiceAbstraction,
configService: ConfigService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
toastService: ToastService,
private formBuilder: FormBuilder,
@Inject(WINDOW) protected win: Window,
) {
super(
loginStrategyService,
router,
i18nService,
apiService,
platformUtilsService,
win,
environmentService,
stateService,
route,
logService,
twoFactorService,
appIdService,
loginEmailService,
userDecryptionOptionsService,
ssoLoginService,
configService,
masterPasswordService,
accountService,
toastService,
);
this.onSuccessfulLoginNavigate = this.goAfterLogIn;
}
async ngOnInit() {
await super.ngOnInit();
this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => {
this.token = value.token;
this.remember = value.remember;
});
}
submitForm = async () => {
await this.submit();
};
async anotherMethod() {
const dialogRef = TwoFactorOptionsComponentV1.open(this.dialogService);
const response: TwoFactorOptionsDialogResultType = await lastValueFrom(dialogRef.closed);
if (response.result === TwoFactorOptionsDialogResult.Provider) {
this.selectedProviderType = response.type;
await this.init();
}
}
protected override handleMigrateEncryptionKey(result: AuthResult): boolean {
if (!result.requiresEncryptionKeyMigration) {
return false;
}
// 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(["migrate-legacy-encryption"]);
return true;
}
goAfterLogIn = async () => {
this.loginEmailService.clearValues();
// 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([this.successRoute], {
queryParams: {
identifier: this.orgIdentifier,
},
});
};
private duoResultChannel: BroadcastChannel;
protected override setupDuoResultListener() {
if (!this.duoResultChannel) {
this.duoResultChannel = new BroadcastChannel("duoResult");
this.duoResultChannel.addEventListener("message", this.handleDuoResultMessage);
}
}
private handleDuoResultMessage = async (msg: { data: { code: string; state: string } }) => {
this.token = msg.data.code + "|" + msg.data.state;
await this.submit();
};
async ngOnDestroy() {
super.ngOnDestroy();
if (this.duoResultChannel) {
// clean up duo listener if it was initialized.
this.duoResultChannel.removeEventListener("message", this.handleDuoResultMessage);
this.duoResultChannel.close();
}
}
}

View File

@@ -55,7 +55,7 @@
</p>
<a
bitButton
href="{{ premiumURL }}}"
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"

View File

@@ -13,7 +13,7 @@ import {
} from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { Subject, firstValueFrom, map, takeUntil } from "rxjs";
import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -28,6 +28,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
BillingApiServiceAbstraction,
BillingInformation,
@@ -265,9 +266,14 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
this.upgradeFlowPrefillForm();
this.policyService
.policyAppliesToActiveUser$(PolicyType.SingleOrg)
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
),
takeUntil(this.destroy$),
)
.subscribe((policyAppliesToActiveUser) => {
this.singleOrgPolicyAppliesToActiveUser = policyAppliesToActiveUser;
});
@@ -727,6 +733,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
submit = async () => {
if (this.taxComponent !== undefined && !this.taxComponent.validate()) {
this.taxComponent.markAllAsTouched();
return;
}
@@ -958,9 +965,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
return ["bwi-billing"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:

View File

@@ -12,7 +12,7 @@ import {
import { FormBuilder, Validators } from "@angular/forms";
import { Router } from "@angular/router";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { debounceTime, map } from "rxjs/operators";
import { debounceTime, map, switchMap } from "rxjs/operators";
import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -31,6 +31,7 @@ import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/mode
import { ProviderOrganizationCreateRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-organization-create.request";
import { ProviderResponse } from "@bitwarden/common/admin-console/models/response/provider/provider.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { PaymentMethodType, PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
@@ -240,9 +241,14 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
this.formGroup.controls.billingEmail.addValidators(Validators.required);
}
this.policyService
.policyAppliesToActiveUser$(PolicyType.SingleOrg)
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
),
takeUntil(this.destroy$),
)
.subscribe((policyAppliesToActiveUser) => {
this.singleOrgPolicyAppliesToActiveUser = policyAppliesToActiveUser;
});

View File

@@ -222,9 +222,8 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
return ["bwi-billing"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:

View File

@@ -96,7 +96,7 @@ export class FreeFamiliesPolicyService {
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.getAll$(PolicyType.FreeFamiliesSponsorshipPolicy, userId),
this.policyService.policiesByType$(PolicyType.FreeFamiliesSponsorshipPolicy, userId),
),
map((policies) => ({
isFreeFamilyPolicyEnabled: policies.some(

View File

@@ -161,6 +161,7 @@ export class StripeService {
},
},
classes: {
base: "tw-stripe-form-control",
focus: "is-focused",
empty: "is-empty",
invalid: "is-invalid",
@@ -168,7 +169,6 @@ export class StripeService {
};
options.style.base.fontWeight = "500";
options.classes.base = "v2";
// Remove the placeholder for number and CVC fields
if (["cardNumber", "cardCvc"].includes(element)) {

View File

@@ -11,8 +11,6 @@ import { BillingSourceResponse } from "@bitwarden/common/billing/models/response
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
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 { DialogService } from "@bitwarden/components";
@@ -24,15 +22,12 @@ import { FreeTrial } from "../types/free-trial";
@Injectable({ providedIn: "root" })
export class TrialFlowService {
private resellerManagedOrgAlert: boolean;
constructor(
private i18nService: I18nService,
protected dialogService: DialogService,
private router: Router,
protected billingApiService: BillingApiServiceAbstraction,
private organizationApiService: OrganizationApiServiceAbstraction,
private configService: ConfigService,
) {}
checkForOrgsWithUpcomingPaymentIssues(
organization: Organization,
@@ -98,10 +93,6 @@ export class TrialFlowService {
isCanceled: boolean,
isUnpaid: boolean,
): Promise<boolean> {
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
FeatureFlag.ResellerManagedOrgAlert,
);
if (!org?.isOwner && !org.providerId) {
await this.dialogService.openSimpleDialog({
title: this.i18nService.t("suspendedOrganizationTitle", org?.name),
@@ -113,7 +104,7 @@ export class TrialFlowService {
return false;
}
if (org.providerId && this.resellerManagedOrgAlert) {
if (org.providerId) {
await this.dialogService.openSimpleDialog({
title: this.i18nService.t("suspendedOrganizationTitle", org.name),
content: { key: "suspendedManagedOrgMessage", placeholders: [org.providerName] },
@@ -134,7 +125,7 @@ export class TrialFlowService {
});
}
if (org.isOwner && isCanceled && this.resellerManagedOrgAlert) {
if (org.isOwner && isCanceled) {
await this.changePlan(org);
}
}

View File

@@ -89,7 +89,7 @@ export class SponsoredFamiliesComponent implements OnInit, OnDestroy {
this.availableSponsorshipOrgs$ = combineLatest([
this.organizationService.organizations$(userId),
this.policyService.getAll$(PolicyType.FreeFamiliesSponsorshipPolicy, userId),
this.policyService.policiesByType$(PolicyType.FreeFamiliesSponsorshipPolicy, userId),
]).pipe(
map(([organizations, policies]) =>
organizations

View File

@@ -10,7 +10,6 @@ import { PolicyType } 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";
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 { DialogService, ToastService } from "@bitwarden/components";
@@ -36,7 +35,6 @@ export class SponsoringOrgRowComponent implements OnInit {
private logService: LogService,
private dialogService: DialogService,
private toastService: ToastService,
private configService: ConfigService,
private policyService: PolicyService,
private accountService: AccountService,
) {}
@@ -54,7 +52,7 @@ export class SponsoringOrgRowComponent implements OnInit {
this.isFreeFamilyPolicyEnabled$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.getAll$(PolicyType.FreeFamiliesSponsorshipPolicy, userId),
this.policyService.policiesByType$(PolicyType.FreeFamiliesSponsorshipPolicy, userId),
),
map(
(policies) =>

View File

@@ -1,4 +1,4 @@
<bit-dialog dialogSize="large" [title]="dialogHeader">
<bit-dialog dialogSize="large" [title]="dialogHeader" [loading]="loading">
<ng-container bitDialogContent>
<app-payment
[showAccountCredit]="false"

View File

@@ -47,6 +47,8 @@ export class AdjustPaymentDialogComponent implements OnInit {
protected productTier?: ProductTierType;
protected providerId?: string;
protected loading = true;
protected taxInformation: TaxInformation;
constructor(
@@ -72,16 +74,26 @@ export class AdjustPaymentDialogComponent implements OnInit {
.getTaxInfo(this.organizationId)
.then((response: TaxInfoResponse) => {
this.taxInformation = TaxInformation.from(response);
this.toggleBankAccount();
})
.catch(() => {
this.taxInformation = new TaxInformation();
})
.finally(() => {
this.loading = false;
});
} else if (this.providerId) {
this.billingApiService
.getProviderTaxInformation(this.providerId)
.then((response) => (this.taxInformation = TaxInformation.from(response)))
.then((response) => {
this.taxInformation = TaxInformation.from(response);
this.toggleBankAccount();
})
.catch(() => {
this.taxInformation = new TaxInformation();
})
.finally(() => {
this.loading = false;
});
} else {
this.apiService
@@ -91,21 +103,28 @@ export class AdjustPaymentDialogComponent implements OnInit {
})
.catch(() => {
this.taxInformation = new TaxInformation();
})
.finally(() => {
this.loading = false;
});
}
}
taxInformationChanged(event: TaxInformation) {
this.taxInformation = event;
if (event.country === "US") {
this.paymentComponent.showBankAccount = !!this.organizationId;
this.toggleBankAccount();
}
toggleBankAccount = () => {
if (this.taxInformation.country === "US") {
this.paymentComponent.showBankAccount = !!this.organizationId || !!this.providerId;
} else {
this.paymentComponent.showBankAccount = false;
if (this.paymentComponent.selected === PaymentMethodType.BankAccount) {
this.paymentComponent.select(PaymentMethodType.Card);
}
}
}
};
submit = async (): Promise<void> => {
if (!this.taxInfoComponent.validate()) {

View File

@@ -15,7 +15,7 @@
class="tw-mr-2"
appA11yTitle="{{ 'downloadInvoice' | i18n }}"
>
<i class="bwi bwi-file-pdf" aria-hidden="true"></i
<i class="bwi bwi-file-text" aria-hidden="true"></i
></a>
<a
bitLink
@@ -34,7 +34,7 @@
{{ "paid" | i18n }}
</span>
<span *ngIf="!i.paid">
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n }}
</span>
</td>
@@ -59,7 +59,7 @@
class="tw-mr-2"
appA11yTitle="{{ 'downloadInvoice' | i18n }}"
>
<i class="bwi bwi-file-pdf" aria-hidden="true"></i
<i class="bwi bwi-file-text" aria-hidden="true"></i
></a>
<a
bitLink
@@ -78,7 +78,7 @@
{{ "paid" | i18n }}
</span>
<span *ngIf="!i.paid">
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n }}
</span>
</td>

View File

@@ -31,7 +31,7 @@ export class BillingHistoryComponent {
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
case PaymentMethodType.WireTransfer:
return ["bwi-bank"];
return ["bwi-billing"];
case PaymentMethodType.BitPay:
return ["bwi-bitcoin text-warning"];
case PaymentMethodType.PayPal:

View File

@@ -237,9 +237,8 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
case PaymentMethodType.Card:
return ["bwi-credit-card"];
case PaymentMethodType.BankAccount:
return ["bwi-bank"];
case PaymentMethodType.Check:
return ["bwi-money"];
return ["bwi-billing"];
case PaymentMethodType.PayPal:
return ["bwi-paypal text-primary"];
default:

View File

@@ -13,7 +13,7 @@
*ngIf="showBankAccount"
>
<bit-label>
<i class="bwi bwi-fw bwi-bank" aria-hidden="true"></i>
<i class="bwi bwi-fw bwi-billing" aria-hidden="true"></i>
{{ "bankAccount" | i18n }}
</bit-label>
</bit-radio-button>
@@ -46,7 +46,7 @@
<app-payment-label for="stripe-card-number" required>
{{ "number" | i18n }}
</app-payment-label>
<div id="stripe-card-number" class="form-control stripe-form-control"></div>
<div id="stripe-card-number" class="tw-stripe-form-control"></div>
</div>
<div class="tw-col-span-1 tw-flex tw-items-end">
<img
@@ -59,7 +59,7 @@
<app-payment-label for="stripe-card-expiry" required>
{{ "expiration" | i18n }}
</app-payment-label>
<div id="stripe-card-expiry" class="form-control stripe-form-control"></div>
<div id="stripe-card-expiry" class="tw-stripe-form-control"></div>
</div>
<div class="tw-col-span-1">
<app-payment-label for="stripe-card-cvc" required>
@@ -74,7 +74,7 @@
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</app-payment-label>
<div id="stripe-card-cvc" class="form-control stripe-form-control"></div>
<div id="stripe-card-cvc" class="tw-stripe-form-control"></div>
</div>
</div>
</ng-container>

View File

@@ -4,7 +4,7 @@ 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 { firstValueFrom, Subject, takeUntil } from "rxjs";
import { firstValueFrom, Subject, switchMap, takeUntil } from "rxjs";
import { PasswordInputResult, RegistrationFinishService } from "@bitwarden/auth/angular";
import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common";
@@ -12,6 +12,8 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import {
OrganizationBillingServiceAbstraction as OrganizationBillingService,
OrganizationInformation,
@@ -106,6 +108,7 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
private validationService: ValidationService,
private loginStrategyService: LoginStrategyServiceAbstraction,
private configService: ConfigService,
private accountService: AccountService,
) {}
async ngOnInit(): Promise<void> {
@@ -173,9 +176,12 @@ export class CompleteTrialInitiationComponent implements OnInit, OnDestroy {
}
if (policies !== null) {
this.policyService
.masterPasswordPolicyOptions$(policies)
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => this.policyService.masterPasswordPolicyOptions$(userId, policies)),
takeUntil(this.destroy$),
)
.subscribe((enforcedPasswordPolicyOptions) => {
this.enforcedPolicyOptions = enforcedPasswordPolicyOptions;
});

View File

@@ -116,6 +116,7 @@ import {
WebLoginDecryptionOptionsService,
WebTwoFactorAuthComponentService,
WebTwoFactorAuthDuoComponentService,
LinkSsoService,
} from "../auth";
import { WebSsoComponentService } from "../auth/core/services/login/web-sso-component.service";
import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service";
@@ -256,6 +257,7 @@ const safeProviders: SafeProvider[] = [
PolicyApiServiceAbstraction,
LogService,
PolicyService,
AccountService,
],
}),
safeProvider({
@@ -311,6 +313,7 @@ const safeProviders: SafeProvider[] = [
PlatformUtilsService,
SsoLoginServiceAbstraction,
Router,
AccountService,
],
}),
safeProvider({
@@ -343,6 +346,18 @@ const safeProviders: SafeProvider[] = [
useClass: WebSsoComponentService,
deps: [I18nServiceAbstraction],
}),
safeProvider({
provide: LinkSsoService,
useClass: LinkSsoService,
deps: [
SsoLoginServiceAbstraction,
ApiService,
CryptoFunctionService,
EnvironmentService,
PasswordGenerationServiceAbstraction,
PlatformUtilsService,
],
}),
safeProvider({
provide: TwoFactorAuthDuoComponentService,
useClass: WebTwoFactorAuthDuoComponentService,

View File

@@ -1,10 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Injectable } from "@angular/core";
import { switchMap } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DeviceType, EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EventResponse } from "@bitwarden/common/models/response/event.response";
@@ -19,10 +22,16 @@ export class EventService {
private i18nService: I18nService,
policyService: PolicyService,
private configService: ConfigService,
private accountService: AccountService,
) {
policyService.policies$.subscribe((policies) => {
this.policies = policies;
});
accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) => policyService.policies$(userId)),
)
.subscribe((policies) => {
this.policies = policies;
});
}
getDefaultDateFilters() {
@@ -490,45 +499,45 @@ export class EventService {
switch (ev.deviceType) {
case DeviceType.Android:
return ["bwi-android", this.i18nService.t("mobile") + " - Android"];
return ["bwi-mobile", this.i18nService.t("mobile") + " - Android"];
case DeviceType.iOS:
return ["bwi-apple", this.i18nService.t("mobile") + " - iOS"];
return ["bwi-mobile", this.i18nService.t("mobile") + " - iOS"];
case DeviceType.UWP:
return ["bwi-windows", this.i18nService.t("mobile") + " - Windows"];
return ["bwi-mobile", this.i18nService.t("mobile") + " - Windows"];
case DeviceType.ChromeExtension:
return ["bwi-chrome", this.i18nService.t("extension") + " - Chrome"];
return ["bwi-puzzle", this.i18nService.t("extension") + " - Chrome"];
case DeviceType.FirefoxExtension:
return ["bwi-firefox", this.i18nService.t("extension") + " - Firefox"];
return ["bwi-puzzle", this.i18nService.t("extension") + " - Firefox"];
case DeviceType.OperaExtension:
return ["bwi-opera", this.i18nService.t("extension") + " - Opera"];
return ["bwi-puzzle", this.i18nService.t("extension") + " - Opera"];
case DeviceType.EdgeExtension:
return ["bwi-edge", this.i18nService.t("extension") + " - Edge"];
return ["bwi-puzzle", this.i18nService.t("extension") + " - Edge"];
case DeviceType.VivaldiExtension:
return ["bwi-puzzle", this.i18nService.t("extension") + " - Vivaldi"];
case DeviceType.SafariExtension:
return ["bwi-safari", this.i18nService.t("extension") + " - Safari"];
return ["bwi-puzzle", this.i18nService.t("extension") + " - Safari"];
case DeviceType.WindowsDesktop:
return ["bwi-windows", this.i18nService.t("desktop") + " - Windows"];
return ["bwi-desktop", this.i18nService.t("desktop") + " - Windows"];
case DeviceType.MacOsDesktop:
return ["bwi-apple", this.i18nService.t("desktop") + " - macOS"];
return ["bwi-desktop", this.i18nService.t("desktop") + " - macOS"];
case DeviceType.LinuxDesktop:
return ["bwi-linux", this.i18nService.t("desktop") + " - Linux"];
return ["bwi-desktop", this.i18nService.t("desktop") + " - Linux"];
case DeviceType.ChromeBrowser:
return ["bwi-globe", this.i18nService.t("webVault") + " - Chrome"];
return ["bwi-browser", this.i18nService.t("webVault") + " - Chrome"];
case DeviceType.FirefoxBrowser:
return ["bwi-globe", this.i18nService.t("webVault") + " - Firefox"];
return ["bwi-browser", this.i18nService.t("webVault") + " - Firefox"];
case DeviceType.OperaBrowser:
return ["bwi-globe", this.i18nService.t("webVault") + " - Opera"];
return ["bwi-browser", this.i18nService.t("webVault") + " - Opera"];
case DeviceType.SafariBrowser:
return ["bwi-globe", this.i18nService.t("webVault") + " - Safari"];
return ["bwi-browser", this.i18nService.t("webVault") + " - Safari"];
case DeviceType.VivaldiBrowser:
return ["bwi-globe", this.i18nService.t("webVault") + " - Vivaldi"];
return ["bwi-browser", this.i18nService.t("webVault") + " - Vivaldi"];
case DeviceType.EdgeBrowser:
return ["bwi-globe", this.i18nService.t("webVault") + " - Edge"];
return ["bwi-browser", this.i18nService.t("webVault") + " - Edge"];
case DeviceType.IEBrowser:
return ["bwi-globe", this.i18nService.t("webVault") + " - IE"];
return ["bwi-browser", this.i18nService.t("webVault") + " - IE"];
case DeviceType.Server:
return ["bwi-server", this.i18nService.t("server")];
return ["bwi-user-monitor", this.i18nService.t("server")];
case DeviceType.WindowsCLI:
return ["bwi-cli", this.i18nService.t("cli") + " - Windows"];
case DeviceType.MacOsCLI:
@@ -537,7 +546,7 @@ export class EventService {
return ["bwi-cli", this.i18nService.t("cli") + " - Linux"];
case DeviceType.UnknownBrowser:
return [
"bwi-globe",
"bwi-browser",
this.i18nService.t("webVault") + " - " + this.i18nService.t("unknown"),
];
default:

View File

@@ -0,0 +1,10 @@
export class AccountKeysRequest {
// Other keys encrypted by the userkey
userKeyEncryptedAccountPrivateKey: string;
accountPublicKey: string;
constructor(userKeyEncryptedAccountPrivateKey: string, accountPublicKey: string) {
this.userKeyEncryptedAccountPrivateKey = userKeyEncryptedAccountPrivateKey;
this.accountPublicKey = accountPublicKey;
}
}

View File

@@ -0,0 +1,35 @@
import { Argon2KdfConfig, KdfConfig, KdfType } from "@bitwarden/key-management";
export class MasterPasswordUnlockDataRequest {
kdfType: KdfType = KdfType.PBKDF2_SHA256;
kdfIterations: number = 0;
kdfMemory?: number;
kdfParallelism?: number;
email: string;
masterKeyAuthenticationHash: string;
masterKeyEncryptedUserKey: string;
masterPasswordHint?: string;
constructor(
kdfConfig: KdfConfig,
email: string,
masterKeyAuthenticationHash: string,
masterKeyEncryptedUserKey: string,
masterPasswordHash?: string,
) {
this.kdfType = kdfConfig.kdfType;
this.kdfIterations = kdfConfig.iterations;
if (kdfConfig.kdfType === KdfType.Argon2id) {
this.kdfMemory = (kdfConfig as Argon2KdfConfig).memory;
this.kdfParallelism = (kdfConfig as Argon2KdfConfig).parallelism;
}
this.email = email;
this.masterKeyAuthenticationHash = masterKeyAuthenticationHash;
this.masterKeyEncryptedUserKey = masterKeyEncryptedUserKey;
this.masterPasswordHint = masterPasswordHash;
}
}

View File

@@ -0,0 +1,29 @@
import { AccountKeysRequest } from "./account-keys.request";
import { UnlockDataRequest } from "./unlock-data.request";
import { UserDataRequest as AccountDataRequest } from "./userdata.request";
export class RotateUserAccountKeysRequest {
constructor(
accountUnlockData: UnlockDataRequest,
accountKeys: AccountKeysRequest,
accountData: AccountDataRequest,
oldMasterKeyAuthenticationHash: string,
) {
this.accountUnlockData = accountUnlockData;
this.accountKeys = accountKeys;
this.accountData = accountData;
this.oldMasterKeyAuthenticationHash = oldMasterKeyAuthenticationHash;
}
// Authentication for the request
oldMasterKeyAuthenticationHash: string;
// All methods to get to the userkey
accountUnlockData: UnlockDataRequest;
// Other keys encrypted by the userkey
accountKeys: AccountKeysRequest;
// User vault data encrypted by the userkey
accountData: AccountDataRequest;
}

View File

@@ -0,0 +1,30 @@
import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common";
import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
import { EmergencyAccessWithIdRequest } from "../../../auth/emergency-access/request/emergency-access-update.request";
import { MasterPasswordUnlockDataRequest } from "./master-password-unlock-data.request";
export class UnlockDataRequest {
// All methods to get to the userkey
masterPasswordUnlockData: MasterPasswordUnlockDataRequest;
emergencyAccessUnlockData: EmergencyAccessWithIdRequest[];
organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[];
passkeyUnlockData: WebauthnRotateCredentialRequest[];
deviceKeyUnlockData: DeviceKeysUpdateRequest[];
constructor(
masterPasswordUnlockData: MasterPasswordUnlockDataRequest,
emergencyAccessUnlockData: EmergencyAccessWithIdRequest[],
organizationAccountRecoveryUnlockData: OrganizationUserResetPasswordWithIdRequest[],
passkeyUnlockData: WebauthnRotateCredentialRequest[],
deviceTrustUnlockData: DeviceKeysUpdateRequest[],
) {
this.masterPasswordUnlockData = masterPasswordUnlockData;
this.emergencyAccessUnlockData = emergencyAccessUnlockData;
this.organizationAccountRecoveryUnlockData = organizationAccountRecoveryUnlockData;
this.passkeyUnlockData = passkeyUnlockData;
this.deviceKeyUnlockData = deviceTrustUnlockData;
}
}

View File

@@ -0,0 +1,19 @@
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
export class UserDataRequest {
ciphers: CipherWithIdRequest[];
folders: FolderWithIdRequest[];
sends: SendWithIdRequest[];
constructor(
ciphers: CipherWithIdRequest[],
folders: FolderWithIdRequest[],
sends: SendWithIdRequest[],
) {
this.ciphers = ciphers;
this.folders = folders;
this.sends = sends;
}
}

View File

@@ -2,6 +2,7 @@ import { inject, Injectable } from "@angular/core";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request";
import { UpdateKeyRequest } from "./request/update-key.request";
@Injectable()
@@ -11,4 +12,14 @@ export class UserKeyRotationApiService {
postUserKeyUpdate(request: UpdateKeyRequest): Promise<any> {
return this.apiService.send("POST", "/accounts/key", request, true, false);
}
postUserKeyUpdateV2(request: RotateUserAccountKeysRequest): Promise<any> {
return this.apiService.send(
"POST",
"/accounts/key-management/rotate-user-account-keys",
request,
true,
false,
);
}
}

View File

@@ -6,21 +6,25 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
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 { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, UserPrivateKey } from "@bitwarden/common/types/key";
import { UserKey, UserPrivateKey, UserPublicKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherWithIdRequest } from "@bitwarden/common/vault/models/request/cipher-with-id.request";
import { FolderWithIdRequest } from "@bitwarden/common/vault/models/request/folder-with-id.request";
import { KeyService } from "@bitwarden/key-management";
import { ToastService } from "@bitwarden/components";
import { DEFAULT_KDF_CONFIG, KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth/core";
@@ -47,6 +51,9 @@ describe("KeyRotationService", () => {
let mockSyncService: MockProxy<SyncService>;
let mockWebauthnLoginAdminService: MockProxy<WebauthnLoginAdminService>;
let mockLogService: MockProxy<LogService>;
let mockVaultTimeoutService: MockProxy<VaultTimeoutService>;
let mockToastService: MockProxy<ToastService>;
let mockI18nService: MockProxy<I18nService>;
const mockUser = {
id: "mockUserId" as UserId,
@@ -70,6 +77,9 @@ describe("KeyRotationService", () => {
mockSyncService = mock<SyncService>();
mockWebauthnLoginAdminService = mock<WebauthnLoginAdminService>();
mockLogService = mock<LogService>();
mockVaultTimeoutService = mock<VaultTimeoutService>();
mockToastService = mock<ToastService>();
mockI18nService = mock<I18nService>();
keyRotationService = new UserKeyRotationService(
mockUserVerificationService,
@@ -85,6 +95,9 @@ describe("KeyRotationService", () => {
mockSyncService,
mockWebauthnLoginAdminService,
mockLogService,
mockVaultTimeoutService,
mockToastService,
mockI18nService,
);
});
@@ -94,6 +107,7 @@ describe("KeyRotationService", () => {
describe("rotateUserKeyAndEncryptedData", () => {
let privateKey: BehaviorSubject<UserPrivateKey | null>;
let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>;
beforeEach(() => {
mockKeyService.makeUserKey.mockResolvedValue([
@@ -112,6 +126,8 @@ describe("KeyRotationService", () => {
// Mock user verification
mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({
masterKey: "mockMasterKey" as any,
kdfConfig: DEFAULT_KDF_CONFIG,
email: "mockEmail",
policyOptions: null,
});
@@ -122,6 +138,12 @@ describe("KeyRotationService", () => {
privateKey = new BehaviorSubject("mockPrivateKey" as any);
mockKeyService.userPrivateKeyWithLegacySupport$.mockReturnValue(privateKey);
keyPair = new BehaviorSubject({
privateKey: "mockPrivateKey",
publicKey: "mockPublicKey",
} as any);
mockKeyService.userEncryptionKeyPair$.mockReturnValue(keyPair);
// Mock ciphers
const mockCiphers = [createMockCipher("1", "Cipher 1"), createMockCipher("2", "Cipher 2")];
mockCipherService.getRotatedData.mockResolvedValue(mockCiphers);
@@ -147,8 +169,8 @@ describe("KeyRotationService", () => {
mockWebauthnLoginAdminService.getRotatedData.mockResolvedValue(webauthn);
});
it("rotates the user key and encrypted data", async () => {
await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser);
it("rotates the user key and encrypted data legacy", async () => {
await keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser);
expect(mockApiService.postUserKeyUpdate).toHaveBeenCalled();
const arg = mockApiService.postUserKeyUpdate.mock.calls[0][0];
@@ -162,9 +184,47 @@ describe("KeyRotationService", () => {
expect(arg.webauthnKeys.length).toBe(2);
});
it("rotates the user key and encrypted data", async () => {
await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockNewMasterPassword",
mockUser,
);
expect(mockApiService.postUserKeyUpdateV2).toHaveBeenCalled();
const arg = mockApiService.postUserKeyUpdateV2.mock.calls[0][0];
expect(arg.oldMasterKeyAuthenticationHash).toBe("mockMasterPasswordHash");
expect(arg.accountUnlockData.masterPasswordUnlockData.email).toBe("mockEmail");
expect(arg.accountUnlockData.masterPasswordUnlockData.kdfType).toBe(
DEFAULT_KDF_CONFIG.kdfType,
);
expect(arg.accountUnlockData.masterPasswordUnlockData.kdfIterations).toBe(
DEFAULT_KDF_CONFIG.iterations,
);
expect(arg.accountKeys.accountPublicKey).toBe(Utils.fromUtf8ToB64("mockPublicKey"));
expect(arg.accountKeys.userKeyEncryptedAccountPrivateKey).toBe("mockEncryptedData");
expect(arg.accountData.ciphers.length).toBe(2);
expect(arg.accountData.folders.length).toBe(2);
expect(arg.accountData.sends.length).toBe(2);
});
it("legacy throws if master password provided is falsey", async () => {
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("", mockUser),
).rejects.toThrow();
});
it("throws if master password provided is falsey", async () => {
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData("", "", mockUser),
).rejects.toThrow();
});
it("legacy throws if user key creation fails", async () => {
mockKeyService.makeUserKey.mockResolvedValueOnce([null, null]);
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser),
).rejects.toThrow();
});
@@ -175,15 +235,41 @@ describe("KeyRotationService", () => {
]);
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockMasterPassword1",
mockUser,
),
).rejects.toThrow();
});
it("legacy throws if no private key is found", async () => {
privateKey.next(null);
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser),
).rejects.toThrow();
});
it("throws if no private key is found", async () => {
privateKey.next(null);
keyPair.next(null);
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockMasterPassword1",
mockUser,
),
).rejects.toThrow();
});
it("legacy throws if master password is incorrect", async () => {
mockUserVerificationService.verifyUserByMasterPassword.mockRejectedValueOnce(
new Error("Invalid master password"),
);
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser),
).rejects.toThrow();
});
@@ -193,15 +279,31 @@ describe("KeyRotationService", () => {
);
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockMasterPassword1",
mockUser,
),
).rejects.toThrow();
});
it("legacy throws if server rotation fails", async () => {
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));
await expect(
keyRotationService.rotateUserKeyAndEncryptedDataLegacy("mockMasterPassword", mockUser),
).rejects.toThrow();
});
it("throws if server rotation fails", async () => {
mockApiService.postUserKeyUpdate.mockRejectedValueOnce(new Error("mockError"));
mockApiService.postUserKeyUpdateV2.mockRejectedValueOnce(new Error("mockError"));
await expect(
keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword", mockUser),
keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData(
"mockMasterPassword",
"mockMasterPassword1",
mockUser,
),
).rejects.toThrow();
});
});

View File

@@ -7,7 +7,11 @@ import { VerificationType } from "@bitwarden/common/auth/enums/verification-type
import { MasterPasswordVerification } from "@bitwarden/common/auth/types/verification";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
@@ -15,13 +19,19 @@ import { UserKey } from "@bitwarden/common/types/key";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { WebauthnLoginAdminService } from "../../auth/core";
import { EmergencyAccessService } from "../../auth/emergency-access";
import { AccountKeysRequest } from "./request/account-keys.request";
import { MasterPasswordUnlockDataRequest } from "./request/master-password-unlock-data.request";
import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request";
import { UnlockDataRequest } from "./request/unlock-data.request";
import { UpdateKeyRequest } from "./request/update-key.request";
import { UserDataRequest } from "./request/userdata.request";
import { UserKeyRotationApiService } from "./user-key-rotation-api.service";
@Injectable()
@@ -40,14 +50,180 @@ export class UserKeyRotationService {
private syncService: SyncService,
private webauthnLoginAdminService: WebauthnLoginAdminService,
private logService: LogService,
private vaultTimeoutService: VaultTimeoutService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
/**
* Creates a new user key and re-encrypts all required data with the it.
* @param masterPassword current master password (used for validation)
* @param oldMasterPassword: The current master password
* @param newMasterPassword: The new master password
* @param user: The user account
* @param newMasterPasswordHint: The hint for the new master password
*/
async rotateUserKeyAndEncryptedData(masterPassword: string, user: Account): Promise<void> {
async rotateUserKeyMasterPasswordAndEncryptedData(
oldMasterPassword: string,
newMasterPassword: string,
user: Account,
newMasterPasswordHint?: string,
): Promise<void> {
this.logService.info("[Userkey rotation] Starting user key rotation...");
if (!newMasterPassword) {
this.logService.info("[Userkey rotation] Invalid master password provided. Aborting!");
throw new Error("Invalid master password");
}
if ((await this.syncService.getLastSync()) === null) {
this.logService.info("[Userkey rotation] Client was never synced. Aborting!");
throw new Error(
"The local vault is de-synced and the keys cannot be rotated. Please log out and log back in to resolve this issue.",
);
}
const {
masterKey: oldMasterKey,
email,
kdfConfig,
} = await this.userVerificationService.verifyUserByMasterPassword(
{
type: VerificationType.MasterPassword,
secret: oldMasterPassword,
},
user.id,
user.email,
);
const newMasterKey = await this.keyService.makeMasterKey(newMasterPassword, email, kdfConfig);
const [newUnencryptedUserKey, newMasterKeyEncryptedUserKey] =
await this.keyService.makeUserKey(newMasterKey);
if (!newUnencryptedUserKey || !newMasterKeyEncryptedUserKey) {
this.logService.info("[Userkey rotation] User key could not be created. Aborting!");
throw new Error("User key could not be created");
}
const newMasterKeyAuthenticationHash = await this.keyService.hashMasterKey(
newMasterPassword,
newMasterKey,
HashPurpose.ServerAuthorization,
);
const masterPasswordUnlockData = new MasterPasswordUnlockDataRequest(
kdfConfig,
email,
newMasterKeyAuthenticationHash,
newMasterKeyEncryptedUserKey.encryptedString!,
newMasterPasswordHint,
);
const keyPair = await firstValueFrom(this.keyService.userEncryptionKeyPair$(user.id));
if (keyPair == null) {
this.logService.info("[Userkey rotation] Key pair is null. Aborting!");
throw new Error("Key pair is null");
}
const { privateKey, publicKey } = keyPair;
const accountKeysRequest = new AccountKeysRequest(
(await this.encryptService.encrypt(privateKey, newUnencryptedUserKey)).encryptedString!,
Utils.fromBufferToB64(publicKey),
);
const originalUserKey = await firstValueFrom(this.keyService.userKey$(user.id));
if (originalUserKey == null) {
this.logService.info("[Userkey rotation] Userkey is null. Aborting!");
throw new Error("Userkey key is null");
}
const rotatedCiphers = await this.cipherService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
const rotatedFolders = await this.folderService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
const rotatedSends = await this.sendService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
if (rotatedCiphers == null || rotatedFolders == null || rotatedSends == null) {
this.logService.info("[Userkey rotation] ciphers, folders, or sends are null. Aborting!");
throw new Error("ciphers, folders, or sends are null");
}
const accountDataRequest = new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends);
const emergencyAccessUnlockData = await this.emergencyAccessService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
// Note: Reset password keys request model has user verification
// properties, but the rotation endpoint uses its own MP hash.
const organizationAccountRecoveryUnlockData = await this.resetPasswordService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
if (organizationAccountRecoveryUnlockData == null) {
this.logService.info(
"[Userkey rotation] Organization account recovery data is null. Aborting!",
);
throw new Error("Organization account recovery data is null");
}
const passkeyUnlockData = await this.webauthnLoginAdminService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
const trustedDeviceUnlockData = await this.deviceTrustService.getRotatedData(
originalUserKey,
newUnencryptedUserKey,
user.id,
);
const unlockDataRequest = new UnlockDataRequest(
masterPasswordUnlockData,
emergencyAccessUnlockData,
organizationAccountRecoveryUnlockData,
passkeyUnlockData,
trustedDeviceUnlockData,
);
const request = new RotateUserAccountKeysRequest(
unlockDataRequest,
accountKeysRequest,
accountDataRequest,
await this.keyService.hashMasterKey(oldMasterPassword, oldMasterKey),
);
this.logService.info("[Userkey rotation] Posting user key rotation request to server");
await this.apiService.postUserKeyUpdateV2(request);
this.logService.info("[Userkey rotation] Userkey rotation request posted to server");
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("rotationCompletedTitle"),
message: this.i18nService.t("rotationCompletedDesc"),
timeout: 15000,
});
// temporary until userkey can be better verified
await this.vaultTimeoutService.logOut();
}
/**
* Creates a new user key and re-encrypts all required data with the it.
* @param masterPassword current master password (used for validation)
* @deprecated
*/
async rotateUserKeyAndEncryptedDataLegacy(masterPassword: string, user: Account): Promise<void> {
this.logService.info("[Userkey rotation] Starting legacy user key rotation...");
if (!masterPassword) {
this.logService.info("[Userkey rotation] Invalid master password provided. Aborting!");
throw new Error("Invalid master password");
@@ -168,6 +344,7 @@ export class UserKeyRotationService {
this.logService.info("[Userkey rotation] Rotating device trust...");
await this.deviceTrustService.rotateDevicesTrust(user.id, newUserKey, masterPasswordHash);
this.logService.info("[Userkey rotation] Device trust rotation completed");
await this.vaultTimeoutService.logOut();
}
private async encryptPrivateKey(

View File

@@ -64,7 +64,7 @@ export class MigrateFromLegacyEncryptionComponent {
try {
await this.syncService.fullSync(false, true);
await this.keyRotationService.rotateUserKeyAndEncryptedData(masterPassword, activeUser);
await this.keyRotationService.rotateUserKeyAndEncryptedDataLegacy(masterPassword, activeUser);
this.toastService.showToast({
variant: "success",

View File

@@ -24,4 +24,8 @@ export class WebBiometricsService extends BiometricsService {
}
async setShouldAutopromptNow(value: boolean): Promise<void> {}
async canEnableBiometricUnlock(): Promise<boolean> {
return false;
}
}

View File

@@ -2,7 +2,6 @@ import { NgModule } from "@angular/core";
import { Route, RouterModule, Routes } from "@angular/router";
import { AuthenticationTimeoutComponent } from "@bitwarden/angular/auth/components/authentication-timeout.component";
import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap";
import {
authGuard,
lockGuard,
@@ -65,7 +64,6 @@ import { AccountComponent } from "./auth/settings/account/account.component";
import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component";
import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component";
import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module";
import { TwoFactorComponentV1 } from "./auth/two-factor-v1.component";
import { UpdatePasswordComponent } from "./auth/update-password.component";
import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component";
import { VerifyEmailTokenComponent } from "./auth/verify-email-token.component";
@@ -378,51 +376,28 @@ const routes: Routes = [
},
],
},
...unauthUiRefreshSwap(
TwoFactorComponentV1,
TwoFactorAuthComponent,
{
path: "2fa",
canActivate: [unauthGuardFn()],
children: [
{
path: "",
component: TwoFactorComponentV1,
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
data: {
pageTitle: {
key: "verifyYourIdentity",
},
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
path: "2fa",
canActivate: [unauthGuardFn(), TwoFactorAuthGuard],
children: [
{
path: "",
component: TwoFactorAuthComponent,
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
data: {
pageTitle: {
key: "verifyYourIdentity",
},
titleAreaMaxWidth: "md",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
),
{
path: "2fa",
component: TwoFactorAuthComponent,
canActivate: [unauthGuardFn(), TwoFactorAuthGuard],
children: [
{
path: "",
component: TwoFactorAuthComponent,
},
{
path: "",
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
],
data: {
pageTitle: {
key: "verifyYourIdentity",
},
titleAreaMaxWidth: "md",
} satisfies RouteDataProperties & AnonLayoutWrapperData,
},
{
path: "lock",
canActivate: [deepLinkGuard(), lockGuard()],

View File

@@ -59,22 +59,22 @@
<td bitCell>
<button
type="button"
bitIconButton="bwi-cog"
bitIconButton="bwi-ellipsis-v"
buttonType="secondary"
[bitMenuTriggerFor]="appListDropdown"
class="tw-border-0 tw-bg-transparent tw-p-0"
></button>
<bit-menu #appListDropdown>
<a href="#" bitMenuItem appStopClick (click)="toggleExcluded(d)" *ngIf="!d.excluded">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
<i class="bwi bwi-fw bwi-minus-circle" aria-hidden="true"></i>
{{ "exclude" | i18n }}
</a>
<a href="#" bitMenuItem appStopClick (click)="toggleExcluded(d)" *ngIf="d.excluded">
<i class="bwi bwi-fw bwi-plus" aria-hidden="true"></i>
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
{{ "include" | i18n }}
</a>
<a href="#" bitMenuItem appStopClick (click)="customize(d)">
<i class="bwi bwi-fw bwi-cut" aria-hidden="true"></i>
<i class="bwi bwi-fw bwi-pencil-square" aria-hidden="true"></i>
{{ "customize" | i18n }}
</a>
</bit-menu>

View File

@@ -2,11 +2,23 @@
// @ts-strict-ignore
import { Component, OnDestroy, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { concatMap, filter, firstValueFrom, map, Observable, Subject, takeUntil, tap } from "rxjs";
import {
concatMap,
filter,
firstValueFrom,
map,
Observable,
Subject,
switchMap,
takeUntil,
tap,
} from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import {
VaultTimeout,
@@ -100,7 +112,12 @@ export class PreferencesComponent implements OnInit, OnDestroy {
this.availableVaultTimeoutActions$ =
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$();
this.vaultTimeoutPolicyCallout = this.policyService.get$(PolicyType.MaximumVaultTimeout).pipe(
this.vaultTimeoutPolicyCallout = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId),
),
getFirstPolicy,
filter((policy) => policy != null),
map((policy) => {
let timeout;

View File

@@ -1,7 +1,6 @@
<details #details class="tw-rounded-sm tw-bg-background-alt tw-text-main" (toggle)="toggle()" open>
<summary class="tw-list-none tw-p-2 tw-px-4">
<div class="tw-flex tw-select-none tw-items-center tw-gap-4">
<i class="bwi bwi-dashboard tw-text-3xl tw-text-primary-600" aria-hidden="true"></i>
<div class="tw-text-lg">{{ title }}</div>
<bit-progress class="tw-flex-1" [showText]="false" [barWidth]="barWidth"></bit-progress>
<span *ngIf="tasks.length > 0; else spinner">

View File

@@ -43,8 +43,6 @@ import { TwoFactorSetupComponent } from "../auth/settings/two-factor/two-factor-
import { TwoFactorVerifyComponent } from "../auth/settings/two-factor/two-factor-verify.component";
import { UserVerificationModule } from "../auth/shared/components/user-verification";
import { SsoComponentV1 } from "../auth/sso-v1.component";
import { TwoFactorOptionsComponentV1 } from "../auth/two-factor-options-v1.component";
import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component";
import { UpdatePasswordComponent } from "../auth/update-password.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component";
@@ -148,12 +146,10 @@ import { SharedModule } from "./shared.module";
SetPasswordComponent,
SponsoredFamiliesComponent,
SponsoringOrgRowComponent,
TwoFactorComponentV1,
SsoComponentV1,
TwoFactorSetupAuthenticatorComponent,
TwoFactorSetupDuoComponent,
TwoFactorSetupEmailComponent,
TwoFactorOptionsComponentV1,
TwoFactorRecoveryComponent,
TwoFactorSetupComponent,
TwoFactorVerifyComponent,
@@ -210,12 +206,10 @@ import { SharedModule } from "./shared.module";
SetPasswordComponent,
SponsoredFamiliesComponent,
SponsoringOrgRowComponent,
TwoFactorComponentV1,
SsoComponentV1,
TwoFactorSetupAuthenticatorComponent,
TwoFactorSetupDuoComponent,
TwoFactorSetupEmailComponent,
TwoFactorOptionsComponentV1,
TwoFactorSetupComponent,
TwoFactorVerifyComponent,
TwoFactorSetupWebAuthnComponent,

View File

@@ -93,58 +93,60 @@
<ng-template body let-rows$>
<tr bitRow *ngFor="let s of rows$ | async">
<td bitCell (click)="editSend(s)" class="tw-cursor-pointer">
<span class="tw-mr-2" aria-hidden="true">
<i class="bwi bwi-fw bwi-lg bwi-file" *ngIf="s.type == sendType.File"></i>
<i class="bwi bwi-fw bwi-lg bwi-file-text" *ngIf="s.type == sendType.Text"></i>
</span>
<button type="button" bitLink>
{{ s.name }}
</button>
<ng-container *ngIf="s.disabled">
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'disabled' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "disabled" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.password">
<i
class="bwi bwi-key"
appStopProp
title="{{ 'password' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "password" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.maxAccessCountReached">
<i
class="bwi bwi-ban"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.expired">
<i
class="bwi bwi-clock"
appStopProp
title="{{ 'expired' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "expired" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.pendingDelete">
<i
class="bwi bwi-trash"
appStopProp
title="{{ 'pendingDeletion' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "pendingDeletion" | i18n }}</span>
</ng-container>
<div class="tw-flex tw-gap-2 tw-items-center">
<span aria-hidden="true">
<i class="bwi bwi-fw bwi-lg bwi-file" *ngIf="s.type == sendType.File"></i>
<i class="bwi bwi-fw bwi-lg bwi-file-text" *ngIf="s.type == sendType.Text"></i>
</span>
<button type="button" bitLink>
{{ s.name }}
</button>
<ng-container *ngIf="s.disabled">
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'disabled' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "disabled" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.password">
<i
class="bwi bwi-key"
appStopProp
title="{{ 'password' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "password" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.maxAccessCountReached">
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.expired">
<i
class="bwi bwi-clock"
appStopProp
title="{{ 'expired' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "expired" | i18n }}</span>
</ng-container>
<ng-container *ngIf="s.pendingDelete">
<i
class="bwi bwi-trash"
appStopProp
title="{{ 'pendingDeletion' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "pendingDeletion" | i18n }}</span>
</ng-container>
</div>
</td>
<td bitCell class="tw-text-muted" (click)="editSend(s)" class="tw-cursor-pointer">
<small bitTypography="body2" appStopProp>{{ s.deletionDate | date: "medium" }}</small>

View File

@@ -15,12 +15,14 @@ describe("BrowserExtensionPromptComponent", () => {
let fixture: ComponentFixture<BrowserExtensionPromptComponent>;
let component: BrowserExtensionPromptComponent;
const start = jest.fn();
const openExtension = jest.fn();
const pageState$ = new BehaviorSubject(BrowserPromptState.Loading);
const setAttribute = jest.fn();
const getAttribute = jest.fn().mockReturnValue("width=1010");
beforeEach(async () => {
start.mockClear();
openExtension.mockClear();
setAttribute.mockClear();
getAttribute.mockClear();
@@ -39,7 +41,7 @@ describe("BrowserExtensionPromptComponent", () => {
providers: [
{
provide: BrowserExtensionPromptService,
useValue: { start, pageState$ },
useValue: { start, openExtension, pageState$ },
},
{
provide: I18nService,
@@ -83,6 +85,15 @@ describe("BrowserExtensionPromptComponent", () => {
const errorText = fixture.debugElement.query(By.css("p")).nativeElement;
expect(errorText.textContent.trim()).toBe("openingExtensionError");
});
it("opens extension on button click", () => {
const button = fixture.debugElement.query(By.css("button")).nativeElement;
button.click();
expect(openExtension).toHaveBeenCalledTimes(1);
expect(openExtension).toHaveBeenCalledWith(true);
});
});
describe("success state", () => {

View File

@@ -61,6 +61,6 @@ export class BrowserExtensionPromptComponent implements OnInit, OnDestroy {
}
openExtension(): void {
this.browserExtensionPromptService.openExtension();
this.browserExtensionPromptService.openExtension(true);
}
}

View File

@@ -39,7 +39,6 @@ import {
} from "@bitwarden/components";
import {
ChangeLoginPasswordService,
CipherAttachmentsComponent,
CipherFormComponent,
CipherFormConfig,
CipherFormGenerationService,
@@ -47,8 +46,6 @@ import {
CipherViewComponent,
DecryptionFailureDialogComponent,
DefaultChangeLoginPasswordService,
DefaultTaskService,
TaskService,
} from "@bitwarden/vault";
import { SharedModule } from "../../../shared/shared.module";
@@ -132,17 +129,14 @@ export enum VaultItemDialogResult {
CommonModule,
SharedModule,
CipherFormModule,
CipherAttachmentsComponent,
AsyncActionsModule,
ItemModule,
DecryptionFailureDialogComponent,
],
providers: [
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService },
RoutedVaultFilterService,
{ provide: TaskService, useClass: DefaultTaskService },
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
],
})

View File

@@ -130,7 +130,7 @@
target="_blank"
rel="noreferrer"
>
<i class="bwi bwi-fw bwi-share-square" aria-hidden="true"></i>
<i class="bwi bwi-fw bwi-external-link" aria-hidden="true"></i>
{{ "launch" | i18n }}
</a>
</ng-container>

View File

@@ -138,6 +138,7 @@ export class VaultItemsComponent {
return canRestore$;
}),
map((canRestore) => canRestore && this.showBulkTrashOptions),
);
}

View File

@@ -363,7 +363,7 @@
(click)="launch(u)"
[disabled]="!u.canLaunch"
>
<i class="bwi bwi-lg bwi-share-square" aria-hidden="true"></i>
<i class="bwi bwi-lg bwi-external-link" aria-hidden="true"></i>
</button>
<button
type="button"

View File

@@ -1,26 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AfterContentInit, Directive, HostListener, Input } from "@angular/core";
import { SsoComponent } from "@bitwarden/angular/auth/components/sso.component";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@Directive({
selector: "[app-link-sso]",
})
export class LinkSsoDirective extends SsoComponent implements AfterContentInit {
@Input() organization: Organization;
returnUri = "/settings/organizations";
redirectUri = window.location.origin + "/sso-connector.html";
clientId = "web";
@HostListener("click", ["$event"])
async onClick($event: MouseEvent) {
$event.preventDefault();
await this.submit(this.returnUri, true);
}
async ngAfterContentInit() {
this.identifier = this.organization.identifier;
}
}

View File

@@ -46,14 +46,12 @@
bitMenuItem
(click)="unlinkSso(organization)"
>
<i class="bwi bwi-fw bwi-chain-broken" aria-hidden="true"></i>
{{ "unlinkSso" | i18n }}
</button>
<ng-template #linkSso>
<a href="#" bitMenuItem app-link-sso [organization]="organization">
<i class="bwi bwi-fw bwi-link" aria-hidden="true"></i>
<button type="button" bitMenuItem (click)="handleLinkSso(organization)">
{{ "linkSso" | i18n }}
</a>
</button>
</ng-template>
</ng-container>
<button *ngIf="showLeaveOrgOption" type="button" bitMenuItem (click)="leave(organization)">

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
import {
combineLatest,
@@ -37,6 +35,7 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { OrganizationUserResetPasswordService } from "../../../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service";
import { EnrollMasterPasswordReset } from "../../../../admin-console/organizations/users/enroll-master-password-reset.component";
import { LinkSsoService } from "../../../../auth/core/services";
import { OptionsInput } from "../shared/components/vault-filter-section.component";
import { OrganizationFilter } from "../shared/models/vault-filter.type";
@@ -45,12 +44,12 @@ import { OrganizationFilter } from "../shared/models/vault-filter.type";
templateUrl: "organization-options.component.html",
})
export class OrganizationOptionsComponent implements OnInit, OnDestroy {
protected actionPromise: Promise<void | boolean>;
protected actionPromise?: Promise<void | boolean>;
protected resetPasswordPolicy?: Policy | undefined;
protected loaded = false;
protected hideMenu = false;
protected showLeaveOrgOption = false;
protected organization: OrganizationFilter;
protected organization!: OrganizationFilter;
private destroy$ = new Subject<void>();
@@ -72,11 +71,14 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
private configService: ConfigService,
private organizationService: OrganizationService,
private accountService: AccountService,
private linkSsoService: LinkSsoService,
) {}
async ngOnInit() {
const resetPasswordPolicies$ = this.policyService.policies$.pipe(
map((policies) => policies.filter((policy) => policy.type === PolicyType.ResetPassword)),
const resetPasswordPolicies$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.policyService.policies$(userId)),
map((policies) => policies.filter((p) => p.type == PolicyType.ResetPassword)),
);
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
@@ -145,6 +147,23 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
return org?.useSso && org?.identifier;
}
/**
* Links SSO to an organization.
* @param organization The organization to link SSO to.
*/
async handleLinkSso(organization: Organization) {
try {
await this.linkSsoService.linkSso(organization.identifier);
} catch (e) {
this.logService.error(e);
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t("errorOccurred"),
});
}
}
async unlinkSso(org: Organization) {
const confirmed = await this.dialogService.openSimpleDialog({
title: org.name,
@@ -163,7 +182,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
title: "",
message: this.i18nService.t("unlinkedSso"),
});
} catch (e) {
@@ -187,7 +206,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
title: "",
message: this.i18nService.t("leftOrganization"),
});
} catch (e) {
@@ -213,7 +232,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
// Remove reset password
const request = new OrganizationUserResetPasswordEnrollmentRequest();
request.masterPasswordHash = "ignored";
request.resetPasswordKey = null;
request.resetPasswordKey = "";
this.actionPromise =
this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
this.organization.id,
@@ -224,7 +243,7 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
await this.actionPromise;
this.toastService.showToast({
variant: "success",
title: null,
title: "",
message: this.i18nService.t("withdrawPasswordResetSuccess"),
});
await this.syncService.fullSync(true);

View File

@@ -6,6 +6,9 @@ import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -91,6 +94,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
}
private trialFlowService = inject(TrialFlowService);
protected activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
constructor(
protected vaultFilterService: VaultFilterService,
@@ -101,6 +105,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
protected configService: ConfigService,
protected accountService: AccountService,
) {}
async ngOnInit(): Promise<void> {
@@ -110,10 +115,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
this.isLoaded = true;
// Without refactoring the entire component, we need to manually update the organization filter whenever the policies update
merge(
this.policyService.get$(PolicyType.SingleOrg),
this.policyService.get$(PolicyType.PersonalOwnership),
)
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
merge(
this.policyService.policiesByType$(PolicyType.SingleOrg, userId).pipe(getFirstPolicy),
this.policyService
.policiesByType$(PolicyType.PersonalOwnership, userId)
.pipe(getFirstPolicy),
),
),
)
.pipe(
switchMap(() => this.addOrganizationFilter()),
takeUntil(this.destroy$),
@@ -150,7 +163,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
filter.selectedOrganizationNode = orgNode;
}
this.vaultFilterService.setOrganizationFilter(orgNode.node);
await this.vaultFilterService.expandOrgFilter();
const userId = await firstValueFrom(this.activeUserId$);
await this.vaultFilterService.expandOrgFilter(userId);
};
applyTypeFilter = async (filterNode: TreeNode<CipherTypeFilter>): Promise<void> => {
@@ -190,9 +204,22 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
}
protected async addOrganizationFilter(): Promise<VaultFilterSection> {
const singleOrgPolicy = await this.policyService.policyAppliesToUser(PolicyType.SingleOrg);
const personalVaultPolicy = await this.policyService.policyAppliesToUser(
PolicyType.PersonalOwnership,
const singleOrgPolicy = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
),
),
);
const personalVaultPolicy = await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
),
),
);
const addAction = !singleOrgPolicy

View File

@@ -4,6 +4,7 @@ import { Observable } from "rxjs";
import { CollectionAdminView, CollectionView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { UserId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -22,16 +23,19 @@ export abstract class VaultFilterService {
folderTree$: Observable<TreeNode<FolderFilter>>;
collectionTree$: Observable<TreeNode<CollectionFilter>>;
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>>;
getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
setCollapsedFilterNodes: (collapsedFilterNodes: Set<string>) => Promise<void>;
expandOrgFilter: () => Promise<void>;
getOrganizationFilter: () => Observable<Organization>;
setOrganizationFilter: (organization: Organization) => void;
buildTypeTree: (
abstract getCollectionNodeFromTree: (id: string) => Promise<TreeNode<CollectionFilter>>;
abstract setCollapsedFilterNodes: (
collapsedFilterNodes: Set<string>,
userId: UserId,
) => Promise<void>;
abstract expandOrgFilter: (userId: UserId) => Promise<void>;
abstract getOrganizationFilter: () => Observable<Organization>;
abstract setOrganizationFilter: (organization: Organization) => void;
abstract buildTypeTree: (
head: CipherTypeFilter,
array: CipherTypeFilter[],
) => Observable<TreeNode<CipherTypeFilter>>;
// TODO: Remove this from org vault when collection admin service adopts state management
reloadCollections?: (collections: CollectionAdminView[]) => void;
clearOrganizationFilter: () => void;
abstract reloadCollections?: (collections: CollectionAdminView[]) => void;
abstract clearOrganizationFilter: () => void;
}

View File

@@ -2,7 +2,7 @@ import {
FakeAccountService,
mockAccountServiceWith,
} from "@bitwarden/common/../spec/fake-account-service";
import { FakeActiveUserState } from "@bitwarden/common/../spec/fake-state";
import { FakeSingleUserState } from "@bitwarden/common/../spec/fake-state";
import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, ReplaySubject } from "rxjs";
@@ -42,7 +42,7 @@ describe("vault filter service", () => {
const mockUserId = Utils.newGuid() as UserId;
let accountService: FakeAccountService;
let collapsedGroupingsState: FakeActiveUserState<string[]>;
let collapsedGroupingsState: FakeSingleUserState<string[]>;
beforeEach(() => {
organizationService = mock<OrganizationService>();
@@ -65,11 +65,11 @@ describe("vault filter service", () => {
organizationService.memberOrganizations$.mockReturnValue(organizations);
folderService.folderViews$.mockReturnValue(folderViews);
collectionService.decryptedCollections$ = collectionViews;
policyService.policyAppliesToActiveUser$
.calledWith(PolicyType.PersonalOwnership)
policyService.policyAppliesToUser$
.calledWith(PolicyType.PersonalOwnership, mockUserId)
.mockReturnValue(personalOwnershipPolicy);
policyService.policyAppliesToActiveUser$
.calledWith(PolicyType.SingleOrg)
policyService.policyAppliesToUser$
.calledWith(PolicyType.SingleOrg, mockUserId)
.mockReturnValue(singleOrgPolicy);
cipherService.cipherViews$.mockReturnValue(cipherViews);
@@ -83,21 +83,21 @@ describe("vault filter service", () => {
collectionService,
accountService,
);
collapsedGroupingsState = stateProvider.activeUser.getFake(COLLAPSED_GROUPINGS);
collapsedGroupingsState = stateProvider.singleUser.getFake(mockUserId, COLLAPSED_GROUPINGS);
});
describe("collapsed filter nodes", () => {
const nodes = new Set(["1", "2"]);
it("should update the collapsedFilterNodes$", async () => {
await vaultFilterService.setCollapsedFilterNodes(nodes);
await vaultFilterService.setCollapsedFilterNodes(nodes, mockUserId);
const collapsedGroupingsState = stateProvider.activeUser.getFake(COLLAPSED_GROUPINGS);
expect(await firstValueFrom(collapsedGroupingsState.state$)).toEqual(Array.from(nodes));
expect(collapsedGroupingsState.nextMock).toHaveBeenCalledWith([
const collapsedGroupingsState = stateProvider.singleUser.getFake(
mockUserId,
Array.from(nodes),
]);
COLLAPSED_GROUPINGS,
);
expect(await firstValueFrom(collapsedGroupingsState.state$)).toEqual(Array.from(nodes));
expect(collapsedGroupingsState.nextMock).toHaveBeenCalledWith(Array.from(nodes));
});
it("loads from state on initialization", async () => {

View File

@@ -23,8 +23,10 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } 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";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -47,16 +49,27 @@ const NestingDelimiter = "/";
@Injectable()
export class VaultFilterService implements VaultFilterServiceAbstraction {
private activeUserId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
protected activeUserId$ = this.accountService.activeAccount$.pipe(getUserId);
memberOrganizations$ = this.activeUserId$.pipe(
switchMap((id) => this.organizationService.memberOrganizations$(id)),
);
collapsedFilterNodes$ = this.activeUserId$.pipe(
switchMap((id) => this.collapsedGroupingsState(id).state$),
map((state) => new Set(state)),
);
organizationTree$: Observable<TreeNode<OrganizationFilter>> = combineLatest([
this.memberOrganizations$,
this.policyService.policyAppliesToActiveUser$(PolicyType.SingleOrg),
this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership),
this.activeUserId$.pipe(
switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)),
),
this.activeUserId$.pipe(
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
),
),
]).pipe(
switchMap(([orgs, singleOrgPolicy, personalOwnershipPolicy]) =>
this.buildOrganizationTree(orgs, singleOrgPolicy, personalOwnershipPolicy),
@@ -97,11 +110,9 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
cipherTypeTree$: Observable<TreeNode<CipherTypeFilter>> = this.buildCipherTypeTree();
private collapsedGroupingsState: ActiveUserState<string[]> =
this.stateProvider.getActive(COLLAPSED_GROUPINGS);
readonly collapsedFilterNodes$: Observable<Set<string>> =
this.collapsedGroupingsState.state$.pipe(map((c) => new Set(c)));
private collapsedGroupingsState(userId: UserId): SingleUserState<string[]> {
return this.stateProvider.getUser(userId, COLLAPSED_GROUPINGS);
}
constructor(
protected organizationService: OrganizationService,
@@ -119,8 +130,8 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
return ServiceUtils.getTreeNodeObject(collections, id) as TreeNode<CollectionFilter>;
}
async setCollapsedFilterNodes(collapsedFilterNodes: Set<string>): Promise<void> {
await this.collapsedGroupingsState.update(() => Array.from(collapsedFilterNodes));
async setCollapsedFilterNodes(collapsedFilterNodes: Set<string>, userId: UserId): Promise<void> {
await this.collapsedGroupingsState(userId).update(() => Array.from(collapsedFilterNodes));
}
protected async getCollapsedFilterNodes(): Promise<Set<string>> {
@@ -143,13 +154,13 @@ export class VaultFilterService implements VaultFilterServiceAbstraction {
}
}
async expandOrgFilter() {
async expandOrgFilter(userId: UserId) {
const collapsedFilterNodes = await firstValueFrom(this.collapsedFilterNodes$);
if (!collapsedFilterNodes.has("AllVaults")) {
return;
}
collapsedFilterNodes.delete("AllVaults");
await this.setCollapsedFilterNodes(collapsedFilterNodes);
await this.setCollapsedFilterNodes(collapsedFilterNodes, userId);
}
protected async buildOrganizationTree(

View File

@@ -1,10 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, InjectionToken, Injector, Input, OnDestroy, OnInit } from "@angular/core";
import { Observable, Subject, takeUntil } from "rxjs";
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
import { map } from "rxjs/operators";
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 { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { VaultFilterService } from "../../services/abstractions/vault-filter.service";
@@ -17,6 +19,7 @@ import { VaultFilter } from "../models/vault-filter.model";
})
export class VaultFilterSectionComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private activeUserId$ = getUserId(this.accountService.activeAccount$);
@Input() activeFilter: VaultFilter;
@Input() section: VaultFilterSection;
@@ -29,6 +32,7 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
constructor(
private vaultFilterService: VaultFilterService,
private injector: Injector,
private accountService: AccountService,
) {
this.vaultFilterService.collapsedFilterNodes$
.pipe(takeUntil(this.destroy$))
@@ -126,7 +130,8 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
} else {
this.collapsedFilterNodes.add(node.id);
}
await this.vaultFilterService.setCollapsedFilterNodes(this.collapsedFilterNodes);
const userId = await firstValueFrom(this.activeUserId$);
await this.vaultFilterService.setCollapsedFilterNodes(this.collapsedFilterNodes, userId);
}
// an injector is necessary to pass data into a dynamic component

View File

@@ -4,7 +4,6 @@ import { SearchModule } from "@bitwarden/components";
import { VaultFilterSharedModule } from "../../individual-vault/vault-filter/shared/vault-filter-shared.module";
import { LinkSsoDirective } from "./components/link-sso.directive";
import { OrganizationOptionsComponent } from "./components/organization-options.component";
import { VaultFilterComponent } from "./components/vault-filter.component";
import { VaultFilterService as VaultFilterServiceAbstraction } from "./services/abstractions/vault-filter.service";
@@ -12,7 +11,7 @@ import { VaultFilterService } from "./services/vault-filter.service";
@NgModule({
imports: [VaultFilterSharedModule, SearchModule],
declarations: [VaultFilterComponent, OrganizationOptionsComponent, LinkSsoDirective],
declarations: [VaultFilterComponent, OrganizationOptionsComponent],
exports: [VaultFilterComponent],
providers: [
{

View File

@@ -142,13 +142,13 @@ describe("VaultOnboardingComponent", () => {
});
describe("individualVaultPolicyCheck", () => {
it("should set isIndividualPolicyVault to true", async () => {
it("should set isIndividualPolicyVault to true", () => {
individualVaultPolicyCheckSpy.mockRestore();
const spy = jest
.spyOn((component as any).policyService, "policyAppliesToActiveUser$")
.spyOn((component as any).policyService, "policyAppliesToUser$")
.mockReturnValue(of(true));
await component.individualVaultPolicyCheck();
component.individualVaultPolicyCheck();
fixture.detectChanges();
expect(spy).toHaveBeenCalled();
});

View File

@@ -11,7 +11,7 @@ import {
SimpleChanges,
OnChanges,
} from "@angular/core";
import { Subject, takeUntil, Observable, firstValueFrom, fromEvent } from "rxjs";
import { Subject, takeUntil, Observable, firstValueFrom, fromEvent, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -20,7 +20,6 @@ import { PolicyType } 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";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
@@ -67,7 +66,6 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy {
protected policyService: PolicyService,
private apiService: ApiService,
private vaultOnboardingService: VaultOnboardingServiceAbstraction,
private configService: ConfigService,
private accountService: AccountService,
) {}
@@ -165,9 +163,14 @@ export class VaultOnboardingComponent implements OnInit, OnChanges, OnDestroy {
}
individualVaultPolicyCheck() {
this.policyService
.policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
.pipe(takeUntil(this.destroy$))
this.accountService.activeAccount$
.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
),
takeUntil(this.destroy$),
)
.subscribe((data) => {
this.isIndividualPolicyVault = data;
});

View File

@@ -20,9 +20,10 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { ChangeLoginPasswordService, DefaultTaskService, TaskService } from "@bitwarden/vault";
import { ChangeLoginPasswordService } from "@bitwarden/vault";
import { ViewCipherDialogParams, ViewCipherDialogResult, ViewComponent } from "./view.component";
@@ -83,12 +84,12 @@ describe("ViewComponent", () => {
canDeleteCipher$: jest.fn().mockReturnValue(true),
},
},
{ provide: TaskService, useValue: mock<TaskService>() },
],
})
.overrideComponent(ViewComponent, {
remove: {
providers: [
{ provide: TaskService, useClass: DefaultTaskService },
{ provide: PlatformUtilsService, useValue: PlatformUtilsService },
{
provide: ChangeLoginPasswordService,
@@ -98,10 +99,6 @@ describe("ViewComponent", () => {
},
add: {
providers: [
{
provide: TaskService,
useValue: mock<TaskService>(),
},
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{
provide: ChangeLoginPasswordService,

View File

@@ -3,7 +3,7 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Inject, OnInit } from "@angular/core";
import { Observable, firstValueFrom, map } from "rxjs";
import { firstValueFrom, map, Observable } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -26,7 +26,7 @@ import {
DialogService,
ToastService,
} from "@bitwarden/components";
import { CipherViewComponent, DefaultTaskService, TaskService } from "@bitwarden/vault";
import { CipherViewComponent } from "@bitwarden/vault";
import { SharedModule } from "../../shared/shared.module";
import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service";
@@ -74,7 +74,6 @@ export interface ViewCipherDialogCloseResult {
providers: [
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
{ provide: TaskService, useClass: DefaultTaskService },
],
})
export class ViewComponent implements OnInit {

View File

@@ -38,7 +38,7 @@ describe("AdminConsoleCipherFormConfigService", () => {
status: OrganizationUserStatusType.Confirmed,
userId: "UserId",
};
const policyAppliesToActiveUser$ = new BehaviorSubject<boolean>(true);
const policyAppliesToUser$ = new BehaviorSubject<boolean>(true);
const collection = {
id: "12345-5555",
organizationId: "234534-34334",
@@ -75,7 +75,7 @@ describe("AdminConsoleCipherFormConfigService", () => {
},
{
provide: PolicyService,
useValue: { policyAppliesToActiveUser$: () => policyAppliesToActiveUser$ },
useValue: { policyAppliesToUser$: () => policyAppliesToUser$ },
},
{
provide: RoutedVaultFilterService,
@@ -129,13 +129,13 @@ describe("AdminConsoleCipherFormConfigService", () => {
});
it("sets `allowPersonalOwnership`", async () => {
policyAppliesToActiveUser$.next(true);
policyAppliesToUser$.next(true);
let result = await adminConsoleConfigService.buildConfig("clone", cipherId);
expect(result.allowPersonalOwnership).toBe(false);
policyAppliesToActiveUser$.next(false);
policyAppliesToUser$.next(false);
result = await adminConsoleConfigService.buildConfig("clone", cipherId);
@@ -143,7 +143,7 @@ describe("AdminConsoleCipherFormConfigService", () => {
});
it("disables personal ownership when not cloning", async () => {
policyAppliesToActiveUser$.next(false);
policyAppliesToUser$.next(false);
let result = await adminConsoleConfigService.buildConfig("add", cipherId);

View File

@@ -10,6 +10,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { OrganizationUserStatusType, PolicyType } 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";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -30,9 +31,13 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
private apiService: ApiService = inject(ApiService);
private accountService: AccountService = inject(AccountService);
private allowPersonalOwnership$ = this.policyService
.policyAppliesToActiveUser$(PolicyType.PersonalOwnership)
.pipe(map((p) => !p));
private allowPersonalOwnership$ = this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) =>
this.policyService.policyAppliesToUser$(PolicyType.PersonalOwnership, userId),
),
map((p) => !p),
);
private organizationId$ = this.routedVaultFilterService.filter$.pipe(
map((filter) => filter.organizationId),

View File

@@ -169,5 +169,32 @@ describe("BrowserExtensionPromptService", () => {
pageTitle: { key: "somethingWentWrong" },
});
});
it("sets manual open state when open extension is called", (done) => {
service.openExtension(true);
jest.advanceTimersByTime(1000);
service.pageState$.subscribe((state) => {
expect(state).toBe(BrowserPromptState.ManualOpen);
done();
});
});
it("shows success state when extension auto opens", (done) => {
service.openExtension(true);
jest.advanceTimersByTime(500); // don't let timeout occur
window.dispatchEvent(
new MessageEvent("message", { data: { command: VaultMessages.PopupOpened } }),
);
service.pageState$.subscribe((state) => {
expect(state).toBe(BrowserPromptState.Success);
expect(service["extensionCheckTimeout"]).toBeUndefined();
done();
});
});
});
});

View File

@@ -54,8 +54,18 @@ export class BrowserExtensionPromptService {
}
/** Post a message to the extension to open */
openExtension() {
openExtension(setManualErrorTimeout = false) {
window.postMessage({ command: VaultMessages.OpenPopup });
// Optionally, configure timeout to show the manual open error state if
// the extension does not open within one second.
if (setManualErrorTimeout) {
this.clearExtensionCheckTimeout();
this.extensionCheckTimeout = window.setTimeout(() => {
this.setErrorState(BrowserPromptState.ManualOpen);
}, 750);
}
}
/** Send message checking for the browser extension */

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