1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Merge branch 'main' into auth/pm-9115/implement-view-data-persistence-in-2FA-flows

This commit is contained in:
Alec Rippberger
2025-04-01 18:42:48 -05:00
committed by GitHub
108 changed files with 1609 additions and 522 deletions

View File

@@ -1309,6 +1309,13 @@ export default class MainBackground {
// Only the "true" background should run migrations
await this.stateService.init({ runMigrations: true });
this.configService.serverConfig$.subscribe((newConfig) => {
if (newConfig != null) {
this.encryptService.onServerConfigChange(newConfig);
this.bulkEncryptService.onServerConfigChange(newConfig);
}
});
// This is here instead of in in the InitService b/c we don't plan for
// side effects to run in the Browser InitService.
const accounts = await firstValueFrom(this.accountService.accounts$);

View File

@@ -3,6 +3,9 @@ import { inject, Inject, Injectable } from "@angular/core";
import { AbstractThemingService } from "@bitwarden/angular/platform/services/theming/theming.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService as LogServiceAbstraction } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -27,6 +30,9 @@ export class InitService {
private themingService: AbstractThemingService,
private sdkLoadService: SdkLoadService,
private viewCacheService: PopupViewCacheService,
private configService: ConfigService,
private encryptService: EncryptService,
private bulkEncryptService: BulkEncryptService,
@Inject(DOCUMENT) private document: Document,
) {}
@@ -34,6 +40,12 @@ export class InitService {
return async () => {
await this.sdkLoadService.loadAndInit();
await this.stateService.init({ runMigrations: false }); // Browser background is responsible for migrations
this.configService.serverConfig$.subscribe((newConfig) => {
if (newConfig != null) {
this.encryptService.onServerConfigChange(newConfig);
this.bulkEncryptService.onServerConfigChange(newConfig);
}
});
await this.i18nService.init();
this.twoFactorService.init();
await this.viewCacheService.init();

View File

@@ -4,8 +4,9 @@ import { switchMap, tap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { ToastService } from "@bitwarden/components";
import { filterOutNullish, TaskService } from "@bitwarden/vault";
export const canAccessAtRiskPasswords: CanActivateFn = () => {
const accountService = inject(AccountService);

View File

@@ -4,9 +4,10 @@ import { RouterModule } from "@angular/router";
import { map, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { AnchorLinkDirective, CalloutModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { filterOutNullish, SecurityTaskType, TaskService } from "@bitwarden/vault";
// TODO: This component will need to be reworked to use the new EndUserNotificationService in PM-10609

View File

@@ -16,14 +16,12 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService, ToastService } from "@bitwarden/components";
import {
ChangeLoginPasswordService,
DefaultChangeLoginPasswordService,
PasswordRepromptService,
SecurityTask,
SecurityTaskType,
TaskService,
} from "@bitwarden/vault";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";

View File

@@ -15,6 +15,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import {
BadgeModule,
ButtonModule,
@@ -28,10 +30,7 @@ import {
import {
ChangeLoginPasswordService,
DefaultChangeLoginPasswordService,
filterOutNullish,
PasswordRepromptService,
SecurityTaskType,
TaskService,
VaultCarouselModule,
} from "@bitwarden/vault";

View File

@@ -170,7 +170,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
this.cipherService
.failedToDecryptCiphers$(activeUserId)
.pipe(
map((ciphers) => ciphers.filter((c) => !c.isDeleted)),
map((ciphers) => (ciphers ? ciphers.filter((c) => !c.isDeleted) : [])),
filter((ciphers) => ciphers.length > 0),
take(1),
takeUntilDestroyed(this.destroyRef),

View File

@@ -33,19 +33,17 @@ import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cip
import {
AsyncActionsModule,
ButtonModule,
CalloutModule,
DialogService,
IconButtonModule,
SearchModule,
ToastService,
CalloutModule,
} from "@bitwarden/components";
import {
ChangeLoginPasswordService,
CipherViewComponent,
CopyCipherFieldService,
DefaultChangeLoginPasswordService,
DefaultTaskService,
TaskService,
} from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
@@ -95,7 +93,6 @@ type LoadAction =
providers: [
{ provide: ViewPasswordHistoryService, useClass: BrowserViewPasswordHistoryService },
{ provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService },
{ provide: TaskService, useClass: DefaultTaskService },
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
],
})

View File

@@ -108,7 +108,7 @@ export class VaultPopupItemsService {
this.cipherService.failedToDecryptCiphers$(userId),
]),
),
map(([ciphers, failedToDecryptCiphers]) => [...failedToDecryptCiphers, ...ciphers]),
map(([ciphers, failedToDecryptCiphers]) => [...(failedToDecryptCiphers || []), ...ciphers]),
),
),
shareReplay({ refCount: true, bufferSize: 1 }),

View File

@@ -12,7 +12,6 @@ import {
startWith,
switchMap,
take,
tap,
} from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
@@ -360,10 +359,10 @@ export class VaultPopupListFiltersService {
switchMap((userId) => {
// Observable of cipher views
const cipherViews$ = this.cipherService.cipherViews$(userId).pipe(
tap((cipherViews) => {
this.cipherViews = Object.values(cipherViews);
map((ciphers) => {
this.cipherViews = ciphers ? Object.values(ciphers) : [];
return this.cipherViews;
}),
map((ciphers) => Object.values(ciphers)),
);
return combineLatest([

View File

@@ -283,6 +283,7 @@ export class ServiceContainer {
cipherAuthorizationService: CipherAuthorizationService;
ssoUrlService: SsoUrlService;
masterPasswordApiService: MasterPasswordApiServiceAbstraction;
bulkEncryptService: FallbackBulkEncryptService;
constructor() {
let p = null;
@@ -314,6 +315,7 @@ export class ServiceContainer {
this.logService,
true,
);
this.bulkEncryptService = new FallbackBulkEncryptService(this.encryptService);
this.storageService = new LowdbStorageService(this.logService, null, p, false, true);
this.secureStorageService = new NodeEnvSecureStorageService(
this.storageService,
@@ -686,7 +688,7 @@ export class ServiceContainer {
this.stateService,
this.autofillSettingsService,
this.encryptService,
new FallbackBulkEncryptService(this.encryptService),
this.bulkEncryptService,
this.cipherFileUploadService,
this.configService,
this.stateProvider,
@@ -885,6 +887,12 @@ export class ServiceContainer {
await this.sdkLoadService.loadAndInit();
await this.storageService.init();
await this.stateService.init();
this.configService.serverConfig$.subscribe((newConfig) => {
if (newConfig != null) {
this.encryptService.onServerConfigChange(newConfig);
this.bulkEncryptService.onServerConfigChange(newConfig);
}
});
this.containerService.attachToGlobal(global);
await this.i18nService.init();
this.twoFactorService.init();

View File

@@ -7,8 +7,10 @@ import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
@@ -49,6 +51,8 @@ export class InitService {
private sshAgentService: SshAgentService,
private autofillService: DesktopAutofillService,
private sdkLoadService: SdkLoadService,
private configService: ConfigService,
private bulkEncryptService: BulkEncryptService,
@Inject(DOCUMENT) private document: Document,
) {}
@@ -59,6 +63,13 @@ export class InitService {
this.nativeMessagingService.init();
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
this.configService.serverConfig$.subscribe((newConfig) => {
if (newConfig != null) {
this.encryptService.onServerConfigChange(newConfig);
this.bulkEncryptService.onServerConfigChange(newConfig);
}
});
const accounts = await firstValueFrom(this.accountService.accounts$);
const setUserKeyInMemoryPromises = [];
for (const userId of Object.keys(accounts) as UserId[]) {

View File

@@ -282,14 +282,9 @@
</div>
</div>
<div class="footer">
<button
type="submit"
class="primary btn-submit"
appA11yTitle="{{ 'save' | i18n }}"
*ngIf="!disableSend"
>
<button type="submit" class="primary btn-submit" *ngIf="!disableSend">
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
<span><i class="bwi bwi-save-changes bwi-lg bwi-fw" aria-hidden="true"></i></span>
{{ "save" | i18n }}
</button>
<button type="button" (click)="cancel()">
{{ "cancel" | i18n }}

View File

@@ -99,7 +99,7 @@
</ng-container>
<ng-container *ngIf="s.maxAccessCountReached">
<i
class="bwi bwi-ban"
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"

View File

@@ -3561,5 +3561,8 @@
},
"changeAtRiskPassword": {
"message": "Change at-risk password"
},
"move": {
"message": "Move"
}
}

View File

@@ -773,17 +773,8 @@
</div>
</div>
<div class="footer">
<button
type="submit"
class="primary"
appA11yTitle="{{ 'save' | i18n }}"
[disabled]="$any(form).loading"
>
<i
class="bwi bwi-save-changes bwi-lg bwi-fw"
[hidden]="$any(form).loading"
aria-hidden="true"
></i>
<button type="submit" class="primary" [disabled]="$any(form).loading">
<span [hidden]="$any(form).loading">{{ "save" | i18n }}</span>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!$any(form).loading"
@@ -797,10 +788,9 @@
<button
type="button"
(click)="share()"
appA11yTitle="{{ 'moveToOrganization' | i18n }}"
*ngIf="editMode && cipher && !cipher.organizationId && !cloneMode"
>
<i class="bwi bwi-arrow-circle-right bwi-lg bwi-fw" aria-hidden="true"></i>
{{ "move" | i18n }}
</button>
<button
#deleteBtn

View File

@@ -54,17 +54,8 @@
</div>
</div>
<div class="modal-footer">
<button
type="submit"
class="primary"
appA11yTitle="{{ 'save' | i18n }}"
[disabled]="form.loading"
>
<i
class="bwi bwi-save-changes bwi-lg bwi-fw"
[hidden]="form.loading"
aria-hidden="true"
></i>
<button type="submit" class="primary" [disabled]="form.loading">
<span [hidden]="form.loading">{{ "save" | i18n }}</span>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!form.loading"

View File

@@ -28,17 +28,8 @@
</div>
</div>
<div class="modal-footer">
<button
type="submit"
class="primary"
appA11yTitle="{{ 'save' | i18n }}"
[disabled]="form.loading"
>
<i
class="bwi bwi-save-changes bwi-lg bwi-fw"
[hidden]="form.loading"
aria-hidden="true"
></i>
<button type="submit" class="primary" [disabled]="form.loading">
<span [hidden]="form.loading">{{ "save" | i18n }}</span>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!form.loading"

View File

@@ -21,17 +21,8 @@
</div>
</div>
<div class="modal-footer">
<button
type="submit"
class="primary"
appA11yTitle="{{ 'save' | i18n }}"
[disabled]="form.loading"
>
<i
class="bwi bwi-save-changes bwi-lg bwi-fw"
[hidden]="form.loading"
aria-hidden="true"
></i>
<button type="submit" class="primary" [disabled]="form.loading">
<span [hidden]="form.loading">{{ "save" | i18n }}</span>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!form.loading"

View File

@@ -58,15 +58,10 @@
<button
type="submit"
class="primary"
appA11yTitle="{{ 'save' | i18n }}"
[disabled]="form.loading || !canSave"
*ngIf="organizations && organizations.length"
>
<i
class="bwi bwi-save-changes bwi-lg bwi-fw"
[hidden]="form.loading"
aria-hidden="true"
></i>
<span [hidden]="form.loading">{{ "save" | i18n }}</span>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!form.loading"

View File

@@ -3,16 +3,23 @@
{{ "customFields" | i18n }}
</h2>
<div class="box-content">
<div class="box-content-row box-content-row-flex" *ngFor="let field of cipher.fields">
<div
class="box-content-row box-content-row-flex"
*ngFor="let field of cipher.fields; index as i"
>
<div class="row-main">
<span
*ngIf="field.type != fieldType.Linked"
class="row-label draggable"
draggable="true"
(dragstart)="setTextDataOnDrag($event, field.value)"
>{{ field.name }}</span
[id]="'customField-' + i"
>
<span *ngIf="field.type === fieldType.Linked" class="row-label">{{ field.name }}</span>
{{ field.name }}
</span>
<span *ngIf="field.type === fieldType.Linked" class="row-label">
{{ "cfTypeLinked" | i18n }}: {{ field.name }}
</span>
<div *ngIf="field.type === fieldType.Text">
{{ field.value || "&nbsp;" }}
</div>
@@ -29,19 +36,14 @@
></span>
</div>
<div *ngIf="field.type === fieldType.Boolean">
<i class="bwi bwi-check-square" *ngIf="field.value === 'true'" aria-hidden="true"></i>
<i class="bwi bwi-square" *ngIf="field.value !== 'true'" aria-hidden="true"></i>
<span class="sr-only">{{ field.value }}</span>
<input
type="checkbox"
[checked]="field.value === 'true'"
disabled="true"
[attr.aria-labelledby]="'customField-' + i"
/>
</div>
<div *ngIf="field.type === fieldType.Linked" class="box-content-row-flex">
<div class="icon icon-small">
<i
class="bwi bwi-link"
aria-hidden="true"
appA11yTitle="{{ 'linkedValue' | i18n }}"
></i>
<span class="sr-only">{{ "linkedValue" | i18n }}</span>
</div>
<span>{{ cipher.linkedFieldI18nKey(field.linkedId) | i18n }}</span>
</div>
</div>

View File

@@ -535,7 +535,7 @@
*ngIf="u.canLaunch"
(click)="launch(u)"
>
<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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -965,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

@@ -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

@@ -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>

View File

@@ -499,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:
@@ -546,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

@@ -7,8 +7,10 @@ import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { DefaultVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@@ -37,6 +39,8 @@ export class InitService {
private accountService: AccountService,
private versionService: VersionService,
private sdkLoadService: SdkLoadService,
private configService: ConfigService,
private bulkEncryptService: BulkEncryptService,
@Inject(DOCUMENT) private document: Document,
) {}
@@ -45,6 +49,13 @@ export class InitService {
await this.sdkLoadService.loadAndInit();
await this.stateService.init();
this.configService.serverConfig$.subscribe((newConfig) => {
if (newConfig != null) {
this.encryptService.onServerConfigChange(newConfig);
this.bulkEncryptService.onServerConfigChange(newConfig);
}
});
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount) {
// If there is an active account, we must await the process of setting the user key in memory

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

@@ -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

@@ -121,7 +121,7 @@
</ng-container>
<ng-container *ngIf="s.maxAccessCountReached">
<i
class="bwi bwi-ban"
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"

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

@@ -52,10 +52,12 @@
{{ "permission" | i18n }}
</th>
<th bitCell class="tw-w-12 tw-text-right">
@let featureFlaggedDisable =
(limitItemDeletion$ | async) ? (disableMenu$ | async) : disableMenu;
<button
[disabled]="disabled || isEmpty || disableMenu"
[disabled]="disabled || isEmpty || featureFlaggedDisable"
[bitMenuTriggerFor]="headerMenu"
[attr.title]="disableMenu ? ('missingPermissions' | i18n) : ''"
[attr.title]="featureFlaggedDisable ? ('missingPermissions' | i18n) : ''"
bitIconButton="bwi-ellipsis-v"
size="small"
type="button"

View File

@@ -85,6 +85,7 @@ export class VaultItemsComponent {
protected selection = new SelectionModel<VaultItem>(true, [], true);
protected canDeleteSelected$: Observable<boolean>;
protected canRestoreSelected$: Observable<boolean>;
protected disableMenu$: Observable<boolean>;
constructor(
protected cipherAuthorizationService: CipherAuthorizationService,
@@ -140,6 +141,20 @@ export class VaultItemsComponent {
}),
map((canRestore) => canRestore && this.showBulkTrashOptions),
);
this.disableMenu$ = combineLatest([this.limitItemDeletion$, this.canDeleteSelected$]).pipe(
map(([enabled, canDelete]) => {
if (enabled) {
return (
!this.bulkMoveAllowed &&
!this.showAssignToCollections() &&
!canDelete &&
!this.showBulkEditCollectionAccess
);
}
return false;
}),
);
}
get showExtraColumn() {

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

@@ -46,12 +46,10 @@
bitMenuItem
(click)="unlinkSso(organization)"
>
<i class="bwi bwi-fw bwi-chain-broken" aria-hidden="true"></i>
{{ "unlinkSso" | i18n }}
</button>
<ng-template #linkSso>
<button type="button" bitMenuItem (click)="handleLinkSso(organization)">
<i class="bwi bwi-fw bwi-link" aria-hidden="true"></i>
{{ "linkSso" | i18n }}
</button>
</ng-template>

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

@@ -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 */

View File

@@ -3,7 +3,7 @@
<bit-nav-logo [openIcon]="logo" route="." [label]="'providerPortal' | i18n"></bit-nav-logo>
<bit-nav-item
icon="bwi-bank"
icon="bwi-provider"
[text]="'clients' | i18n"
[route]="(isBillable | async) ? 'manage-client-organizations' : 'clients'"
></bit-nav-item>
@@ -30,7 +30,7 @@
<bit-nav-item [text]="'billingHistory' | i18n" route="billing/history"></bit-nav-item>
</bit-nav-group>
<bit-nav-item
icon="bwi-cogs"
icon="bwi-cog"
[text]="'settings' | i18n"
route="settings"
*ngIf="showSettingsTab(provider)"

View File

@@ -17,7 +17,7 @@
{{ "newClient" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addExistingOrganization()">
<i aria-hidden="true" class="bwi bwi-sitemap"></i>
<i aria-hidden="true" class="bwi bwi-filter"></i>
{{ "existingOrganization" | i18n }}
</button>
</bit-menu>

View File

@@ -182,9 +182,8 @@ export class ProviderSubscriptionComponent 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

@@ -32,7 +32,7 @@
</div>
</bit-nav-item>
<bit-nav-item
icon="bwi-providers"
icon="bwi-msp"
[text]="'integrations' | i18n"
route="integrations"
[relativeTo]="route.parent"

View File

@@ -5,7 +5,7 @@
<bit-menu #newMenu>
<button type="button" bitMenuItem (click)="openProjectDialog()">
<i class="bwi bwi-fw bwi-sitemap" aria-hidden="true"></i>
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "project" | i18n }}
</button>
<button type="button" bitMenuItem (click)="openSecretDialog()">

View File

@@ -19,6 +19,7 @@ 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 { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTaskType } from "@bitwarden/common/vault/tasks";
import {
Icons,
NoItemsModule,
@@ -27,7 +28,6 @@ import {
ToastService,
} from "@bitwarden/components";
import { CardComponent } from "@bitwarden/tools-card";
import { SecurityTaskType } from "@bitwarden/vault";
import { HeaderModule } from "@bitwarden/web-vault/app/layouts/header/header.module";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
import { PipesModule } from "@bitwarden/web-vault/app/vault/individual-vault/pipes/pipes.module";

View File

@@ -1,5 +1,5 @@
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/vault";
import { SecurityTask, SecurityTaskStatus, SecurityTaskType } from "@bitwarden/common/vault/tasks";
/**
* Request type for creating tasks.

View File

@@ -1,8 +1,8 @@
import { MockProxy, mock } from "jest-mock-extended";
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherId, OrganizationId } from "@bitwarden/common/types/guid";
import { SecurityTaskStatus, SecurityTaskType } from "@bitwarden/vault";
import { SecurityTaskStatus, SecurityTaskType } from "@bitwarden/common/vault/tasks";
import { CreateTasksRequest } from "./abstractions/admin-task.abstraction";
import { DefaultAdminTaskService } from "./default-admin-task.service";

View File

@@ -8,7 +8,7 @@ import {
SecurityTaskData,
SecurityTaskResponse,
SecurityTaskStatus,
} from "@bitwarden/vault";
} from "@bitwarden/common/vault/tasks";
import { AdminTaskService, CreateTasksRequest } from "./abstractions/admin-task.abstraction";

View File

@@ -35,7 +35,7 @@
{{ "open" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'unpaid'">
<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 | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'paid'">

View File

@@ -4,48 +4,48 @@ import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Subject } from "rxjs";
import {
OrganizationUserApiService,
DefaultOrganizationUserApiService,
CollectionService,
DefaultCollectionService,
DefaultOrganizationUserApiService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import {
SetPasswordJitService,
DefaultSetPasswordJitService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
DefaultRegistrationFinishService,
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
LoginComponentService,
DefaultLoginApprovalComponentService,
DefaultLoginComponentService,
LoginDecryptionOptionsService,
DefaultLoginDecryptionOptionsService,
TwoFactorAuthComponentService,
DefaultRegistrationFinishService,
DefaultSetPasswordJitService,
DefaultTwoFactorAuthComponentService,
DefaultTwoFactorAuthEmailComponentService,
TwoFactorAuthEmailComponentService,
DefaultTwoFactorAuthWebAuthnComponentService,
LoginComponentService,
LoginDecryptionOptionsService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
SetPasswordJitService,
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthWebAuthnComponentService,
DefaultLoginApprovalComponentService,
} from "@bitwarden/auth/angular";
import {
AuthRequestServiceAbstraction,
AuthRequestService,
PinServiceAbstraction,
PinService,
LoginStrategyServiceAbstraction,
LoginStrategyService,
LoginEmailServiceAbstraction,
LoginEmailService,
InternalUserDecryptionOptionsServiceAbstraction,
UserDecryptionOptionsService,
UserDecryptionOptionsServiceAbstraction,
LogoutReason,
AuthRequestApiService,
AuthRequestService,
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
LoginSuccessHandlerService,
InternalUserDecryptionOptionsServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
LoginEmailService,
LoginEmailServiceAbstraction,
LoginStrategyService,
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
LogoutReason,
PinService,
PinServiceAbstraction,
UserDecryptionOptionsService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@@ -118,16 +118,16 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import {
AutofillSettingsServiceAbstraction,
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/autofill-settings.service";
import {
BadgeSettingsServiceAbstraction,
BadgeSettingsService,
BadgeSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/badge-settings.service";
import {
DomainSettingsService,
DefaultDomainSettingsService,
DomainSettingsService,
} from "@bitwarden/common/autofill/services/domain-settings.service";
import {
BillingApiServiceAbstraction,
@@ -200,8 +200,8 @@ import {
WebPushNotificationsApiService,
} from "@bitwarden/common/platform/notifications/internal";
import {
TaskSchedulerService,
DefaultTaskSchedulerService,
TaskSchedulerService,
} from "@bitwarden/common/platform/scheduling";
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
@@ -222,10 +222,10 @@ import { ValidationService } from "@bitwarden/common/platform/services/validatio
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import {
ActiveUserStateProvider,
DerivedStateProvider,
GlobalStateProvider,
SingleUserStateProvider,
StateProvider,
DerivedStateProvider,
} from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- We need the implementations to inject, but generally these should not be accessed */
import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider";
@@ -280,6 +280,7 @@ import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import { ToastService } from "@bitwarden/components";
import {
GeneratorHistoryService,
@@ -292,34 +293,32 @@ import {
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
import {
KeyService,
DefaultKeyService,
BiometricsService,
BiometricStateService,
DefaultBiometricStateService,
BiometricsService,
DefaultKdfConfigService,
KdfConfigService,
UserAsymmetricKeysRegenerationService,
DefaultUserAsymmetricKeysRegenerationService,
UserAsymmetricKeysRegenerationApiService,
DefaultKeyService,
DefaultUserAsymmetricKeysRegenerationApiService,
DefaultUserAsymmetricKeysRegenerationService,
KdfConfigService,
KeyService,
UserAsymmetricKeysRegenerationApiService,
UserAsymmetricKeysRegenerationService,
} from "@bitwarden/key-management";
import { SafeInjectionToken } from "@bitwarden/ui-common";
import {
DefaultTaskService,
DefaultEndUserNotificationService,
EndUserNotificationService,
NewDeviceVerificationNoticeService,
PasswordRepromptService,
TaskService,
} from "@bitwarden/vault";
import {
VaultExportService,
VaultExportServiceAbstraction,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
VaultExportService,
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
@@ -334,24 +333,24 @@ import { AbstractThemingService } from "../platform/services/theming/theming.ser
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
import {
CLIENT_TYPE,
DEFAULT_VAULT_TIMEOUT,
ENV_ADDITIONAL_REGIONS,
INTRAPROCESS_MESSAGING_SUBJECT,
LOCALES_DIRECTORY,
LOCKED_CALLBACK,
LOGOUT_CALLBACK,
LOG_MAC_FAILURES,
LOGOUT_CALLBACK,
MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
SECURE_STORAGE,
STATE_FACTORY,
SUPPORTS_SECURE_STORAGE,
SYSTEM_LANGUAGE,
SYSTEM_THEME_OBSERVABLE,
WINDOW,
DEFAULT_VAULT_TIMEOUT,
INTRAPROCESS_MESSAGING_SUBJECT,
CLIENT_TYPE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
ENV_ADDITIONAL_REGIONS,
} from "./injection-tokens";
import { ModalService } from "./modal.service";

View File

@@ -0,0 +1,38 @@
import { mock } from "jest-mock-extended";
import { ServerConfig } from "../platform/abstractions/config/server-config";
import { getFeatureFlagValue, FeatureFlag, DefaultFeatureFlagValue } from "./feature-flag.enum";
describe("getFeatureFlagValue", () => {
const testFlag = Object.values(FeatureFlag)[0];
const testFlagDefaultValue = DefaultFeatureFlagValue[testFlag];
it("returns default flag value when serverConfig is null", () => {
const result = getFeatureFlagValue(null, testFlag);
expect(result).toBe(testFlagDefaultValue);
});
it("returns default flag value when serverConfig.featureStates is undefined", () => {
const serverConfig = {} as ServerConfig;
const result = getFeatureFlagValue(serverConfig, testFlag);
expect(result).toBe(testFlagDefaultValue);
});
it("returns default flag value when the feature flag is not in serverConfig.featureStates", () => {
const serverConfig = mock<ServerConfig>();
serverConfig.featureStates = {};
const result = getFeatureFlagValue(serverConfig, testFlag);
expect(result).toBe(testFlagDefaultValue);
});
it("returns the flag value from serverConfig.featureStates when the feature flag exists", () => {
const expectedValue = true;
const serverConfig = mock<ServerConfig>();
serverConfig.featureStates = { [testFlag]: expectedValue };
const result = getFeatureFlagValue(serverConfig, testFlag);
expect(result).toBe(expectedValue);
});
});

View File

@@ -1,3 +1,5 @@
import { ServerConfig } from "../platform/abstractions/config/server-config";
/**
* Feature flags.
*
@@ -125,3 +127,14 @@ export const DefaultFeatureFlagValue = {
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
export type FeatureFlagValueType<Flag extends FeatureFlag> = DefaultFeatureFlagValueType[Flag];
export function getFeatureFlagValue<Flag extends FeatureFlag>(
serverConfig: ServerConfig | null,
flag: Flag,
) {
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
return DefaultFeatureFlagValue[flag];
}
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
}

View File

@@ -1,10 +1,13 @@
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
export abstract class BulkEncryptService {
abstract decryptItems<T extends InitializerMetadata>(
items: Decryptable<T>[],
key: SymmetricCryptoKey,
): Promise<T[]>;
abstract onServerConfigChange(newConfig: ServerConfig): void;
}

View File

@@ -1,9 +1,10 @@
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted";
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { Encrypted } from "../../../platform/interfaces/encrypted";
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
export abstract class EncryptService {
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
@@ -54,4 +55,6 @@ export abstract class EncryptService {
value: string | Uint8Array,
algorithm: "sha1" | "sha256" | "sha512",
): Promise<string>;
abstract onServerConfigChange(newConfig: ServerConfig): void;
}

View File

@@ -0,0 +1,170 @@
import { mock, MockProxy } from "jest-mock-extended";
import * as rxjs from "rxjs";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { buildSetConfigMessage } from "../types/worker-command.type";
import { BulkEncryptServiceImplementation } from "./bulk-encrypt.service.implementation";
describe("BulkEncryptServiceImplementation", () => {
const cryptoFunctionService = mock<CryptoFunctionService>();
const logService = mock<LogService>();
let sut: BulkEncryptServiceImplementation;
beforeEach(() => {
sut = new BulkEncryptServiceImplementation(cryptoFunctionService, logService);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("decryptItems", () => {
const key = mock<SymmetricCryptoKey>();
const serverConfig = mock<ServerConfig>();
const mockWorker = mock<Worker>();
let globalWindow: any;
beforeEach(() => {
globalWindow = global.window;
// Mock creating a worker.
global.Worker = jest.fn().mockImplementation(() => mockWorker);
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
global.URL.revokeObjectURL = jest.fn();
global.URL.canParse = jest.fn().mockReturnValue(true);
// Mock the workers returned response.
const mockMessageEvent = {
id: "mock-guid",
data: ["decrypted1", "decrypted2"],
};
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
});
afterEach(() => {
global.window = globalWindow;
});
it("throws error if key is null", async () => {
const nullKey = null as unknown as SymmetricCryptoKey;
await expect(sut.decryptItems([], nullKey)).rejects.toThrow("No encryption key provided.");
});
it("returns an empty array when items is null", async () => {
const result = await sut.decryptItems(null as any, key);
expect(result).toEqual([]);
});
it("returns an empty array when items is empty", async () => {
const result = await sut.decryptItems([], key);
expect(result).toEqual([]);
});
it("decrypts items sequentially when window is undefined", async () => {
// Make global window undefined.
delete (global as any).window;
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
const result = await sut.decryptItems(mockItems, key);
expect(logService.info).toHaveBeenCalledWith(
"Window not available in BulkEncryptService, decrypting sequentially",
);
expect(result).toEqual(["item1", "item2"]);
expect(mockItems[0].decrypt).toHaveBeenCalledWith(key);
expect(mockItems[1].decrypt).toHaveBeenCalledWith(key);
});
it("uses workers for decryption when window is available", async () => {
const mockDecryptedItems = ["decrypted1", "decrypted2"];
jest
.spyOn<any, any>(sut, "getDecryptedItemsFromWorkers")
.mockResolvedValue(mockDecryptedItems);
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
const result = await sut.decryptItems(mockItems, key);
expect(sut["getDecryptedItemsFromWorkers"]).toHaveBeenCalledWith(mockItems, key);
expect(result).toEqual(mockDecryptedItems);
});
it("creates new worker when none exist", async () => {
(sut as any).currentServerConfig = undefined;
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
await sut.decryptItems(mockItems, key);
expect(global.Worker).toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
(sut as any).currentServerConfig = serverConfig;
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
await sut.decryptItems(mockItems, key);
expect(global.Worker).toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
expect(mockWorker.postMessage).toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
it("does not create worker if one exists", async () => {
(sut as any).currentServerConfig = serverConfig;
(sut as any).workers = [mockWorker];
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
await sut.decryptItems(mockItems, key);
expect(global.Worker).not.toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
});
describe("onServerConfigChange", () => {
it("updates internal currentServerConfig to new config", () => {
const newConfig = mock<ServerConfig>();
sut.onServerConfigChange(newConfig);
expect((sut as any).currentServerConfig).toBe(newConfig);
});
it("does send a SetConfigMessage to workers when there is a worker", () => {
const newConfig = mock<ServerConfig>();
const mockWorker = mock<Worker>();
(sut as any).workers = [mockWorker];
sut.onServerConfigChange(newConfig);
expect(mockWorker.postMessage).toHaveBeenCalledWith(buildSetConfigMessage({ newConfig }));
});
});
});
function createMockDecryptable<T extends InitializerMetadata>(
returnValue: any,
): MockProxy<Decryptable<T>> {
const mockDecryptable = mock<Decryptable<T>>();
mockDecryptable.decrypt.mockResolvedValue(returnValue);
return mockDecryptable;
}

View File

@@ -12,6 +12,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
const workerTTL = 60000; // 1 minute
const maxWorkers = 8;
@@ -20,6 +23,7 @@ const minNumberOfItemsForMultithreading = 400;
export class BulkEncryptServiceImplementation implements BulkEncryptService {
private workers: Worker[] = [];
private timeout: any;
private currentServerConfig: ServerConfig | undefined = undefined;
private clear$ = new Subject<void>();
@@ -57,6 +61,11 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
return decryptedItems;
}
onServerConfigChange(newConfig: ServerConfig): void {
this.currentServerConfig = newConfig;
this.updateWorkerServerConfigs(newConfig);
}
/**
* Sends items to a set of web workers to decrypt them. This utilizes multiple workers to decrypt items
* faster without interrupting other operations (e.g. updating UI).
@@ -93,6 +102,9 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
),
);
}
if (this.currentServerConfig != undefined) {
this.updateWorkerServerConfigs(this.currentServerConfig);
}
}
const itemsPerWorker = Math.floor(items.length / this.workers.length);
@@ -108,17 +120,18 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
itemsForWorker.push(...items.slice(end));
}
const request = {
id: Utils.newGuid(),
const id = Utils.newGuid();
const request = buildDecryptMessage({
id,
items: itemsForWorker,
key: key,
};
});
worker.postMessage(JSON.stringify(request));
worker.postMessage(request);
results.push(
firstValueFrom(
fromEvent(worker, "message").pipe(
filter((response: MessageEvent) => response.data?.id === request.id),
filter((response: MessageEvent) => response.data?.id === id),
map((response) => JSON.parse(response.data.items)),
map((items) =>
items.map((jsonItem: Jsonify<T>) => {
@@ -143,6 +156,13 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
return decryptedItems;
}
private updateWorkerServerConfigs(newConfig: ServerConfig) {
this.workers.forEach((worker) => {
const request = buildSetConfigMessage({ newConfig });
worker.postMessage(request);
});
}
private clear() {
this.clear$.next();
for (const worker of this.workers) {

View File

@@ -15,6 +15,7 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { EncryptedObject } from "@bitwarden/common/platform/models/domain/encrypted-object";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { EncryptService } from "../abstractions/encrypt.service";
export class EncryptServiceImplementation implements EncryptService {
@@ -24,6 +25,11 @@ export class EncryptServiceImplementation implements EncryptService {
protected logMacFailures: boolean,
) {}
// Handle updating private properties to turn on/off feature flags.
onServerConfigChange(newConfig: ServerConfig): void {
return;
}
async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString> {
if (key == null) {
throw new Error("No encryption key provided.");

View File

@@ -2,12 +2,19 @@
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { LogService } from "../../../platform/abstractions/log.service";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { ConsoleLogService } from "../../../platform/services/console-log.service";
import { ContainerService } from "../../../platform/services/container.service";
import { getClassInitializer } from "../../../platform/services/cryptography/get-class-initializer";
import { WebCryptoFunctionService } from "../../../platform/services/web-crypto-function.service";
import {
DECRYPT_COMMAND,
SET_CONFIG_COMMAND,
ParsedDecryptCommandData,
} from "../types/worker-command.type";
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
@@ -15,13 +22,14 @@ const workerApi: Worker = self as any;
let inited = false;
let encryptService: EncryptServiceImplementation;
let logService: LogService;
/**
* Bootstrap the worker environment with services required for decryption
*/
export function init() {
const cryptoFunctionService = new WebCryptoFunctionService(self);
const logService = new ConsoleLogService(false);
logService = new ConsoleLogService(false);
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
const bitwardenContainerService = new ContainerService(null, encryptService);
@@ -39,11 +47,22 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
}
const request: {
id: string;
items: Jsonify<Decryptable<any>>[];
key: Jsonify<SymmetricCryptoKey>;
command: string;
} = JSON.parse(event.data);
switch (request.command) {
case DECRYPT_COMMAND:
return await handleDecrypt(request as unknown as ParsedDecryptCommandData);
case SET_CONFIG_COMMAND: {
const newConfig = (request as unknown as { newConfig: Jsonify<ServerConfig> }).newConfig;
return await handleSetConfig(newConfig);
}
default:
logService.error(`[EncryptWorker] unknown worker command`, request.command, request);
}
});
async function handleDecrypt(request: ParsedDecryptCommandData) {
const key = SymmetricCryptoKey.fromJSON(request.key);
const items = request.items.map((jsonItem) => {
const initializer = getClassInitializer<Decryptable<any>>(jsonItem.initializerKey);
@@ -55,4 +74,8 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
id: request.id,
items: JSON.stringify(result),
});
});
}
async function handleSetConfig(newConfig: Jsonify<ServerConfig>) {
encryptService.onServerConfigChange(ServerConfig.fromJSON(newConfig));
}

View File

@@ -0,0 +1,97 @@
import { mock } from "jest-mock-extended";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { BulkEncryptService } from "../abstractions/bulk-encrypt.service";
import { EncryptService } from "../abstractions/encrypt.service";
import { FallbackBulkEncryptService } from "./fallback-bulk-encrypt.service";
describe("FallbackBulkEncryptService", () => {
const encryptService = mock<EncryptService>();
const featureFlagEncryptService = mock<BulkEncryptService>();
const serverConfig = mock<ServerConfig>();
let sut: FallbackBulkEncryptService;
beforeEach(() => {
sut = new FallbackBulkEncryptService(encryptService);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("decryptItems", () => {
const key = mock<SymmetricCryptoKey>();
const mockItems = [{ id: "guid", name: "encryptedValue" }] as any[];
const mockDecryptedItems = [{ id: "guid", name: "decryptedValue" }] as any[];
it("calls decryptItems on featureFlagEncryptService when it is set", async () => {
featureFlagEncryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
const result = await sut.decryptItems(mockItems, key);
expect(featureFlagEncryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
expect(encryptService.decryptItems).not.toHaveBeenCalled();
expect(result).toEqual(mockDecryptedItems);
});
it("calls decryptItems on encryptService when featureFlagEncryptService is not set", async () => {
encryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
const result = await sut.decryptItems(mockItems, key);
expect(encryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
expect(result).toEqual(mockDecryptedItems);
});
});
describe("setFeatureFlagEncryptService", () => {
it("sets the featureFlagEncryptService property", async () => {
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
});
it("does not call onServerConfigChange when currentServerConfig is undefined", async () => {
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
expect(featureFlagEncryptService.onServerConfigChange).not.toHaveBeenCalled();
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
});
it("calls onServerConfigChange with currentServerConfig when it is defined", async () => {
sut.onServerConfigChange(serverConfig);
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
});
});
describe("onServerConfigChange", () => {
it("updates internal currentServerConfig to new config", async () => {
sut.onServerConfigChange(serverConfig);
expect((sut as any).currentServerConfig).toBe(serverConfig);
});
it("calls onServerConfigChange on featureFlagEncryptService when it is set", async () => {
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
sut.onServerConfigChange(serverConfig);
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
expect(encryptService.onServerConfigChange).not.toHaveBeenCalled();
});
it("calls onServerConfigChange on encryptService when featureFlagEncryptService is not set", () => {
sut.onServerConfigChange(serverConfig);
expect(encryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
});
});
});

View File

@@ -5,6 +5,7 @@ import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.i
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { EncryptService } from "../abstractions/encrypt.service";
/**
@@ -12,6 +13,7 @@ import { EncryptService } from "../abstractions/encrypt.service";
*/
export class FallbackBulkEncryptService implements BulkEncryptService {
private featureFlagEncryptService: BulkEncryptService;
private currentServerConfig: ServerConfig | undefined = undefined;
constructor(protected encryptService: EncryptService) {}
@@ -31,6 +33,14 @@ export class FallbackBulkEncryptService implements BulkEncryptService {
}
async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) {
if (this.currentServerConfig !== undefined) {
featureFlagEncryptService.onServerConfigChange(this.currentServerConfig);
}
this.featureFlagEncryptService = featureFlagEncryptService;
}
onServerConfigChange(newConfig: ServerConfig): void {
this.currentServerConfig = newConfig;
(this.featureFlagEncryptService ?? this.encryptService).onServerConfigChange(newConfig);
}
}

View File

@@ -0,0 +1,123 @@
import { mock } from "jest-mock-extended";
import * as rxjs from "rxjs";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { buildSetConfigMessage } from "../types/worker-command.type";
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "./multithread-encrypt.service.implementation";
describe("MultithreadEncryptServiceImplementation", () => {
const cryptoFunctionService = mock<CryptoFunctionService>();
const logService = mock<LogService>();
const serverConfig = mock<ServerConfig>();
let sut: MultithreadEncryptServiceImplementation;
beforeEach(() => {
sut = new MultithreadEncryptServiceImplementation(cryptoFunctionService, logService, true);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("decryptItems", () => {
const key = mock<SymmetricCryptoKey>();
const mockWorker = mock<Worker>();
beforeEach(() => {
// Mock creating a worker.
global.Worker = jest.fn().mockImplementation(() => mockWorker);
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
global.URL.revokeObjectURL = jest.fn();
global.URL.canParse = jest.fn().mockReturnValue(true);
// Mock the workers returned response.
const mockMessageEvent = {
id: "mock-guid",
data: ["decrypted1", "decrypted2"],
};
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
});
it("returns empty array if items is null", async () => {
const items = null as unknown as Decryptable<any>[];
const result = await sut.decryptItems(items, key);
expect(result).toEqual([]);
});
it("returns empty array if items is empty", async () => {
const result = await sut.decryptItems([], key);
expect(result).toEqual([]);
});
it("creates worker if none exists", async () => {
// Make sure currentServerConfig is undefined so a SetConfigMessage is not sent.
(sut as any).currentServerConfig = undefined;
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
expect(global.Worker).toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
// Populate currentServerConfig so a SetConfigMessage is sent.
(sut as any).currentServerConfig = serverConfig;
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
expect(global.Worker).toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
expect(mockWorker.postMessage).toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
it("does not create worker if one exists", async () => {
(sut as any).currentServerConfig = serverConfig;
(sut as any).worker = mockWorker;
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
expect(global.Worker).not.toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
});
describe("onServerConfigChange", () => {
it("updates internal currentServerConfig to new config and calls super", () => {
const superSpy = jest.spyOn(EncryptServiceImplementation.prototype, "onServerConfigChange");
sut.onServerConfigChange(serverConfig);
expect(superSpy).toHaveBeenCalledWith(serverConfig);
expect((sut as any).currentServerConfig).toBe(serverConfig);
});
it("sends config update to worker if worker exists", () => {
const mockWorker = mock<Worker>();
(sut as any).worker = mockWorker;
sut.onServerConfigChange(serverConfig);
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
});
});

View File

@@ -9,6 +9,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
@@ -20,6 +23,7 @@ const workerTTL = 3 * 60000; // 3 minutes
export class MultithreadEncryptServiceImplementation extends EncryptServiceImplementation {
private worker: Worker;
private timeout: any;
private currentServerConfig: ServerConfig | undefined = undefined;
private clear$ = new Subject<void>();
@@ -37,27 +41,33 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
this.logService.info("Starting decryption using multithreading");
this.worker ??= new Worker(
new URL(
/* webpackChunkName: 'encrypt-worker' */
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
import.meta.url,
),
);
if (this.worker == null) {
this.worker = new Worker(
new URL(
/* webpackChunkName: 'encrypt-worker' */
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
import.meta.url,
),
);
if (this.currentServerConfig !== undefined) {
this.updateWorkerServerConfig(this.currentServerConfig);
}
}
this.restartTimeout();
const request = {
id: Utils.newGuid(),
const id = Utils.newGuid();
const request = buildDecryptMessage({
id,
items: items,
key: key,
};
});
this.worker.postMessage(JSON.stringify(request));
this.worker.postMessage(request);
return await firstValueFrom(
fromEvent(this.worker, "message").pipe(
filter((response: MessageEvent) => response.data?.id === request.id),
filter((response: MessageEvent) => response.data?.id === id),
map((response) => JSON.parse(response.data.items)),
map((items) =>
items.map((jsonItem: Jsonify<T>) => {
@@ -71,6 +81,19 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
);
}
override onServerConfigChange(newConfig: ServerConfig): void {
this.currentServerConfig = newConfig;
super.onServerConfigChange(newConfig);
this.updateWorkerServerConfig(newConfig);
}
private updateWorkerServerConfig(newConfig: ServerConfig) {
if (this.worker != null) {
const request = buildSetConfigMessage({ newConfig });
this.worker.postMessage(request);
}
}
private clear() {
this.clear$.next();
this.worker?.terminate();

View File

@@ -0,0 +1,67 @@
import { mock } from "jest-mock-extended";
import { makeStaticByteArray } from "../../../../spec";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import {
DECRYPT_COMMAND,
DecryptCommandData,
SET_CONFIG_COMMAND,
buildDecryptMessage,
buildSetConfigMessage,
} from "./worker-command.type";
describe("Worker command types", () => {
describe("buildDecryptMessage", () => {
it("builds a message with the correct command", () => {
const commandData = createDecryptCommandData();
const result = buildDecryptMessage(commandData);
const parsedResult = JSON.parse(result);
expect(parsedResult.command).toBe(DECRYPT_COMMAND);
});
it("includes the provided data in the message", () => {
const mockItems = [{ encrypted: "test-encrypted" } as unknown as Decryptable<any>];
const commandData = createDecryptCommandData(mockItems);
const result = buildDecryptMessage(commandData);
const parsedResult = JSON.parse(result);
expect(parsedResult.command).toBe(DECRYPT_COMMAND);
expect(parsedResult.id).toBe("test-id");
expect(parsedResult.items).toEqual(mockItems);
expect(SymmetricCryptoKey.fromJSON(parsedResult.key)).toEqual(commandData.key);
});
});
describe("buildSetConfigMessage", () => {
it("builds a message with the correct command", () => {
const result = buildSetConfigMessage({ newConfig: mock<ServerConfig>() });
const parsedResult = JSON.parse(result);
expect(parsedResult.command).toBe(SET_CONFIG_COMMAND);
});
it("includes the provided data in the message", () => {
const serverConfig = { version: "test-version" } as unknown as ServerConfig;
const result = buildSetConfigMessage({ newConfig: serverConfig });
const parsedResult = JSON.parse(result);
expect(parsedResult.command).toBe(SET_CONFIG_COMMAND);
expect(ServerConfig.fromJSON(parsedResult.newConfig).version).toEqual(serverConfig.version);
});
});
});
function createDecryptCommandData(items?: Decryptable<any>[]): DecryptCommandData {
return {
id: "test-id",
items: items ?? [],
key: new SymmetricCryptoKey(makeStaticByteArray(64)),
};
}

View File

@@ -0,0 +1,36 @@
import { Jsonify } from "type-fest";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
export const DECRYPT_COMMAND = "decrypt";
export const SET_CONFIG_COMMAND = "updateConfig";
export type DecryptCommandData = {
id: string;
items: Decryptable<any>[];
key: SymmetricCryptoKey;
};
export type ParsedDecryptCommandData = {
id: string;
items: Jsonify<Decryptable<any>>[];
key: Jsonify<SymmetricCryptoKey>;
};
type SetConfigCommandData = { newConfig: ServerConfig };
export function buildDecryptMessage(data: DecryptCommandData): string {
return JSON.stringify({
command: DECRYPT_COMMAND,
...data,
});
}
export function buildSetConfigMessage(data: SetConfigCommandData): string {
return JSON.stringify({
command: SET_CONFIG_COMMAND,
...data,
});
}

View File

@@ -17,11 +17,7 @@ import { SemVer } from "semver";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import {
DefaultFeatureFlagValue,
FeatureFlag,
FeatureFlagValueType,
} from "../../../enums/feature-flag.enum";
import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ConfigService } from "../../abstractions/config/config.service";
@@ -123,26 +119,13 @@ export class DefaultConfigService implements ConfigService {
}
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {
return this.serverConfig$.pipe(
map((serverConfig) => this.getFeatureFlagValue(serverConfig, key)),
);
}
private getFeatureFlagValue<Flag extends FeatureFlag>(
serverConfig: ServerConfig | null,
flag: Flag,
) {
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
return DefaultFeatureFlagValue[flag];
}
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
return this.serverConfig$.pipe(map((serverConfig) => getFeatureFlagValue(serverConfig, key)));
}
userCachedFeatureFlag$<Flag extends FeatureFlag>(key: Flag, userId: UserId) {
return this.stateProvider
.getUser(userId, USER_SERVER_CONFIG)
.state$.pipe(map((config) => this.getFeatureFlagValue(config, key)));
.state$.pipe(map((config) => getFeatureFlagValue(config, key)));
}
async getFeatureFlag<Flag extends FeatureFlag>(key: Flag) {

View File

@@ -206,3 +206,4 @@ export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk");

View File

@@ -31,7 +31,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
abstract failedToDecryptCiphers$(userId: UserId): Observable<CipherView[]>;
abstract failedToDecryptCiphers$(userId: UserId): Observable<CipherView[] | null>;
abstract clearCache(userId: UserId): Promise<void>;
abstract encrypt(
model: CipherView,

View File

@@ -1,7 +1,8 @@
import { Observable } from "rxjs";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { SecurityTask } from "@bitwarden/vault";
import { SecurityTask } from "../models";
export abstract class TaskService {
/**

View File

@@ -1,19 +1,18 @@
import { TestBed } from "@angular/core/testing";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { DefaultTaskService, SecurityTaskStatus } from "@bitwarden/vault";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec";
import { SecurityTaskData } from "../models/security-task.data";
import { SecurityTaskResponse } from "../models/security-task.response";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { SecurityTaskStatus } from "../enums";
import { SecurityTaskData, SecurityTaskResponse } from "../models";
import { SECURITY_TASKS } from "../state/security-task.state";
import { DefaultTaskService } from "./default-task.service";
describe("Default task service", () => {
let fakeStateProvider: FakeStateProvider;
@@ -21,7 +20,7 @@ describe("Default task service", () => {
const mockGetAllOrgs$ = jest.fn();
const mockGetFeatureFlag$ = jest.fn();
let testBed: TestBed;
let service: DefaultTaskService;
beforeEach(async () => {
mockApiSend.mockClear();
@@ -29,34 +28,12 @@ describe("Default task service", () => {
mockGetFeatureFlag$.mockClear();
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
testBed = TestBed.configureTestingModule({
imports: [],
providers: [
DefaultTaskService,
{
provide: ConfigService,
useValue: {
getFeatureFlag$: mockGetFeatureFlag$,
},
},
{
provide: StateProvider,
useValue: fakeStateProvider,
},
{
provide: ApiService,
useValue: {
send: mockApiSend,
},
},
{
provide: OrganizationService,
useValue: {
organizations$: mockGetAllOrgs$,
},
},
],
});
service = new DefaultTaskService(
fakeStateProvider,
{ send: mockApiSend } as unknown as ApiService,
{ organizations$: mockGetAllOrgs$ } as unknown as OrganizationService,
{ getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService,
);
});
describe("tasksEnabled$", () => {
@@ -73,7 +50,7 @@ describe("Default task service", () => {
] as Organization[]),
);
const { tasksEnabled$ } = testBed.inject(DefaultTaskService);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
@@ -93,7 +70,7 @@ describe("Default task service", () => {
] as Organization[]),
);
const { tasksEnabled$ } = testBed.inject(DefaultTaskService);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
@@ -110,7 +87,7 @@ describe("Default task service", () => {
] as Organization[]),
);
const { tasksEnabled$ } = testBed.inject(DefaultTaskService);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
@@ -130,7 +107,7 @@ describe("Default task service", () => {
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null as any);
const { tasks$ } = testBed.inject(DefaultTaskService);
const { tasks$ } = service;
const result = await firstValueFrom(tasks$("user-id" as UserId));
@@ -145,7 +122,7 @@ describe("Default task service", () => {
} as SecurityTaskData,
]);
const { tasks$ } = testBed.inject(DefaultTaskService);
const { tasks$ } = service;
const result = await firstValueFrom(tasks$("user-id" as UserId));
@@ -154,7 +131,7 @@ describe("Default task service", () => {
});
it("should share the same observable for the same user", async () => {
const { tasks$ } = testBed.inject(DefaultTaskService);
const { tasks$ } = service;
const first = tasks$("user-id" as UserId);
const second = tasks$("user-id" as UserId);
@@ -176,7 +153,7 @@ describe("Default task service", () => {
},
] as SecurityTaskData[]);
const { pendingTasks$ } = testBed.inject(DefaultTaskService);
const { pendingTasks$ } = service;
const result = await firstValueFrom(pendingTasks$("user-id" as UserId));
@@ -195,8 +172,6 @@ describe("Default task service", () => {
] as SecurityTaskResponse[],
});
const service = testBed.inject(DefaultTaskService);
await service.refreshTasks("user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);
@@ -217,8 +192,6 @@ describe("Default task service", () => {
null as any,
);
const service = testBed.inject(DefaultTaskService);
await service.refreshTasks("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([
@@ -237,8 +210,6 @@ describe("Default task service", () => {
} as SecurityTaskData,
]);
const service = testBed.inject(DefaultTaskService);
await service.clear("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([]);
@@ -247,8 +218,6 @@ describe("Default task service", () => {
describe("markAsComplete()", () => {
it("should send an API request to mark the task as complete", async () => {
const service = testBed.inject(DefaultTaskService);
await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith(
@@ -278,8 +247,6 @@ describe("Default task service", () => {
} as SecurityTaskData,
]);
const service = testBed.inject(DefaultTaskService);
await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);

View File

@@ -1,4 +1,3 @@
import { Injectable } from "@angular/core";
import { combineLatest, map, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -8,14 +7,13 @@ import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/vault";
import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities";
import { SecurityTaskData } from "../models/security-task.data";
import { SecurityTaskResponse } from "../models/security-task.response";
import { TaskService } from "../abstractions/task.service";
import { SecurityTaskStatus } from "../enums";
import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models";
import { SECURITY_TASKS } from "../state/security-task.state";
@Injectable()
export class DefaultTaskService implements TaskService {
constructor(
private stateProvider: StateProvider,

View File

@@ -48,11 +48,12 @@ export class MenuTriggerForDirective implements OnDestroy {
overlayX: "start",
overlayY: "top",
},
// Fallback position: show above the trigger
{
originX: "end",
originY: "bottom",
overlayX: "end",
overlayY: "top",
originX: "start",
originY: "top",
overlayX: "start",
overlayY: "bottom",
},
])
.withLockedPosition(true)

View File

@@ -14,11 +14,8 @@ or an options menu icon.
| Icon | bwi-name | Usage |
| -------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <i class="bwi bwi-ban"></i> | bwi-ban | option or feature not available. Example: send maximum access count was reached |
| <i class="bwi bwi-check"></i> | bwi-check | confirmation action (Example: "confirm member"), successful confirmation (toast or callout), or shows currently selected option in a menu. Use with success color variable if applicable. |
| <i class="bwi bwi-error"></i> | bwi-error | error; used in form field error states and error toasts, banners, and callouts. Do not use as a close or clear icon. Use with danger color variable. |
| <i class="bwi bwi-expired"></i> | bwi-expired | - |
| <i class="bwi bwi-exclamation-circle"></i> | bwi-exclamation-circle | deprecated error icon; use bwi-error |
| <i class="bwi bwi-exclamation-triangle"></i> | bwi-exclamation-triangle | warning; used in warning callouts, banners, and toasts. Use with warning color variable. |
| <i class="bwi bwi-info-circle"></i> | bwi-info-circle | information; used in info callouts, banners, and toasts. Use with info color variable. |
| <i class="bwi bwi-question-circle"></i> | bwi-question-circle | link to help documentation or hover tooltip |
@@ -26,34 +23,31 @@ or an options menu icon.
## Bitwarden Objects
| Icon | bwi-name | Usage |
| ------------------------------------- | ----------------- | --------------------------------------------------- |
| <i class="bwi bwi-authenticator"></i> | bwi-authenticator | authenticator app |
| <i class="bwi bwi-business"></i> | bwi-business | organization or vault for Free, Teams or Enterprise |
| <i class="bwi bwi-collection"></i> | bwi-collection | collection |
| <i class="bwi bwi-credit-card"></i> | bwi-credit-card | card item type |
| <i class="bwi bwi-family"></i> | bwi-family | family vault or organization |
| <i class="bwi bwi-folder"></i> | bwi-folder | folder |
| <i class="bwi bwi-globe"></i> | bwi-globe | login item type |
| <i class="bwi bwi-id-card"></i> | bwi-id-card | identity item type |
| <i class="bwi bwi-send"></i> | bwi-send | send action or feature |
| <i class="bwi bwi-send-f"></i> | bwi-send-f | - |
| <i class="bwi bwi-sticky-note"></i> | bwi-sticky-note | secure note item type |
| <i class="bwi bwi-users"></i> | bwi-users | user group |
| <i class="bwi bwi-vault"></i> | bwi-vault | general vault |
| <i class="bwi bwi-vault-f"></i> | bwi-vault-f | general vault |
| Icon | bwi-name | Usage |
| ----------------------------------- | --------------- | --------------------------------------------------- |
| <i class="bwi bwi-business"></i> | bwi-business | organization or vault for Free, Teams or Enterprise |
| <i class="bwi bwi-collection"></i> | bwi-collection | collection |
| <i class="bwi bwi-credit-card"></i> | bwi-credit-card | card item type |
| <i class="bwi bwi-family"></i> | bwi-family | family vault or organization |
| <i class="bwi bwi-folder"></i> | bwi-folder | folder |
| <i class="bwi bwi-globe"></i> | bwi-globe | login item type |
| <i class="bwi bwi-id-card"></i> | bwi-id-card | identity item type |
| <i class="bwi bwi-send"></i> | bwi-send | send action or feature |
| <i class="bwi bwi-send-f"></i> | bwi-send-f | - |
| <i class="bwi bwi-sticky-note"></i> | bwi-sticky-note | secure note item type |
| <i class="bwi bwi-users"></i> | bwi-users | user group |
| <i class="bwi bwi-vault"></i> | bwi-vault | general vault |
| <i class="bwi bwi-vault-f"></i> | bwi-vault-f | general vault |
## Actions
| Icon | bwi-name | Usage |
| -------------------------------------- | ------------------ | -------------------------------------------- |
| <i class="bwi bwi-check-circle"></i> | bwi-check-circle | check if password has been exposed |
| <i class="bwi bwi-check-square"></i> | bwi-check-square | select all action |
| <i class="bwi bwi-clone"></i> | bwi-clone | copy to clipboard action |
| <i class="bwi bwi-close"></i> | bwi-close | close action |
| <i class="bwi bwi-cog"></i> | bwi-cog | settings |
| <i class="bwi bwi-cog-f"></i> | bwi-cog-f | settings |
| <i class="bwi bwi-cogs"></i> | bwi-cogs | deprecated; do not use in app. |
| <i class="bwi bwi-download"></i> | bwi-download | download or export |
| <i class="bwi bwi-envelope"></i> | bwi-envelope | action related to emailing a user |
| <i class="bwi bwi-external-link"></i> | bwi-external-link | open in new window or popout |
@@ -66,141 +60,81 @@ or an options menu icon.
| <i class="bwi bwi-lock-encrypted"></i> | bwi-lock-encrypted | - |
| <i class="bwi bwi-lock-f"></i> | bwi-lock-f | - |
| <i class="bwi bwi-minus-circle"></i> | bwi-minus-circle | remove action |
| <i class="bwi bwi-minus-square"></i> | bwi-minus-square | unselect all action |
| <i class="bwi bwi-paste"></i> | bwi-paste | paste from clipboard action |
| <i class="bwi bwi-pencil-square"></i> | bwi-pencil-square | edit action |
| <i class="bwi bwi-popout"></i> | bwi-popout | popout action |
| <i class="bwi bwi-play"></i> | bwi-play | start or play action |
| <i class="bwi bwi-plus"></i> | bwi-plus | new or add option in contained buttons/links |
| <i class="bwi bwi-plus-f"></i> | bwi-plus-f | new or add option in contained buttons/links |
| <i class="bwi bwi-plus-circle"></i> | bwi-plus-circle | new or add option in text buttons/links |
| <i class="bwi bwi-plus-square"></i> | bwi-plus-square | - |
| <i class="bwi bwi-refresh"></i> | bwi-refresh | "re"-action; such as refresh or regenerate |
| <i class="bwi bwi-refresh-tab"></i> | bwi-refresh-tab | - |
| <i class="bwi bwi-save"></i> | bwi-save | alternate download action |
| <i class="bwi bwi-save-changes"></i> | bwi-save-changes | save changes action |
| <i class="bwi bwi-search"></i> | bwi-search | search action |
| <i class="bwi bwi-share"></i> | bwi-share | - |
| <i class="bwi bwi-share-arrow"></i> | bwi-share-arrow | - |
| <i class="bwi bwi-share-square"></i> | bwi-share-square | avoid using; use external-link instead |
| <i class="bwi bwi-sign-in"></i> | bwi-sign-in | sign-in action |
| <i class="bwi bwi-sign-out"></i> | bwi-sign-out | sign-out action |
| <i class="bwi bwi-star"></i> | bwi-star | favorite action |
| <i class="bwi bwi-star-f"></i> | bwi-star-f | favorited / unfavorite action |
| <i class="bwi bwi-stop"></i> | bwi-stop | stop action |
| <i class="bwi bwi-trash"></i> | bwi-trash | delete action or trash area |
| <i class="bwi bwi-undo"></i> | bwi-undo | restore action |
| <i class="bwi bwi-unlock"></i> | bwi-unlock | unlocked |
## Directional and Menu Indicators
| Icon | bwi-name | Usage |
| ------------------------------------------ | ---------------------- | ------------------------------------------------------- |
| <i class="bwi bwi-angle-down"></i> | bwi-angle-down | closed dropdown or open expandable section |
| <i class="bwi bwi-angle-left"></i> | bwi-angle-left | - |
| <i class="bwi bwi-angle-right"></i> | bwi-angle-right | closed expandable section |
| <i class="bwi bwi-angle-up"></i> | bwi-angle-up | open dropdown |
| <i class="bwi bwi-arrow-circle-down"></i> | bwi-arrow-circle-down | - |
| <i class="bwi bwi-arrow-circle-left"></i> | bwi-arrow-circle-left | - |
| <i class="bwi bwi-arrow-circle-right"></i> | bwi-arrow-circle-right | - |
| <i class="bwi bwi-arrow-circle-up"></i> | bwi-arrow-circle-up | - |
| <i class="bwi bwi-back"></i> | bwi-back | back arrow |
| <i class="bwi bwi-caret-down"></i> | bwi-caret-down | table sort order |
| <i class="bwi bwi-caret-right"></i> | bwi-caret-right | - |
| <i class="bwi bwi-caret-up"></i> | bwi-caret-up | table sort order |
| <i class="bwi bwi-dbl-angle-left"></i> | bwi-dbl-angle-left | - |
| <i class="bwi bwi-dbl-angle-right"></i> | bwi-dbl-angle-right | - |
| <i class="bwi bwi-down-solid"></i> | bwi-down-solid | table sort order |
| <i class="bwi bwi-ellipsis-h"></i> | bwi-ellipsis-h | more options menu horizontal; used in mobile list items |
| <i class="bwi bwi-ellipsis-v"></i> | bwi-ellipsis-v | more options menu vertical; used primarily in tables |
| <i class="bwi bwi-filter"></i> | bwi-filter | Product switcher |
| <i class="bwi bwi-hamburger"></i> | bwi-hamburger | navigation indicator |
| <i class="bwi bwi-list"></i> | bwi-list | toggle list/grid view |
| <i class="bwi bwi-list-alt"></i> | bwi-list-alt | view item action in extension |
| <i class="bwi bwi-long-arrow-right"></i> | bwi-long-arrow-right | - |
| <i class="bwi bwi-numbered-list"></i> | bwi-numbered-list | toggle numbered list view |
| <i class="bwi bwi-up-down-btn"></i> | bwi-up-down-btn | table sort order |
| <i class="bwi bwi-up-solid"></i> | bwi-up-solid | table sort order |
| Icon | bwi-name | Usage |
| ------------------------------------- | ----------------- | ------------------------------------------------------- |
| <i class="bwi bwi-angle-down"></i> | bwi-angle-down | closed dropdown or open expandable section |
| <i class="bwi bwi-angle-left"></i> | bwi-angle-left | - |
| <i class="bwi bwi-angle-right"></i> | bwi-angle-right | closed expandable section |
| <i class="bwi bwi-angle-up"></i> | bwi-angle-up | open dropdown |
| <i class="bwi bwi-back"></i> | bwi-back | back arrow |
| <i class="bwi bwi-down-solid"></i> | bwi-down-solid | table sort order |
| <i class="bwi bwi-ellipsis-h"></i> | bwi-ellipsis-h | more options menu horizontal; used in mobile list items |
| <i class="bwi bwi-ellipsis-v"></i> | bwi-ellipsis-v | more options menu vertical; used primarily in tables |
| <i class="bwi bwi-filter"></i> | bwi-filter | Product switcher |
| <i class="bwi bwi-hamburger"></i> | bwi-hamburger | navigation indicator |
| <i class="bwi bwi-list"></i> | bwi-list | toggle list/grid view |
| <i class="bwi bwi-list-alt"></i> | bwi-list-alt | view item action in extension |
| <i class="bwi bwi-numbered-list"></i> | bwi-numbered-list | toggle numbered list view |
| <i class="bwi bwi-up-down-btn"></i> | bwi-up-down-btn | table sort order |
| <i class="bwi bwi-up-solid"></i> | bwi-up-solid | table sort order |
## Misc Objects
| Icon | bwi-name | Usage |
| ----------------------------------------- | --------------------- | ---------------------------------------------- |
| <i class="bwi bwi-bank"></i> | bwi-bank | - |
| <i class="bwi bwi-billing"></i> | bwi-billing | billing options |
| <i class="bwi bwi-bitcoin"></i> | bwi-bitcoin | crypto |
| <i class="bwi bwi-bolt"></i> | bwi-bolt | deprecated "danger" icon |
| <i class="bwi bwi-bookmark"></i> | bwi-bookmark | bookmark or save related actions |
| <i class="bwi bwi-browser"></i> | bwi-browser | web browser |
| <i class="bwi bwi-browser-alt"></i> | bwi-browser-alt | web browser |
| <i class="bwi bwi-bug"></i> | bwi-bug | test or debug action |
| <i class="bwi bwi-camera"></i> | bwi-camera | actions related to camera use |
| <i class="bwi bwi-chain-broken"></i> | bwi-chain-broken | unlink action |
| <i class="bwi bwi-chat"></i> | bwi-chat | - |
| <i class="bwi bwi-cli"></i> | bwi-cli | cli client or code |
| <i class="bwi bwi-clock"></i> | bwi-clock | use for time based actions or views |
| <i class="bwi bwi-community"></i> | bwi-community | - |
| <i class="bwi bwi-cut"></i> | bwi-cut | cut or omit actions |
| <i class="bwi bwi-dashboard"></i> | bwi-dashboard | statuses or dashboard views |
| <i class="bwi bwi-desktop"></i> | bwi-desktop | desktop client |
| <i class="bwi bwi-desktop-alt"></i> | bwi-desktop-alt | desktop client |
| <i class="bwi bwi-dollar"></i> | bwi-dollar | account credit |
| <i class="bwi bwi-file"></i> | bwi-file | file related objects or actions |
| <i class="bwi bwi-file-pdf"></i> | bwi-file-pdf | PDF related object or actions |
| <i class="bwi bwi-file-text"></i> | bwi-file-text | text related objects or actions |
| <i class="bwi bwi-fingerprint"></i> | bwi-fingerprint | - |
| <i class="bwi bwi-bw-folder-open-f1"></i> | bwi-bw-folder-open-f1 | - |
| <i class="bwi bwi-folder-closed-f"></i> | bwi-folder-closed-f | - |
| <i class="bwi bwi-folder-open"></i> | bwi-folder-open | - |
| <i class="bwi bwi-frown"></i> | bwi-frown | - |
| <i class="bwi bwi-hashtag"></i> | bwi-hashtag | link to specific id |
| <i class="bwi bwi-icon-1"></i> | bwi-icon-1 | - |
| <i class="bwi bwi-icon-2"></i> | bwi-icon-2 | - |
| <i class="bwi bwi-icon-3"></i> | bwi-icon-3 | - |
| <i class="bwi bwi-icon-4"></i> | bwi-icon-4 | - |
| <i class="bwi bwi-icon-5"></i> | bwi-icon-5 | - |
| <i class="bwi bwi-icon-6"></i> | bwi-icon-6 | - |
| <i class="bwi bwi-icon-7"></i> | bwi-icon-7 | - |
| <i class="bwi bwi-icon-8"></i> | bwi-icon-8 | - |
| <i class="bwi bwi-icon-9"></i> | bwi-icon-9 | - |
| <i class="bwi bwi-insurance"></i> | bwi-insurance | - |
| <i class="bwi bwi-key"></i> | bwi-key | key or password related objects or actions |
| <i class="bwi bwi-learning"></i> | bwi-learning | learning center |
| <i class="bwi bwi-lightbulb"></i> | bwi-lightbulb | - |
| <i class="bwi bwi-link"></i> | bwi-link | link action |
| <i class="bwi bwi-mobile"></i> | bwi-mobile | mobile client |
| <i class="bwi bwi-mobile-alt"></i> | bwi-mobile-alt | mobile client |
| <i class="bwi bwi-money"></i> | bwi-money | - |
| <i class="bwi bwi-msp"></i> | bwi-msp | - |
| <i class="bwi bwi-paperclip"></i> | bwi-paperclip | attachments |
| <i class="bwi bwi-passkey"></i> | bwi-passkey | passkey |
| <i class="bwi bwi-pencil"></i> | bwi-pencil | editing |
| <i class="bwi bwi-provider"></i> | bwi-provider | relates to provider or provider portal |
| <i class="bwi bwi-providers"></i> | bwi-providers | - |
| <i class="bwi bwi-puzzle"></i> | bwi-puzzle | - |
| <i class="bwi bwi-rocket"></i> | bwi-rocket | - |
| <i class="bwi bwi-rss"></i> | bwi-rss | - |
| <i class="bwi bwi-search-book"></i> | bwi-search-book | - |
| <i class="bwi bwi-server"></i> | bwi-server | - |
| <i class="bwi bwi-shield"></i> | bwi-shield | - |
| <i class="bwi bwi-sitemap"></i> | bwi-sitemap | - |
| <i class="bwi bwi-sliders"></i> | bwi-sliders | reporting or filtering |
| <i class="bwi bwi-software-license"></i> | bwi-software-license | - |
| <i class="bwi bwi-square"></i> | bwi-square | - |
| <i class="bwi bwi-tag"></i> | bwi-tag | labels |
| <i class="bwi bwi-thumb-tack"></i> | bwi-thumb-tack | - |
| <i class="bwi bwi-thumbs-up"></i> | bwi-thumbs-up | - |
| <i class="bwi bwi-totp-codes"></i> | bwi-totp-codes | - |
| <i class="bwi bwi-totp-codes-alt"></i> | bwi-totp-codes-alt | - |
| <i class="bwi bwi-totp-codes-alt2"></i> | bwi-totp-codes-alt2 | - |
| <i class="bwi bwi-universal-access"></i> | bwi-universal-access | use for accessibility related actions |
| <i class="bwi bwi-user"></i> | bwi-user | relates to current user or organization member |
| <i class="bwi bwi-user-circle"></i> | bwi-user-circle | - |
| <i class="bwi bwi-user-f"></i> | bwi-user-f | - |
| <i class="bwi bwi-user-monitor"></i> | bwi-user-monitor | - |
| <i class="bwi bwi-wand"></i> | bwi-wand | - |
| <i class="bwi bwi-wireless"></i> | bwi-wireless | - |
| <i class="bwi bwi-wrench"></i> | bwi-wrench | tools or additional configuration options |
| Icon | bwi-name | Usage |
| ---------------------------------------- | -------------------- | ---------------------------------------------- |
| <i class="bwi bwi-billing"></i> | bwi-billing | billing options |
| <i class="bwi bwi-bitcoin"></i> | bwi-bitcoin | crypto |
| <i class="bwi bwi-browser"></i> | bwi-browser | web browser |
| <i class="bwi bwi-browser-alt"></i> | bwi-browser-alt | web browser |
| <i class="bwi bwi-bug"></i> | bwi-bug | test or debug action |
| <i class="bwi bwi-camera"></i> | bwi-camera | actions related to camera use |
| <i class="bwi bwi-cli"></i> | bwi-cli | cli client or code |
| <i class="bwi bwi-clock"></i> | bwi-clock | use for time based actions or views |
| <i class="bwi bwi-community"></i> | bwi-community | - |
| <i class="bwi bwi-desktop"></i> | bwi-desktop | desktop client |
| <i class="bwi bwi-dollar"></i> | bwi-dollar | account credit |
| <i class="bwi bwi-file"></i> | bwi-file | file related objects or actions |
| <i class="bwi bwi-file-text"></i> | bwi-file-text | text related objects or actions |
| <i class="bwi bwi-hashtag"></i> | bwi-hashtag | link to specific id |
| <i class="bwi bwi-key"></i> | bwi-key | key or password related objects or actions |
| <i class="bwi bwi-link"></i> | bwi-link | link action |
| <i class="bwi bwi-mobile"></i> | bwi-mobile | mobile client |
| <i class="bwi bwi-msp"></i> | bwi-msp | - |
| <i class="bwi bwi-paperclip"></i> | bwi-paperclip | attachments |
| <i class="bwi bwi-passkey"></i> | bwi-passkey | passkey |
| <i class="bwi bwi-pencil"></i> | bwi-pencil | editing |
| <i class="bwi bwi-provider"></i> | bwi-provider | relates to provider or provider portal |
| <i class="bwi bwi-puzzle"></i> | bwi-puzzle | - |
| <i class="bwi bwi-shield"></i> | bwi-shield | - |
| <i class="bwi bwi-sliders"></i> | bwi-sliders | reporting or filtering |
| <i class="bwi bwi-tag"></i> | bwi-tag | labels |
| <i class="bwi bwi-totp-codes"></i> | bwi-totp-codes | - |
| <i class="bwi bwi-totp-codes-alt"></i> | bwi-totp-codes-alt | - |
| <i class="bwi bwi-totp-codes-alt2"></i> | bwi-totp-codes-alt2 | - |
| <i class="bwi bwi-universal-access"></i> | bwi-universal-access | use for accessibility related actions |
| <i class="bwi bwi-user"></i> | bwi-user | relates to current user or organization member |
| <i class="bwi bwi-user-monitor"></i> | bwi-user-monitor | - |
| <i class="bwi bwi-wireless"></i> | bwi-wireless | - |
| <i class="bwi bwi-wrench"></i> | bwi-wrench | tools or additional configuration options |
## Platforms and Logos

View File

@@ -736,6 +736,52 @@ describe("keyService", () => {
});
});
describe("getOrDeriveMasterKey", () => {
it("returns the master key if it is already available", async () => {
const getMasterKey = jest
.spyOn(masterPasswordService, "masterKey$")
.mockReturnValue(of("masterKey" as any));
const result = await keyService.getOrDeriveMasterKey("password", mockUserId);
expect(getMasterKey).toHaveBeenCalledWith(mockUserId);
expect(result).toEqual("masterKey");
});
it("derives the master key if it is not available", async () => {
const getMasterKey = jest
.spyOn(masterPasswordService, "masterKey$")
.mockReturnValue(of(null as any));
const deriveKeyFromPassword = jest
.spyOn(keyGenerationService, "deriveKeyFromPassword")
.mockResolvedValue("mockMasterKey" as any);
kdfConfigService.getKdfConfig$.mockReturnValue(of("mockKdfConfig" as any));
const result = await keyService.getOrDeriveMasterKey("password", mockUserId);
expect(getMasterKey).toHaveBeenCalledWith(mockUserId);
expect(deriveKeyFromPassword).toHaveBeenCalledWith("password", "email", "mockKdfConfig");
expect(result).toEqual("mockMasterKey");
});
it("throws an error if no user is found", async () => {
accountService.activeAccountSubject.next(null);
await expect(keyService.getOrDeriveMasterKey("password")).rejects.toThrow("No user found");
});
it("throws an error if no kdf config is found", async () => {
jest.spyOn(masterPasswordService, "masterKey$").mockReturnValue(of(null as any));
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
await expect(keyService.getOrDeriveMasterKey("password", mockUserId)).rejects.toThrow(
"No kdf found for user",
);
});
});
describe("compareKeyHash", () => {
type TestCase = {
masterKey: MasterKey;

View File

@@ -287,10 +287,15 @@ export class DefaultKeyService implements KeyServiceAbstraction {
),
);
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(resolvedUserId));
return (
masterKey ||
(await this.makeMasterKey(password, email, await this.kdfConfigService.getKdfConfig()))
);
if (masterKey != null) {
return masterKey;
}
const kdf = await firstValueFrom(this.kdfConfigService.getKdfConfig$(resolvedUserId));
if (kdf == null) {
throw new Error("No kdf found for user");
}
return await this.makeMasterKey(password, email, kdf);
}
/**

View File

@@ -10,6 +10,7 @@
<vault-autofill-uri-option
*ngFor="let uri of uriControls; let i = index"
cdkDrag
[cdkDragDisabled]="uriControls.length <= 1"
[formControlName]="i"
(remove)="removeUri(i)"
(onKeydown)="onUriItemKeydown($event, i)"

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