1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 10:23:52 +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 */