1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-20 03:13:55 +00:00

Merge branch 'km/sdk-550' into km/sdk-key-rotation

This commit is contained in:
Bernd Schoolmann
2026-02-19 14:09:18 +01:00
committed by GitHub
88 changed files with 3803 additions and 830 deletions

1
.github/CODEOWNERS vendored
View File

@@ -66,6 +66,7 @@ apps/web/src/locales @bitwarden/team-platform-dev
apps/browser/src/vault @bitwarden/team-vault-dev
apps/cli/src/vault @bitwarden/team-vault-dev
apps/desktop/src/vault @bitwarden/team-vault-dev
apps/web/src/app/shared/components/onboarding @bitwarden/team-vault-dev
apps/web/src/app/vault @bitwarden/team-vault-dev
libs/angular/src/vault @bitwarden/team-vault-dev
libs/common/src/vault @bitwarden/team-vault-dev

View File

@@ -97,4 +97,3 @@ jobs:
artifacts: "apps/web/artifacts/web-${{ needs.setup.outputs.release_version }}-selfhosted-COMMERCIAL.zip,
apps/web/artifacts/web-${{ needs.setup.outputs.release_version }}-selfhosted-open-source.zip"
token: ${{ secrets.GITHUB_TOKEN }}
draft: true

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2026.1.0",
"version": "2026.1.1",
"scripts": {
"build": "npm run build:chrome",
"build:bit": "npm run build:bit:chrome",

View File

@@ -896,6 +896,9 @@
"invalidVerificationCode": {
"message": "Invalid verification code"
},
"invalidEmailOrVerificationCode": {
"message": "Invalid email or verification code"
},
"valueCopied": {
"message": "$VALUE$ copied",
"description": "Value has been copied to the clipboard.",
@@ -6160,10 +6163,12 @@
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailsRequiredChangeAccessType": {
"message": "Email verification requires at least one email address. To remove all emails, change the access type above."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
},
"downloadBitwardenApps": {
"message": "Download Bitwarden apps"
},
@@ -6173,5 +6178,8 @@
"sendPasswordHelperText": {
"message": "Individuals will need to enter the password to view this Send",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"userVerificationFailed": {
"message": "User verification failed."
}
}

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2026.1.0",
"version": "2026.1.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "Bitwarden",
"version": "2026.1.0",
"version": "2026.1.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,11 +3,7 @@
import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core";
import { merge, of, Subject } from "rxjs";
import {
CollectionService,
OrganizationUserApiService,
OrganizationUserService,
} from "@bitwarden/admin-console/common";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceManagementComponentServiceAbstraction } from "@bitwarden/angular/auth/device-management/device-management-component.service.abstraction";
import { ChangePasswordService } from "@bitwarden/angular/auth/password-management/change-password";
import { AngularThemingService } from "@bitwarden/angular/platform/services/theming/angular-theming.service";
@@ -48,19 +44,13 @@ import {
LogoutService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service";
import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service";
import { BrowserRouterService } from "@bitwarden/browser/platform/popup/services/browser-router.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import {
InternalOrganizationServiceAbstraction,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import {
AccountService,
@@ -776,19 +766,6 @@ const safeProviders: SafeProvider[] = [
useClass: ExtensionNewDeviceVerificationComponentService,
deps: [],
}),
safeProvider({
provide: AutomaticUserConfirmationService,
useClass: DefaultAutomaticUserConfirmationService,
deps: [
ConfigService,
ApiService,
OrganizationUserService,
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
PolicyService,
],
}),
safeProvider({
provide: SessionTimeoutTypeService,
useClass: BrowserSessionTimeoutTypeService,

View File

@@ -6,8 +6,8 @@
(onRefresh)="refreshCurrentTab()"
[description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : undefined"
isAutofillList
showAutofillButton
[disableDescriptionMargin]="showEmptyAutofillTip$ | async"
[groupByType]="groupByType()"
[showAutofillButton]="(clickItemsToAutofillVaultView$ | async) === false"
[primaryActionAutofill]="clickItemsToAutofillVaultView$ | async"
></app-vault-list-items-container>

View File

@@ -90,7 +90,13 @@
</ng-container>
<cdk-virtual-scroll-viewport [itemSize]="itemHeight$ | async" bitScrollLayout>
<bit-item *cdkVirtualFor="let cipher of group.ciphers" class="tw-group/vault-item">
<bit-item
*cdkVirtualFor="let cipher of group.ciphers"
class="tw-group/vault-item"
[ngClass]="{
'hover:tw-bg-hover-default hover:tw-cursor-pointer': showFillTextOnHover(),
}"
>
<button
bit-item-content
type="button"
@@ -127,11 +133,13 @@
<ng-container slot="end">
@if (showFillTextOnHover()) {
<bit-item-action>
<span
class="tw-opacity-0 tw-text-sm tw-text-primary-600 tw-px-2 group-hover/vault-item:tw-opacity-100 group-focus-within/vault-item:tw-opacity-100 tw-cursor-pointer"
<button
type="button"
(click)="doAutofill(cipher)"
class="tw-opacity-0 tw-text-primary-600 tw-px-2 tw-bg-transparent tw-border-0 group-hover/vault-item:tw-opacity-100 group-focus-within/vault-item:tw-opacity-100"
>
{{ "fill" | i18n }}
</span>
</button>
</bit-item-action>
}
@if (showAutofillBadge()) {

View File

@@ -302,8 +302,9 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
if (this.currentUriIsBlocked()) {
return false;
}
return this.isAutofillList()
? this.simplifiedItemActionEnabled()
return this.simplifiedItemActionEnabled()
? this.isAutofillList()
: this.primaryActionAutofill();
});

View File

@@ -127,7 +127,7 @@
<!--Change the title header when a filter is applied-->
<app-vault-list-items-container
[title]="((numberOfAppliedFilters$ | async) === 0 ? 'allItems' : 'items') | i18n"
[ciphers]="(remainingCiphers$ | async) || []"
[ciphers]="(filteredCiphers$ | async) || []"
id="allItems"
disableSectionMargin
collapsibleKey="allItems"

View File

@@ -164,7 +164,6 @@ export class VaultComponent implements OnInit, OnDestroy {
protected filteredCiphers$ = this.vaultPopupItemsService.filteredCiphers$;
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;
protected cipherCount$ = this.vaultPopupItemsService.cipherCount$;
protected hasPremium$ = this.activeUserId$.pipe(

View File

@@ -370,37 +370,6 @@ describe("VaultPopupItemsService", () => {
});
});
describe("remainingCiphers$", () => {
beforeEach(() => {
searchService.isSearchable.mockImplementation(async (text) => text.length > 2);
});
it("should exclude autofill and favorite ciphers", (done) => {
service.remainingCiphers$.subscribe((ciphers) => {
// 2 autofill ciphers, 2 favorite ciphers = 6 remaining ciphers to show
expect(ciphers.length).toBe(6);
done();
});
});
it("should filter remainingCiphers$ down to search term", (done) => {
const cipherList = Object.values(allCiphers);
const searchText = "Login";
searchService.searchCiphers.mockImplementation(async () => {
return cipherList.filter((cipher) => {
return cipher.name.includes(searchText);
});
});
service.remainingCiphers$.subscribe((ciphers) => {
// There are 6 remaining ciphers but only 2 with "Login" in the name
expect(ciphers.length).toBe(2);
done();
});
});
});
describe("emptyVault$", () => {
it("should return true if there are no ciphers", (done) => {
cipherServiceMock.cipherListViews$.mockReturnValue(of([]));
@@ -493,8 +462,8 @@ describe("VaultPopupItemsService", () => {
// Start tracking loading$ emissions
tracked = new ObservableTracker(service.loading$);
// Track remainingCiphers$ to make cipher observables active
trackedCiphers = new ObservableTracker(service.remainingCiphers$);
// Track favoriteCiphers$ to make cipher observables active
trackedCiphers = new ObservableTracker(service.favoriteCiphers$);
});
it("should initialize with true first", async () => {

View File

@@ -2,7 +2,6 @@ import { inject, Injectable, NgZone } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import {
combineLatest,
concatMap,
distinctUntilChanged,
distinctUntilKeyChanged,
filter,
@@ -119,7 +118,7 @@ export class VaultPopupItemsService {
this.cipherService
.cipherListViews$(userId)
.pipe(filter((ciphers) => ciphers != null)),
this.cipherService.failedToDecryptCiphers$(userId),
this.cipherService.failedToDecryptCiphers$(userId).pipe(startWith([])),
this.restrictedItemTypesService.restricted$,
]),
),
@@ -242,31 +241,12 @@ export class VaultPopupItemsService {
shareReplay({ refCount: false, bufferSize: 1 }),
);
/**
* List of all remaining ciphers that are not currently suggested for autofill or marked as favorite.
* Ciphers are sorted by name.
*/
remainingCiphers$: Observable<PopupCipherViewLike[]> = this.favoriteCiphers$.pipe(
concatMap(
(
favoriteCiphers, // concatMap->of is used to make withLatestFrom lazy to avoid race conditions with autoFillCiphers$
) =>
of(favoriteCiphers).pipe(withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$)),
),
map(([favoriteCiphers, ciphers, autoFillCiphers]) =>
ciphers.filter(
(cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher),
),
),
shareReplay({ refCount: false, bufferSize: 1 }),
);
/**
* Observable that indicates whether the service is currently loading ciphers.
*/
loading$: Observable<boolean> = merge(
this._ciphersLoading$.pipe(map(() => true)),
this.remainingCiphers$.pipe(map(() => false)),
this.favoriteCiphers$.pipe(map(() => false)),
).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 }));
/** Observable that indicates whether there is search text present.

View File

@@ -94,11 +94,12 @@ describe("FoldersComponent", () => {
fixture.detectChanges();
});
it("removes the last option in the folder array", (done) => {
it("should show all folders", (done) => {
component.folders$.subscribe((folders) => {
expect(folders).toEqual([
{ id: "1", name: "Folder 1" },
{ id: "2", name: "Folder 2" },
{ id: "0", name: "No Folder" },
]);
done();
});

View File

@@ -53,13 +53,6 @@ export class FoldersComponent {
this.folders$ = this.activeUserId$.pipe(
filter((userId): userId is UserId => userId !== null),
switchMap((userId) => this.folderService.folderViews$(userId)),
map((folders) => {
// Remove the last folder, which is the "no folder" option folder
if (folders.length > 0) {
return folders.slice(0, folders.length - 1);
}
return folders;
}),
);
}

View File

@@ -35,6 +35,9 @@
"invalidVerificationCode": {
"message": "Invalid verification code."
},
"invalidEmailOrVerificationCode": {
"message": "Invalid email or verification code"
},
"masterPassRequired": {
"message": "Master password is required."
},

View File

@@ -1,17 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!--suppress XmlUnusedNamespaceDeclaration -->
<!-- <Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"> -->
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
IgnorableNamespaces="uap rescap com uap10 build"
xmlns:build="http://schemas.microsoft.com/developer/appx/2015/build">
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities">
<!-- use single quotes to avoid double quotes escaping in the publisher value -->
<Identity Name="${identityName}"
ProcessorArchitecture="${arch}"
@@ -78,6 +70,7 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/re
<Resource Language="sl" />
<Resource Language="sr-cyrl" />
<Resource Language="sv" />
<Resource Language="ta" />
<Resource Language="te" />
<Resource Language="th" />
<Resource Language="tr" />
@@ -87,8 +80,9 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/re
<Resource Language="zh-tw" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.26200.7019"
MaxVersionTested="10.0.26200.7171" />
<!-- MinVersion 10.0.14316.0 = Windows 10 2016 Anniversary Update (April 2016) -->
<TargetDeviceFamily Name="Windows.Desktop"
MinVersion="10.0.14316.0" MaxVersionTested="10.0.14316.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
@@ -106,6 +100,13 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/re
<uap:DefaultTile Wide310x150Logo="assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="bitwarden">
<uap:DisplayName>Bitwarden</uap:DisplayName>
</uap:Protocol>
</uap:Extension>
</Extensions>
</Application>
</Applications>
</Package>

View File

@@ -61,7 +61,6 @@
"appx": {
"artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}",
"backgroundColor": "#175DDC",
"customManifestPath": "./custom-appx-manifest.xml",
"applicationId": "BitwardenBeta",
"identityName": "8bitSolutionsLLC.BitwardenBeta",
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",

View File

@@ -176,7 +176,6 @@
"appx": {
"artifactName": "${productName}-${version}-${arch}.${ext}",
"backgroundColor": "#175DDC",
"customManifestPath": "./custom-appx-manifest.xml",
"applicationId": "bitwardendesktop",
"identityName": "8bitSolutionsLLC.bitwardendesktop",
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",

View File

@@ -72,6 +72,7 @@ param(
# Whether to build in release mode.
$Release=$false
)
$ErrorActionPreference = "Stop"
$PSNativeCommandUseErrorActionPreference = $true
$startTime = Get-Date
@@ -113,7 +114,7 @@ else {
$builderConfig = Get-Content $electronConfigFile | ConvertFrom-Json
$packageConfig = Get-Content package.json | ConvertFrom-Json
$manifestTemplate = Get-Content $builderConfig.appx.customManifestPath
$manifestTemplate = Get-Content ($builderConfig.appx.customManifestPath ?? "custom-appx-manifest.xml")
$srcDir = Get-Location
$assetsDir = Get-Item $builderConfig.directories.buildResources

View File

@@ -7,6 +7,7 @@ import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeUserWithPermissionCredentials,
SetInitialPasswordUserType,
} from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import {
@@ -30,6 +31,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { DesktopSetInitialPasswordService } from "./desktop-set-initial-password.service";
@@ -224,4 +226,68 @@ describe("DesktopSetInitialPasswordService", () => {
superSpy.mockRestore();
});
});
describe("setInitialPasswordTdeUserWithPermission()", () => {
let credentials: SetInitialPasswordTdeUserWithPermissionCredentials;
let userId: UserId;
let superSpy: jest.SpyInstance;
beforeEach(() => {
credentials = {
newPassword: "newPassword123!",
salt: "user@example.com" as MasterPasswordSalt,
kdfConfig: DEFAULT_KDF_CONFIG,
newPasswordHint: "newPasswordHint",
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId" as OrganizationId,
resetPasswordAutoEnroll: false,
};
userId = newGuid() as UserId;
superSpy = jest
.spyOn(
DefaultSetInitialPasswordService.prototype,
"setInitialPasswordTdeUserWithPermission",
)
.mockResolvedValue(undefined); // undefined = successful
});
afterEach(() => {
superSpy.mockRestore();
});
it("should call the setInitialPasswordTdeUserWithPermission() method on the default service", async () => {
// Act
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
expect(superSpy).toHaveBeenCalledWith(credentials, userId);
});
describe("given the initial password was successfully set", () => {
it("should send a 'redrawMenu' message", async () => {
// Act
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
expect(messagingService.send).toHaveBeenCalledTimes(1);
expect(messagingService.send).toHaveBeenCalledWith("redrawMenu");
});
});
describe("given the initial password was NOT successfully set (due an error on the default service)", () => {
it("should NOT send a 'redrawMenu' message", async () => {
// Arrange
const error = new Error("error on DefaultSetInitialPasswordService");
superSpy.mockRejectedValue(error);
// Act
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
await expect(promise).rejects.toThrow(error);
expect(messagingService.send).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -4,6 +4,7 @@ import {
InitializeJitPasswordCredentials,
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeUserWithPermissionCredentials,
SetInitialPasswordUserType,
} from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
@@ -75,4 +76,13 @@ export class DesktopSetInitialPasswordService
this.messagingService.send("redrawMenu");
}
override async setInitialPasswordTdeUserWithPermission(
credentials: SetInitialPasswordTdeUserWithPermissionCredentials,
userId: UserId,
) {
await super.setInitialPasswordTdeUserWithPermission(credentials, userId);
this.messagingService.send("redrawMenu");
}
}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, computed, inject, signal, viewChild } from "@angular/core";
import { Component, computed, DestroyRef, inject, signal, viewChild } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { combineLatest, map, switchMap, lastValueFrom } from "rxjs";
@@ -20,7 +20,7 @@ import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.s
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { SendId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ButtonModule, DialogService, ToastService } from "@bitwarden/components";
import { ButtonModule, DialogRef, DialogService, ToastService } from "@bitwarden/components";
import {
NewSendDropdownV2Component,
SendItemsService,
@@ -28,6 +28,7 @@ import {
SendListState,
SendAddEditDialogComponent,
DefaultSendFormConfigService,
SendItemDialogResult,
} from "@bitwarden/send-ui";
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
@@ -84,6 +85,9 @@ export class SendV2Component {
private dialogService = inject(DialogService);
private toastService = inject(ToastService);
private logService = inject(LogService);
private destroyRef = inject(DestroyRef);
private activeDrawerRef?: DialogRef<SendItemDialogResult>;
protected readonly useDrawerEditMode = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone2),
@@ -128,6 +132,12 @@ export class SendV2Component {
{ initialValue: null },
);
constructor() {
this.destroyRef.onDestroy(() => {
this.activeDrawerRef?.close();
});
}
protected readonly selectedSendType = computed(() => {
const action = this.action();
@@ -143,11 +153,12 @@ export class SendV2Component {
if (this.useDrawerEditMode()) {
const formConfig = await this.sendFormConfigService.buildConfig("add", undefined, type);
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
this.activeDrawerRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
await lastValueFrom(dialogRef.closed);
await lastValueFrom(this.activeDrawerRef.closed);
this.activeDrawerRef = null;
} else {
this.action.set(Action.Add);
this.sendId.set(null);
@@ -173,11 +184,12 @@ export class SendV2Component {
if (this.useDrawerEditMode()) {
const formConfig = await this.sendFormConfigService.buildConfig("edit", sendId as SendId);
const dialogRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
this.activeDrawerRef = SendAddEditDialogComponent.openDrawer(this.dialogService, {
formConfig,
});
await lastValueFrom(dialogRef.closed);
await lastValueFrom(this.activeDrawerRef.closed);
this.activeDrawerRef = null;
} else {
if (sendId === this.sendId() && this.action() === Action.Edit) {
return;

View File

@@ -1023,6 +1023,9 @@
"invalidVerificationCode": {
"message": "Invalid verification code"
},
"invalidEmailOrVerificationCode": {
"message": "Invalid email or verification code"
},
"continue": {
"message": "Continue"
},
@@ -4615,7 +4618,13 @@
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailsRequiredChangeAccessType": {
"message": "Email verification requires at least one email address. To remove all emails, change the access type above."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
},
"userVerificationFailed": {
"message": "User verification failed."
}
}

View File

@@ -4,7 +4,7 @@ import { once } from "node:events";
import * as path from "path";
import * as url from "url";
import { app, BrowserWindow, dialog, ipcMain, nativeTheme, screen, session } from "electron";
import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron";
import { concatMap, firstValueFrom, pairwise } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -127,7 +127,6 @@ export class WindowMain {
if (!isMacAppStore()) {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
dialog.showErrorBox("Error", "An instance of Bitwarden Desktop is already running.");
app.quit();
return;
} else {

View File

@@ -4,9 +4,9 @@ import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/pre
import { ItemModule } from "@bitwarden/components";
import { DangerZoneComponent } from "../../../auth/settings/account/danger-zone.component";
import { AccountFingerprintComponent } from "../../../key-management/account-fingerprint/account-fingerprint.component";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component";
import { AccountComponent } from "./account.component";
import { OrganizationSettingsRoutingModule } from "./organization-settings-routing.module";

View File

@@ -18,8 +18,8 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { DynamicAvatarComponent } from "../../../components/dynamic-avatar.component";
import { AccountFingerprintComponent } from "../../../key-management/account-fingerprint/account-fingerprint.component";
import { SharedModule } from "../../../shared";
import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component";
import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.component";

File diff suppressed because it is too large Load Diff

View File

@@ -113,8 +113,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() currentPlan: PlanResponse;
selectedFile: File;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input()
@@ -675,9 +673,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
const collectionCt = collection.encryptedString;
const orgKeys = await this.keyService.makeKeyPair(orgKey[1]);
orgId = this.selfHosted
? await this.createSelfHosted(key, collectionCt, orgKeys)
: await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1], activeUserId);
orgId = await this.createCloudHosted(key, collectionCt, orgKeys, orgKey[1], activeUserId);
this.toastService.showToast({
variant: "success",
@@ -953,27 +949,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
}
}
private async createSelfHosted(key: string, collectionCt: string, orgKeys: [string, EncString]) {
if (!this.selectedFile) {
throw new Error(this.i18nService.t("selectFile"));
}
const fd = new FormData();
fd.append("license", this.selectedFile);
fd.append("key", key);
fd.append("collectionName", collectionCt);
const response = await this.organizationApiService.createLicense(fd);
const orgId = response.id;
await this.apiService.refreshIdentityToken();
// Org Keys live outside of the OrganizationLicense - add the keys to the org here
const request = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
await this.organizationApiService.updateKeys(orgId, request);
return orgId;
}
private billingSubLabelText(): string {
const selectedPlan = this.selectedPlan;
const price =

View File

@@ -44,16 +44,9 @@ import {
InternalUserDecryptionOptionsServiceAbstraction,
LoginEmailService,
} from "@bitwarden/auth/common";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
InternalOrganizationServiceAbstraction,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import {
InternalPolicyService,
@@ -373,19 +366,6 @@ const safeProviders: SafeProvider[] = [
I18nServiceAbstraction,
],
}),
safeProvider({
provide: AutomaticUserConfirmationService,
useClass: DefaultAutomaticUserConfirmationService,
deps: [
ConfigService,
ApiService,
OrganizationUserService,
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
PolicyService,
],
}),
safeProvider({
provide: SdkLoadService,
useClass: flagEnabled("sdk") ? WebSdkLoadService : NoopSdkLoadService,

View File

@@ -43,16 +43,16 @@
></bit-chip-select>
}
}
<bit-table-scroll [dataSource]="dataSource" [rowSize]="54">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="54" layout="fixed">
<ng-container header>
<th bitCell></th>
<th bitCell class="tw-w-12"></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
@if (!isAdminConsoleActive) {
<th bitCell bitSortable="organizationId">
<th bitCell bitSortable="organizationId" class="tw-w-1/4">
{{ "owner" | i18n }}
</th>
}
<th bitCell class="tw-text-right" bitSortable="exposedXTimes">
<th bitCell class="tw-w-1/4 tw-text-right" bitSortable="exposedXTimes">
{{ "timesExposed" | i18n }}
</th>
</ng-container>
@@ -60,7 +60,7 @@
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<td bitCell class="tw-truncate tw-max-w-0">
@if (!organization || canManageCipher(row)) {
<a
bitLink
@@ -72,7 +72,7 @@
{{ row.name }}
</a>
} @else {
<span>{{ row.name }}</span>
<span title="{{ row.name }}">{{ row.name }}</span>
}
@if (!organization && row.organizationId) {
<i

View File

@@ -122,19 +122,16 @@ describe("ExposedPasswordsReportComponent", () => {
expect(component).toBeTruthy();
});
it('should get only ciphers with exposed passwords that the user has "Can Edit" access to', async () => {
const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
const expectedIdTwo = "cbea34a8-bde4-46ad-9d19-b05001228cd3";
it("should get ciphers with exposed passwords regardless of edit access", async () => {
jest.spyOn(auditService, "passwordLeaked").mockReturnValue(Promise.resolve<any>(1234));
jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve<any>(cipherData));
await component.setCiphers();
expect(component.ciphers.length).toEqual(2);
expect(component.ciphers[0].id).toEqual(expectedIdOne);
expect(component.ciphers[0].edit).toEqual(true);
expect(component.ciphers[1].id).toEqual(expectedIdTwo);
expect(component.ciphers[1].edit).toEqual(true);
const cipherIds = component.ciphers.map((c) => c.id);
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab1");
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab2");
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228cd3");
expect(component.ciphers.length).toEqual(3);
});
it("should call fullSync method of syncService", () => {

View File

@@ -64,14 +64,12 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
this.filterStatus = [0];
allCiphers.forEach((ciph) => {
const { type, login, isDeleted, edit, viewPassword } = ciph;
const { type, login, isDeleted } = ciph;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!viewPassword
isDeleted
) {
return;
}

View File

@@ -45,20 +45,20 @@
></bit-chip-select>
}
}
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
@if (!isAdminConsoleActive) {
<ng-container header>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell></th>
</ng-container>
}
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75" layout="fixed">
<ng-container header>
<th bitCell class="tw-w-12"></th>
<th bitCell>{{ "name" | i18n }}</th>
@if (!isAdminConsoleActive) {
<th bitCell class="tw-w-1/4">{{ "owner" | i18n }}</th>
}
<th bitCell class="tw-w-1/4"></th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<td bitCell class="tw-truncate tw-max-w-0">
@if (!organization || canManageCipher(row)) {
<a
bitLink
@@ -69,7 +69,7 @@
>{{ row.name }}</a
>
} @else {
<span>{{ row.name }}</span>
<span title="{{ row.name }}">{{ row.name }}</span>
}
@if (!organization && row.organizationId) {
<i
@@ -92,16 +92,20 @@
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell>
@if (!organization) {
<app-org-badge
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
/>
}
</td>
@if (!isAdminConsoleActive) {
<td bitCell>
@if (!organization) {
<app-org-badge
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="
row.organizationId | orgNameFromId: (organizations$ | async)
"
appStopProp
/>
}
</td>
}
<td bitCell class="tw-text-right">
@if (cipherDocs.has(row.id)) {
<a bitBadge href="{{ cipherDocs.get(row.id) }}" target="_blank" rel="noreferrer">

View File

@@ -95,9 +95,7 @@ describe("InactiveTwoFactorReportComponent", () => {
expect(component).toBeTruthy();
});
it('should get only ciphers with domains in the 2fa directory that they have "Can Edit" access to', async () => {
const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228xy4";
const expectedIdTwo: any = "cbea34a8-bde4-46ad-9d19-b05001227nm5";
it("should get ciphers with domains in the 2fa directory regardless of edit access", async () => {
component.services.set(
"101domain.com",
"https://help.101domain.com/account-management/account-security/enabling-disabling-two-factor-verification",
@@ -110,11 +108,10 @@ describe("InactiveTwoFactorReportComponent", () => {
jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve<any>(cipherData));
await component.setCiphers();
const cipherIds = component.ciphers.map((c) => c.id);
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228xy4");
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001227nm5");
expect(component.ciphers.length).toEqual(2);
expect(component.ciphers[0].id).toEqual(expectedIdOne);
expect(component.ciphers[0].edit).toEqual(true);
expect(component.ciphers[1].id).toEqual(expectedIdTwo);
expect(component.ciphers[1].edit).toEqual(true);
});
it("should call fullSync method of syncService", () => {
@@ -197,7 +194,7 @@ describe("InactiveTwoFactorReportComponent", () => {
expect(doc).toBe("");
});
it("should return false if cipher does not have edit access and no organization", () => {
it("should return true for cipher without edit access", () => {
component.organization = null;
const cipher = createCipherView({
edit: false,
@@ -206,11 +203,11 @@ describe("InactiveTwoFactorReportComponent", () => {
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
expect(isInactive).toBe(true);
expect(doc).toBe("https://example.com/2fa-doc");
});
it("should return false if cipher does not have viewPassword", () => {
it("should return true for cipher without viewPassword", () => {
const cipher = createCipherView({
viewPassword: false,
login: {
@@ -218,8 +215,8 @@ describe("InactiveTwoFactorReportComponent", () => {
},
});
const [doc, isInactive] = (component as any).isInactive2faCipher(cipher);
expect(isInactive).toBe(false);
expect(doc).toBe("");
expect(isInactive).toBe(true);
expect(doc).toBe("https://example.com/2fa-doc");
});
it("should check all uris and return true if any matches domain or host", () => {

View File

@@ -92,14 +92,12 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
let docFor2fa: string = "";
let isInactive2faCipher: boolean = false;
const { type, login, isDeleted, edit, viewPassword } = cipher;
const { type, login, isDeleted } = cipher;
if (
type !== CipherType.Login ||
(login.totp != null && login.totp !== "") ||
!login.hasUris ||
isDeleted ||
(!this.organization && !edit) ||
!viewPassword
isDeleted
) {
return [docFor2fa, isInactive2faCipher];
}

View File

@@ -45,20 +45,20 @@
></bit-chip-select>
}
}
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
@if (!isAdminConsoleActive) {
<ng-container header>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
<th bitCell class="tw-text-right">{{ "timesReused" | i18n }}</th>
</ng-container>
}
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75" layout="fixed">
<ng-container header>
<th bitCell class="tw-w-12"></th>
<th bitCell>{{ "name" | i18n }}</th>
@if (!isAdminConsoleActive) {
<th bitCell class="tw-w-1/4">{{ "owner" | i18n }}</th>
}
<th bitCell class="tw-w-1/4 tw-text-right">{{ "timesReused" | i18n }}</th>
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<td bitCell class="tw-truncate tw-max-w-0">
@if (!organization || canManageCipher(row)) {
<a
bitLink
@@ -69,7 +69,7 @@
>{{ row.name }}</a
>
} @else {
<span>{{ row.name }}</span>
<span title="{{ row.name }}">{{ row.name }}</span>
}
@if (!organization && row.organizationId) {
<i
@@ -92,17 +92,21 @@
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell>
@if (!organization) {
<app-org-badge
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
}
</td>
@if (!isAdminConsoleActive) {
<td bitCell>
@if (!organization) {
<app-org-badge
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="
row.organizationId | orgNameFromId: (organizations$ | async)
"
appStopProp
>
</app-org-badge>
}
</td>
}
<td bitCell class="tw-text-right">
<span bitBadge variant="warning">
{{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }}

View File

@@ -109,17 +109,15 @@ describe("ReusedPasswordsReportComponent", () => {
expect(component).toBeTruthy();
});
it('should get ciphers with reused passwords that the user has "Can Edit" access to', async () => {
const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
const expectedIdTwo = "cbea34a8-bde4-46ad-9d19-b05001228cd3";
it("should get ciphers with reused passwords regardless of edit access", async () => {
jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve<any>(cipherData));
await component.setCiphers();
expect(component.ciphers.length).toEqual(2);
expect(component.ciphers[0].id).toEqual(expectedIdOne);
expect(component.ciphers[0].edit).toEqual(true);
expect(component.ciphers[1].id).toEqual(expectedIdTwo);
expect(component.ciphers[1].edit).toEqual(true);
const cipherIds = component.ciphers.map((c) => c.id);
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab1");
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab2");
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228cd3");
expect(component.ciphers.length).toEqual(3);
});
it("should call fullSync method of syncService", () => {

View File

@@ -71,14 +71,12 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
this.filterStatus = [0];
ciphers.forEach((ciph) => {
const { type, login, isDeleted, edit, viewPassword } = ciph;
const { type, login, isDeleted } = ciph;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!viewPassword
isDeleted
) {
return;
}

View File

@@ -45,19 +45,19 @@
></bit-chip-select>
}
}
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75">
@if (!isAdminConsoleActive) {
<ng-container header>
<th bitCell></th>
<th bitCell>{{ "name" | i18n }}</th>
<th bitCell>{{ "owner" | i18n }}</th>
</ng-container>
}
<bit-table-scroll [dataSource]="dataSource" [rowSize]="75" layout="fixed">
<ng-container header>
<th bitCell class="tw-w-12"></th>
<th bitCell>{{ "name" | i18n }}</th>
@if (!isAdminConsoleActive) {
<th bitCell class="tw-w-1/3">{{ "owner" | i18n }}</th>
}
</ng-container>
<ng-template bitRowDef let-row>
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<td bitCell class="tw-truncate tw-max-w-0">
@if (!organization || canManageCipher(row)) {
<a
bitLink
@@ -68,7 +68,7 @@
>{{ row.name }}</a
>
} @else {
<span>{{ row.name }}</span>
<span title="{{ row.name }}">{{ row.name }}</span>
}
@if (!organization && row.organizationId) {
<i
@@ -91,17 +91,21 @@
<br />
<small>{{ row.subTitle }}</small>
</td>
<td bitCell>
@if (!organization) {
<app-org-badge
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="row.organizationId | orgNameFromId: (organizations$ | async)"
appStopProp
>
</app-org-badge>
}
</td>
@if (!isAdminConsoleActive) {
<td bitCell>
@if (!organization) {
<app-org-badge
[disabled]="disabled"
[organizationId]="row.organizationId"
[organizationName]="
row.organizationId | orgNameFromId: (organizations$ | async)
"
appStopProp
>
</app-org-badge>
}
</td>
}
</ng-template>
</bit-table-scroll>
}

View File

@@ -118,17 +118,14 @@ describe("UnsecuredWebsitesReportComponent", () => {
expect(component).toBeTruthy();
});
it('should get only unsecured ciphers that the user has "Can Edit" access to', async () => {
const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
const expectedIdTwo = "cbea34a8-bde4-46ad-9d19-b05001228cd3";
it("should get unsecured ciphers regardless of edit access", async () => {
jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve<any>(cipherData));
await component.setCiphers();
const cipherIds = component.ciphers.map((c) => c.id);
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab2");
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228cd3");
expect(component.ciphers.length).toEqual(2);
expect(component.ciphers[0].id).toEqual(expectedIdOne);
expect(component.ciphers[0].edit).toEqual(true);
expect(component.ciphers[1].id).toEqual(expectedIdTwo);
expect(component.ciphers[1].edit).toEqual(true);
});
it("should call fullSync method of syncService", () => {

View File

@@ -71,12 +71,7 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl
* @param cipher Current cipher with unsecured uri
*/
private cipherContainsUnsecured(cipher: CipherView): boolean {
if (
cipher.type !== CipherType.Login ||
!cipher.login.hasUris ||
cipher.isDeleted ||
(!this.organization && !cipher.edit)
) {
if (cipher.type !== CipherType.Login || !cipher.login.hasUris || cipher.isDeleted) {
return false;
}

View File

@@ -45,12 +45,12 @@
></bit-chip-select>
}
}
<bit-table-scroll [dataSource]="dataSource" [rowSize]="54">
<bit-table-scroll [dataSource]="dataSource" [rowSize]="54" layout="fixed">
<ng-container header>
<th bitCell></th>
<th bitCell class="tw-w-12"></th>
<th bitCell bitSortable="name">{{ "name" | i18n }}</th>
@if (!isAdminConsoleActive) {
<th bitCell bitSortable="organizationId">
<th bitCell bitSortable="organizationId" class="tw-w-1/4">
{{ "owner" | i18n }}
</th>
}
@@ -62,7 +62,7 @@
<td bitCell>
<app-vault-icon [cipher]="row"></app-vault-icon>
</td>
<td bitCell>
<td bitCell class="tw-truncate tw-max-w-0">
@if (!organization || canManageCipher(row)) {
<a
bitLink
@@ -73,7 +73,7 @@
>{{ row.name }}</a
>
} @else {
<span>{{ row.name }}</span>
<span title="{{ row.name }}">{{ row.name }}</span>
}
@if (!organization && row.organizationId) {
<i

View File

@@ -114,10 +114,7 @@ describe("WeakPasswordsReportComponent", () => {
expect(component).toBeTruthy();
});
it('should get only ciphers with weak passwords that the user has "Can Edit" access to', async () => {
const expectedIdOne: any = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
const expectedIdTwo = "cbea34a8-bde4-46ad-9d19-b05001228cd3";
it("should get ciphers with weak passwords regardless of edit access", async () => {
jest.spyOn(passwordStrengthService, "getPasswordStrength").mockReturnValue({
password: "123",
score: 0,
@@ -125,11 +122,11 @@ describe("WeakPasswordsReportComponent", () => {
jest.spyOn(component as any, "getAllCiphers").mockReturnValue(Promise.resolve<any>(cipherData));
await component.setCiphers();
expect(component.ciphers.length).toEqual(2);
expect(component.ciphers[0].id).toEqual(expectedIdOne);
expect(component.ciphers[0].edit).toEqual(true);
expect(component.ciphers[1].id).toEqual(expectedIdTwo);
expect(component.ciphers[1].edit).toEqual(true);
const cipherIds = component.ciphers.map((c) => c.id);
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab1");
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228ab2");
expect(cipherIds).toContain("cbea34a8-bde4-46ad-9d19-b05001228cd3");
expect(component.ciphers.length).toEqual(3);
});
it("should call fullSync method of syncService", () => {

View File

@@ -103,15 +103,8 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
}
protected determineWeakPasswordScore(ciph: CipherView): ReportResult | null {
const { type, login, isDeleted, edit, viewPassword } = ciph;
if (
type !== CipherType.Login ||
login.password == null ||
login.password === "" ||
isDeleted ||
(!this.organization && !edit) ||
!viewPassword
) {
const { type, login, isDeleted } = ciph;
if (type !== CipherType.Login || login.password == null || login.password === "" || isDeleted) {
return;
}

View File

@@ -4,7 +4,7 @@ import { Component, Input, OnInit } from "@angular/core";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../shared.module";
import { SharedModule } from "../../shared/shared.module";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection

View File

@@ -1,8 +1,7 @@
<p bitTypography="body1">{{ "sendProtectedPassword" | i18n }}</p>
<p bitTypography="body1">{{ "sendProtectedPasswordDontKnow" | i18n }}</p>
<bit-form-field>
<bit-label>{{ "password" | i18n }}</bit-label>
<input bitInput type="password" [formControl]="password" required appInputVerbatim appAutofocus />
<bit-hint>{{ "sendProtectedPasswordDontKnow" | i18n }}</bit-hint>
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
<div class="tw-flex">

View File

@@ -52,6 +52,7 @@ export class SendAuthComponent implements OnInit {
authType = AuthType;
private expiredAuthAttempts = 0;
private otpSubmitted = false;
readonly loading = signal<boolean>(false);
readonly error = signal<boolean>(false);
@@ -104,7 +105,27 @@ export class SendAuthComponent implements OnInit {
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 401) {
if (this.sendAuthType() === AuthType.Password) {
// Password was already required, so this is an invalid password error
const passwordControl = this.sendAccessForm.get("password");
if (passwordControl) {
passwordControl.setErrors({
invalidPassword: { message: this.i18nService.t("sendPasswordInvalidAskOwner") },
});
passwordControl.markAsTouched();
}
}
// Set auth type to Password (either first time or refresh)
this.sendAuthType.set(AuthType.Password);
} else if (e.statusCode === 400 && this.sendAuthType() === AuthType.Password) {
// Server returns 400 for SendAccessResult.PasswordInvalid
const passwordControl = this.sendAccessForm.get("password");
if (passwordControl) {
passwordControl.setErrors({
invalidPassword: { message: this.i18nService.t("sendPasswordInvalidAskOwner") },
});
passwordControl.markAsTouched();
}
} else if (e.statusCode === 404) {
this.unavailable.set(true);
} else {
@@ -164,22 +185,29 @@ export class SendAuthComponent implements OnInit {
this.updatePageTitle();
} else if (emailAndOtpRequired(response.error)) {
this.enterOtp.set(true);
if (this.otpSubmitted) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidEmailOrVerificationCode"),
});
}
this.otpSubmitted = true;
this.updatePageTitle();
} else if (otpInvalid(response.error)) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidVerificationCode"),
message: this.i18nService.t("invalidEmailOrVerificationCode"),
});
} else if (passwordHashB64Required(response.error)) {
this.sendAuthType.set(AuthType.Password);
this.updatePageTitle();
} else if (passwordHashB64Invalid(response.error)) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: this.i18nService.t("invalidSendPassword"),
this.sendAccessForm.controls.password?.setErrors({
invalidPassword: { message: this.i18nService.t("sendPasswordInvalidAskOwner") },
});
this.sendAccessForm.controls.password?.markAsTouched();
} else if (sendIdInvalid(response.error)) {
this.unavailable.set(true);
} else {

View File

@@ -9,12 +9,7 @@
@if (loading()) {
<div class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
<bit-spinner></bit-spinner>
</div>
} @else {
@if (unavailable()) {
@@ -47,7 +42,11 @@
}
}
@if (expirationDate()) {
<p class="tw-text-center tw-text-muted">Expires: {{ expirationDate() | date: "medium" }}</p>
@let formattedExpirationTime = expirationDate() | date: "shortTime";
@let formattedExpirationDate = expirationDate() | date: "mediumDate";
<p class="tw-text-center tw-text-muted tw-text-sm">
{{ "sendExpiresOn" | i18n: formattedExpirationTime : formattedExpirationDate }}
</p>
}
</div>
}

View File

@@ -21,7 +21,11 @@ import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response
import { SendAccessView } from "@bitwarden/common/tools/send/models/view/send-access.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
import { AnonLayoutWrapperDataService, ToastService } from "@bitwarden/components";
import {
AnonLayoutWrapperDataService,
SpinnerComponent,
ToastService,
} from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
@@ -32,7 +36,7 @@ import { SendAccessTextComponent } from "./send-access-text.component";
@Component({
selector: "app-send-view",
templateUrl: "send-view.component.html",
imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule],
imports: [SendAccessFileComponent, SendAccessTextComponent, SharedModule, SpinnerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendViewComponent implements OnInit {

View File

@@ -4596,29 +4596,26 @@
"generatingYourAccessIntelligence": {
"message": "Generating your Access Intelligence..."
},
"fetchingMemberData": {
"message": "Fetching member data..."
},
"analyzingPasswordHealth": {
"message": "Analyzing password health..."
},
"calculatingRiskScores": {
"message": "Calculating risk scores..."
},
"generatingReportData": {
"message": "Generating report data..."
},
"savingReport": {
"message": "Saving report..."
},
"compilingInsights": {
"message": "Compiling insights..."
},
"loadingProgress": {
"message": "Loading progress"
},
"thisMightTakeFewMinutes": {
"message": "This might take a few minutes."
"reviewingMemberData": {
"message": "Reviewing member data..."
},
"analyzingPasswords": {
"message": "Analyzing passwords..."
},
"calculatingRisks": {
"message": "Calculating risks..."
},
"generatingReports": {
"message": "Generating reports..."
},
"compilingInsightsProgress": {
"message": "Compiling insights..."
},
"reportGenerationDone": {
"message": "Done!"
},
"riskInsightsRunReport": {
"message": "Run report"
@@ -7400,6 +7397,9 @@
"invalidVerificationCode": {
"message": "Invalid verification code"
},
"invalidEmailOrVerificationCode": {
"message": "Invalid email or verification code"
},
"keyConnectorDomain": {
"message": "Key Connector domain"
},
@@ -12869,6 +12869,9 @@
"enterMultipleEmailsSeparatedByComma": {
"message": "Enter multiple emails by separating with a comma."
},
"emailsRequiredChangeAccessType": {
"message": "Email verification requires at least one email address. To remove all emails, change the access type above."
},
"emailPlaceholder": {
"message": "user@bitwarden.com , user@acme.com"
},
@@ -12948,5 +12951,23 @@
},
"paymentMethodUpdateError": {
"message": "There was an error updating your payment method."
},
"sendPasswordInvalidAskOwner": {
"message": "Invalid password. Ask the sender for the password needed to access this Send.",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
},
"sendExpiresOn": {
"message": "This Send expires at $TIME$ on $DATE$",
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated.",
"placeholders": {
"time": {
"content": "$1",
"example": "10:00 AM"
},
"date": {
"content": "$2",
"example": "Jan 1, 1970"
}
}
}
}

View File

@@ -6,6 +6,7 @@ import { FormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CardComponent, ScrollLayoutDirective, SearchModule } from "@bitwarden/components";
import { MemberActionsService } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-actions/member-actions.service";
import { MemberDialogManagerService } from "@bitwarden/web-vault/app/admin-console/organizations/members/services/member-dialog-manager/member-dialog-manager.service";
import { DangerZoneComponent } from "@bitwarden/web-vault/app/auth/settings/account/danger-zone.component";
import { OrganizationPlansComponent } from "@bitwarden/web-vault/app/billing";
import {
@@ -83,6 +84,11 @@ import { VerifyRecoverDeleteProviderComponent } from "./verify-recover-delete-pr
VerifyRecoverDeleteProviderComponent,
SetupBusinessUnitComponent,
],
providers: [WebProviderService, ProviderActionsService, MemberActionsService],
providers: [
WebProviderService,
ProviderActionsService,
MemberActionsService,
MemberDialogManagerService,
],
})
export class ProvidersModule {}

View File

@@ -10,14 +10,9 @@
></bit-progress>
</div>
<!-- Status message and subtitle -->
<div class="tw-text-center tw-flex tw-flex-col tw-gap-1">
<span class="tw-text-main tw-text-base tw-font-medium tw-leading-4">
{{ stepConfig[progressStep()].message | i18n }}
</span>
<span class="tw-text-muted tw-text-sm tw-font-normal tw-leading-4">
{{ "thisMightTakeFewMinutes" | i18n }}
</span>
</div>
<!-- Status message -->
<span class="tw-text-main tw-text-base tw-font-medium tw-leading-4">
{{ stepConfig[progressStep()].message | i18n }}
</span>
</div>
</div>

View File

@@ -6,12 +6,12 @@ import { ProgressModule } from "@bitwarden/components";
// Map of progress step to display config
const ProgressStepConfig = Object.freeze({
[ReportProgress.FetchingMembers]: { message: "fetchingMemberData", progress: 20 },
[ReportProgress.AnalyzingPasswords]: { message: "analyzingPasswordHealth", progress: 40 },
[ReportProgress.CalculatingRisks]: { message: "calculatingRiskScores", progress: 60 },
[ReportProgress.GeneratingReport]: { message: "generatingReportData", progress: 80 },
[ReportProgress.Saving]: { message: "savingReport", progress: 95 },
[ReportProgress.Complete]: { message: "compilingInsights", progress: 100 },
[ReportProgress.FetchingMembers]: { message: "reviewingMemberData", progress: 20 },
[ReportProgress.AnalyzingPasswords]: { message: "analyzingPasswords", progress: 40 },
[ReportProgress.CalculatingRisks]: { message: "calculatingRisks", progress: 60 },
[ReportProgress.GeneratingReport]: { message: "generatingReports", progress: 80 },
[ReportProgress.Saving]: { message: "compilingInsightsProgress", progress: 95 },
[ReportProgress.Complete]: { message: "reportGenerationDone", progress: 100 },
} as const);
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush

View File

@@ -15,11 +15,13 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "@bitwarden/common/auth/models/request/update-tde-offboarding-password.request";
import { assertNonNullish, assertTruthy } from "@bitwarden/common/auth/utils";
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterPasswordAuthenticationData,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
@@ -45,6 +47,7 @@ import {
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordUserType,
SetInitialPasswordTdeUserWithPermissionCredentials,
} from "./set-initial-password.service.abstraction";
export class DefaultSetInitialPasswordService implements SetInitialPasswordService {
@@ -212,7 +215,7 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
await this.masterPasswordService.setMasterKeyHash(newLocalMasterKeyHash, userId);
if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(newServerMasterKeyHash, orgId, userId);
await this.handleResetPasswordAutoEnrollOld(newServerMasterKeyHash, orgId, userId);
}
}
@@ -336,6 +339,86 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
);
}
async setInitialPasswordTdeUserWithPermission(
credentials: SetInitialPasswordTdeUserWithPermissionCredentials,
userId: UserId,
): Promise<void> {
const ctx =
"Could not set initial password for TDE user with Manage Account Recovery permission.";
assertTruthy(credentials.newPassword, "newPassword", ctx);
assertTruthy(credentials.salt, "salt", ctx);
assertNonNullish(credentials.kdfConfig, "kdfConfig", ctx);
assertNonNullish(credentials.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
assertTruthy(credentials.orgSsoIdentifier, "orgSsoIdentifier", ctx);
assertTruthy(credentials.orgId, "orgId", ctx);
assertNonNullish(credentials.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
assertTruthy(userId, "userId", ctx);
const {
newPassword,
salt,
kdfConfig,
newPasswordHint,
orgSsoIdentifier,
orgId,
resetPasswordAutoEnroll,
} = credentials;
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (!userKey) {
throw new Error("userKey not found.");
}
const authenticationData: MasterPasswordAuthenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
newPassword,
kdfConfig,
salt,
);
const unlockData: MasterPasswordUnlockData =
await this.masterPasswordService.makeMasterPasswordUnlockData(
newPassword,
kdfConfig,
salt,
userKey,
);
const request = SetPasswordRequest.newConstructor(
authenticationData,
unlockData,
newPasswordHint,
orgSsoIdentifier,
null, // no KeysRequest for TDE user because they already have a key pair
);
await this.masterPasswordApiService.setPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);
// User now has a password so update decryption state
await this.masterPasswordService.setMasterPasswordUnlockData(unlockData, userId);
await this.updateLegacyState(
newPassword,
unlockData.kdf,
new EncString(unlockData.masterKeyWrappedUserKey),
userId,
unlockData,
);
if (resetPasswordAutoEnroll) {
await this.handleResetPasswordAutoEnroll(
authenticationData.masterPasswordAuthenticationHash,
orgId,
userId,
userKey,
);
}
}
/**
* @deprecated To be removed in PM-28143
*/
@@ -441,7 +524,19 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
await this.masterPasswordService.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
}
private async handleResetPasswordAutoEnroll(
/**
* @deprecated To be removed in PM-28143
*
* This method is now deprecated because it is used with the deprecated `setInitialPassword()` method,
* which handles both JIT MP and TDE + Permission user flows.
*
* Since these methods can handle the JIT MP flow - which creates a new user key and sets it to state - we
* must retreive that user key here in this method.
*
* But the new handleResetPasswordAutoEnroll() method is only used in the TDE + Permission user case, in which
* case we already have the user key and can simply pass it through via method parameter ( @see handleResetPasswordAutoEnroll )
*/
private async handleResetPasswordAutoEnrollOld(
masterKeyHash: string,
orgId: string,
userId: UserId,
@@ -483,4 +578,43 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
enrollmentRequest,
);
}
private async handleResetPasswordAutoEnroll(
masterKeyHash: string,
orgId: string,
userId: UserId,
userKey: UserKey,
) {
const organizationKeys = await this.organizationApiService.getKeys(orgId);
if (organizationKeys == null) {
throw new Error(
"Organization keys response is null. Could not handle reset password auto enroll.",
);
}
const orgPublicKey = Utils.fromB64ToArray(organizationKeys.publicKey);
// RSA encrypt user key with organization public key
const orgPublicKeyEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
userKey,
orgPublicKey,
);
if (orgPublicKeyEncryptedUserKey == null || !orgPublicKeyEncryptedUserKey.encryptedString) {
throw new Error(
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
);
}
const enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest();
enrollmentRequest.masterPasswordHash = masterKeyHash;
enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString;
await this.organizationUserApiService.putOrganizationUserResetPasswordEnrollment(
orgId,
userId,
enrollmentRequest,
);
}
}

View File

@@ -31,6 +31,9 @@ import {
} from "@bitwarden/common/key-management/crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationData,
MasterPasswordAuthenticationHash,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
@@ -62,6 +65,7 @@ import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordTdeUserWithPermissionCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
@@ -237,7 +241,7 @@ describe("DefaultSetInitialPasswordService", () => {
}
}
// Mock handleResetPasswordAutoEnroll() values
// Mock handleResetPasswordAutoEnrollOld() values
if (config.resetPasswordAutoEnroll) {
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
encryptService.encapsulateKeyUnsigned.mockResolvedValue(orgPublicKeyEncryptedUserKey);
@@ -1104,4 +1108,285 @@ describe("DefaultSetInitialPasswordService", () => {
await expect(promise).rejects.toThrow("Unexpected V2 account cryptographic state");
});
});
describe("setInitialPasswordTdeUserWithPermission()", () => {
// Mock method parameters
let credentials: SetInitialPasswordTdeUserWithPermissionCredentials;
// Mock method data
let authenticationData: MasterPasswordAuthenticationData;
let unlockData: MasterPasswordUnlockData;
let setPasswordRequest: SetPasswordRequest;
let userDecryptionOptions: UserDecryptionOptions;
beforeEach(() => {
// Mock method parameters
credentials = {
newPassword: "newPassword123!",
salt: "user@example.com" as MasterPasswordSalt,
kdfConfig: DEFAULT_KDF_CONFIG,
newPasswordHint: "newPasswordHint",
orgSsoIdentifier: "orgSsoIdentifier",
orgId: "orgId" as OrganizationId,
resetPasswordAutoEnroll: false,
};
// Mock method data
userKey = makeSymmetricCryptoKey(64) as UserKey;
keyService.userKey$.mockReturnValue(of(userKey));
authenticationData = {
salt: credentials.salt,
kdf: credentials.kdfConfig,
masterPasswordAuthenticationHash:
"masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash,
};
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue(
authenticationData,
);
unlockData = {
salt: credentials.salt,
kdf: credentials.kdfConfig,
masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey,
} as MasterPasswordUnlockData;
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData);
setPasswordRequest = SetPasswordRequest.newConstructor(
authenticationData,
unlockData,
credentials.newPasswordHint,
credentials.orgSsoIdentifier,
null, // no KeysRequest for TDE user because they already have a key pair
);
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: false });
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of(userDecryptionOptions),
);
});
describe("general error handling", () => {
["newPassword", "salt", "orgSsoIdentifier", "orgId"].forEach((key) => {
it(`should throw if ${key} is an empty string (falsy) on the SetInitialPasswordTdeUserWithPermissionCredentials object`, async () => {
// Arrange
const invalidCredentials: SetInitialPasswordTdeUserWithPermissionCredentials = {
...credentials,
[key]: "",
};
// Act
const promise = sut.setInitialPasswordTdeUserWithPermission(invalidCredentials, userId);
// Assert
await expect(promise).rejects.toThrow(
`${key} is falsy. Could not set initial password for TDE user with Manage Account Recovery permission.`,
);
});
});
["kdfConfig", "newPasswordHint", "resetPasswordAutoEnroll"].forEach((key) => {
it(`should throw if ${key} is null on the SetInitialPasswordTdeUserWithPermissionCredentials object`, async () => {
// Arrange
const invalidCredentials: SetInitialPasswordTdeUserWithPermissionCredentials = {
...credentials,
[key]: null,
};
// Act
const promise = sut.setInitialPasswordTdeUserWithPermission(invalidCredentials, userId);
// Assert
await expect(promise).rejects.toThrow(
`${key} is null or undefined. Could not set initial password for TDE user with Manage Account Recovery permission.`,
);
});
});
it("should throw if userId is not given", async () => {
// Arrange
userId = null;
// Act
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
await expect(promise).rejects.toThrow(
"userId is falsy. Could not set initial password for TDE user with Manage Account Recovery permission.",
);
});
});
it("should throw if the userKey is not found", async () => {
// Arrange
keyService.userKey$.mockReturnValue(of(null));
// Act
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
await expect(promise).rejects.toThrow("userKey not found.");
});
it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => {
// Act
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
credentials.newPassword,
credentials.kdfConfig,
credentials.salt,
);
expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
credentials.newPassword,
credentials.kdfConfig,
credentials.salt,
userKey,
);
});
it("should call the API method to set a master password", async () => {
// Act
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledTimes(1);
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
describe("given the initial password has been successfully set", () => {
it("should clear the ForceSetPasswordReason by setting it to None", async () => {
// Act
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
expect(masterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.None,
userId,
);
});
it("should set MasterPasswordUnlockData to state", async () => {
// Act
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
expect(masterPasswordService.setMasterPasswordUnlockData).toHaveBeenCalledWith(
unlockData,
userId,
);
});
it("should update legacy state", async () => {
// Act
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
expect.objectContaining({ hasMasterPassword: true }),
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
expect(masterPasswordService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
new EncString(unlockData.masterKeyWrappedUserKey),
userId,
);
expect(masterPasswordService.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith(
credentials.newPassword,
unlockData,
userId,
);
});
describe("given resetPasswordAutoEnroll is false", () => {
it("should NOT handle reset password (account recovery) auto enroll", async () => {
// Act
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).not.toHaveBeenCalled();
});
});
describe("given resetPasswordAutoEnroll is true", () => {
let organizationKeys: OrganizationKeysResponse;
let orgPublicKeyEncryptedUserKey: EncString;
let enrollmentRequest: OrganizationUserResetPasswordEnrollmentRequest;
beforeEach(() => {
credentials.resetPasswordAutoEnroll = true;
organizationKeys = {
privateKey: "orgPrivateKey",
publicKey: "orgPublicKey",
} as OrganizationKeysResponse;
organizationApiService.getKeys.mockResolvedValue(organizationKeys);
orgPublicKeyEncryptedUserKey = new EncString("orgPublicKeyEncryptedUserKey");
encryptService.encapsulateKeyUnsigned.mockResolvedValue(orgPublicKeyEncryptedUserKey);
enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest();
enrollmentRequest.masterPasswordHash =
authenticationData.masterPasswordAuthenticationHash;
enrollmentRequest.resetPasswordKey = orgPublicKeyEncryptedUserKey.encryptedString;
});
it("should throw if organization keys are not found", async () => {
// Arrange
organizationApiService.getKeys.mockResolvedValue(null);
// Act
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
await expect(promise).rejects.toThrow(
"Organization keys response is null. Could not handle reset password auto enroll.",
);
});
it("should throw if orgPublicKeyEncryptedUserKey is not found", async () => {
// Arrange
encryptService.encapsulateKeyUnsigned.mockResolvedValue(null);
// Act
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
await expect(promise).rejects.toThrow(
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
);
});
it("should throw if orgPublicKeyEncryptedUserKey.encryptedString is not found", async () => {
// Arrange
orgPublicKeyEncryptedUserKey.encryptedString = null;
// Act
const promise = sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
await expect(promise).rejects.toThrow(
"orgPublicKeyEncryptedUserKey not found. Could not handle reset password auto enroll.",
);
});
it("should call the API method to handle reset password (account recovery) auto enroll", async () => {
// Act
await sut.setInitialPasswordTdeUserWithPermission(credentials, userId);
// Assert
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).toHaveBeenCalledTimes(1);
expect(
organizationUserApiService.putOrganizationUserResetPasswordEnrollment,
).toHaveBeenCalledWith(credentials.orgId, userId, enrollmentRequest);
});
});
});
});
});

View File

@@ -47,6 +47,7 @@ import {
SetInitialPasswordCredentials,
SetInitialPasswordService,
SetInitialPasswordTdeOffboardingCredentials,
SetInitialPasswordTdeUserWithPermissionCredentials,
SetInitialPasswordUserType,
} from "./set-initial-password.service.abstraction";
@@ -183,7 +184,13 @@ export class SetInitialPasswordComponent implements OnInit {
break;
}
case SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP:
if (passwordInputResult.newApisWithInputPasswordFlagEnabled) {
await this.setInitialPasswordTdeUserWithPermission(passwordInputResult);
return; // EARLY RETURN for flagged logic
}
await this.setInitialPassword(passwordInputResult);
break;
case SetInitialPasswordUserType.OFFBOARDED_TDE_ORG_USER:
await this.setInitialPasswordTdeOffboarding(passwordInputResult);
@@ -382,6 +389,46 @@ export class SetInitialPasswordComponent implements OnInit {
}
}
private async setInitialPasswordTdeUserWithPermission(passwordInputResult: PasswordInputResult) {
const ctx =
"Could not set initial password for TDE user with Manage Account Recovery permission.";
assertTruthy(passwordInputResult.newPassword, "newPassword", ctx);
assertTruthy(passwordInputResult.salt, "salt", ctx);
assertNonNullish(passwordInputResult.kdfConfig, "kdfConfig", ctx);
assertNonNullish(passwordInputResult.newPasswordHint, "newPasswordHint", ctx); // can have an empty string as a valid value, so check non-nullish
assertTruthy(this.orgSsoIdentifier, "orgSsoIdentifier", ctx);
assertTruthy(this.orgId, "orgId", ctx);
assertNonNullish(this.resetPasswordAutoEnroll, "resetPasswordAutoEnroll", ctx); // can have `false` as a valid value, so check non-nullish
assertTruthy(this.userId, "userId", ctx);
try {
const credentials: SetInitialPasswordTdeUserWithPermissionCredentials = {
newPassword: passwordInputResult.newPassword,
salt: passwordInputResult.salt,
kdfConfig: passwordInputResult.kdfConfig,
newPasswordHint: passwordInputResult.newPasswordHint,
orgSsoIdentifier: this.orgSsoIdentifier,
orgId: this.orgId as OrganizationId,
resetPasswordAutoEnroll: this.resetPasswordAutoEnroll,
};
await this.setInitialPasswordService.setInitialPasswordTdeUserWithPermission(
credentials,
this.userId,
);
this.showSuccessToastByUserType();
this.submitting = false;
await this.router.navigate(["vault"]);
} catch (e) {
this.logService.error("Error setting initial password", e);
this.validationService.showError(e);
this.submitting = false;
}
}
private async setInitialPasswordTdeOffboarding(passwordInputResult: PasswordInputResult) {
const ctx = "Could not set initial password.";
assertTruthy(passwordInputResult.newMasterKey, "newMasterKey", ctx);

View File

@@ -55,6 +55,16 @@ export interface SetInitialPasswordCredentials {
salt: MasterPasswordSalt;
}
export interface SetInitialPasswordTdeUserWithPermissionCredentials {
newPassword: string;
salt: MasterPasswordSalt;
kdfConfig: KdfConfig;
newPasswordHint: string;
orgSsoIdentifier: string;
orgId: OrganizationId;
resetPasswordAutoEnroll: boolean;
}
export interface SetInitialPasswordTdeOffboardingCredentials {
newMasterKey: MasterKey;
newServerMasterKeyHash: string;
@@ -103,6 +113,19 @@ export abstract class SetInitialPasswordService {
userId: UserId,
) => Promise<void>;
/**
* Sets an initial password for an existing authed TDE user who has been given the
* Manage Account Recovery permission:
* - {@link SetInitialPasswordUserType.TDE_ORG_USER_RESET_PASSWORD_PERMISSION_REQUIRES_MP}
*
* @param credentials An object of the credentials needed to set the initial password
* @throws If any property on the `credentials` object not found, or if userKey is not found
*/
abstract setInitialPasswordTdeUserWithPermission: (
credentials: SetInitialPasswordTdeUserWithPermissionCredentials,
userId: UserId,
) => Promise<void>;
/**
* Sets an initial password for a user who logs in after their org offboarded from
* trusted device encryption and is now a master-password-encryption org:

View File

@@ -56,7 +56,10 @@ import {
UserDecryptionOptionsService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
import {
AutomaticUserConfirmationService,
DefaultAutomaticUserConfirmationService,
} from "@bitwarden/auto-confirm";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
@@ -1061,6 +1064,19 @@ const safeProviders: SafeProvider[] = [
PendingAuthRequestsStateService,
],
}),
safeProvider({
provide: AutomaticUserConfirmationService,
useClass: DefaultAutomaticUserConfirmationService,
deps: [
ConfigService,
ApiServiceAbstraction,
OrganizationUserService,
StateProvider,
InternalOrganizationServiceAbstraction,
OrganizationUserApiService,
InternalPolicyService,
],
}),
safeProvider({
provide: ServerNotificationsService,
useClass: devFlagEnabled("noopNotifications")
@@ -1512,7 +1528,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: OrganizationMetadataServiceAbstraction,
useClass: DefaultOrganizationMetadataService,
deps: [BillingApiServiceAbstraction, ConfigService, PlatformUtilsServiceAbstraction],
deps: [BillingApiServiceAbstraction, PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: BillingAccountProfileStateService,

View File

@@ -676,7 +676,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
private async decryptViaApprovedAuthRequest(
authRequestResponse: AuthRequestResponse,
privateKey: ArrayBuffer,
privateKey: Uint8Array,
userId: UserId,
): Promise<void> {
/**

View File

@@ -72,7 +72,7 @@ export abstract class AuthRequestServiceAbstraction {
*/
abstract setUserKeyAfterDecryptingSharedUserKey(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
authReqPrivateKey: Uint8Array,
userId: UserId,
): Promise<void>;
/**
@@ -83,7 +83,7 @@ export abstract class AuthRequestServiceAbstraction {
*/
abstract setKeysAfterDecryptingSharedMasterKeyAndHash(
authReqResponse: AuthRequestResponse,
authReqPrivateKey: ArrayBuffer,
authReqPrivateKey: Uint8Array,
userId: UserId,
): Promise<void>;
/**
@@ -94,7 +94,7 @@ export abstract class AuthRequestServiceAbstraction {
*/
abstract decryptPubKeyEncryptedUserKey(
pubKeyEncryptedUserKey: string,
privateKey: ArrayBuffer,
privateKey: Uint8Array,
): Promise<UserKey>;
/**
* Decrypts a `MasterKey` and `MasterKeyHash` from a public key encrypted `MasterKey` and `MasterKeyHash`.
@@ -106,7 +106,7 @@ export abstract class AuthRequestServiceAbstraction {
abstract decryptPubKeyEncryptedMasterKeyAndHash(
pubKeyEncryptedMasterKey: string,
pubKeyEncryptedMasterKeyHash: string,
privateKey: ArrayBuffer,
privateKey: Uint8Array,
): Promise<{ masterKey: MasterKey; masterKeyHash: string }>;
/**

View File

@@ -92,6 +92,7 @@ import { CipherRequest } from "../vault/models/request/cipher.request";
import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response";
import { AttachmentResponse } from "../vault/models/response/attachment.response";
import { CipherMiniResponse, CipherResponse } from "../vault/models/response/cipher.response";
import { DeleteAttachmentResponse } from "../vault/models/response/delete-attachment.response";
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
/**
@@ -243,8 +244,14 @@ export abstract class ApiService {
id: string,
request: AttachmentRequest,
): Promise<AttachmentUploadDataResponse>;
abstract deleteCipherAttachment(id: string, attachmentId: string): Promise<any>;
abstract deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise<any>;
abstract deleteCipherAttachment(
id: string,
attachmentId: string,
): Promise<DeleteAttachmentResponse>;
abstract deleteCipherAttachmentAdmin(
id: string,
attachmentId: string,
): Promise<DeleteAttachmentResponse>;
abstract postShareCipherAttachment(
id: string,
attachmentId: string,

View File

@@ -21,11 +21,7 @@ export abstract class BillingApiServiceAbstraction {
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationBillingMetadataVNext(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationBillingMetadataVNextSelfHost(
abstract getOrganizationBillingMetadataSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;

View File

@@ -36,20 +36,6 @@ export class BillingApiService implements BillingApiServiceAbstraction {
async getOrganizationBillingMetadata(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/metadata",
null,
true,
true,
);
return new OrganizationBillingMetadataResponse(r);
}
async getOrganizationBillingMetadataVNext(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(
"GET",
@@ -62,7 +48,7 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new OrganizationBillingMetadataResponse(r);
}
async getOrganizationBillingMetadataVNextSelfHost(
async getOrganizationBillingMetadataSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(

View File

@@ -1,13 +1,11 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { firstValueFrom } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { newGuid } from "@bitwarden/guid";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { OrganizationId } from "../../../types/guid";
import { DefaultOrganizationMetadataService } from "./organization-metadata.service";
@@ -15,9 +13,7 @@ import { DefaultOrganizationMetadataService } from "./organization-metadata.serv
describe("DefaultOrganizationMetadataService", () => {
let service: DefaultOrganizationMetadataService;
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
let configService: jest.Mocked<ConfigService>;
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
let featureFlagSubject: BehaviorSubject<boolean>;
const mockOrganizationId = newGuid() as OrganizationId;
const mockOrganizationId2 = newGuid() as OrganizationId;
@@ -34,182 +30,114 @@ describe("DefaultOrganizationMetadataService", () => {
beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
platformUtilsService = mock<PlatformUtilsService>();
featureFlagSubject = new BehaviorSubject<boolean>(false);
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
platformUtilsService.isSelfHost.mockReturnValue(false);
service = new DefaultOrganizationMetadataService(
billingApiService,
configService,
platformUtilsService,
);
service = new DefaultOrganizationMetadataService(billingApiService, platformUtilsService);
});
afterEach(() => {
jest.resetAllMocks();
featureFlagSubject.complete();
});
describe("getOrganizationMetadata$", () => {
describe("feature flag OFF", () => {
beforeEach(() => {
featureFlagSubject.next(false);
});
it("calls getOrganizationBillingMetadata for cloud-hosted", async () => {
const mockResponse = createMockMetadataResponse(false, 10);
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
it("calls getOrganizationBillingMetadata when feature flag is off", async () => {
const mockResponse = createMockMetadataResponse(false, 10);
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
it("does not cache metadata when feature flag is off", async () => {
const mockResponse1 = createMockMetadataResponse(false, 10);
const mockResponse2 = createMockMetadataResponse(false, 15);
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
expect(result1).toEqual(mockResponse1);
expect(result2).toEqual(mockResponse2);
});
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(result).toEqual(mockResponse);
});
describe("feature flag ON", () => {
beforeEach(() => {
featureFlagSubject.next(true);
});
it("calls getOrganizationBillingMetadataSelfHost when isSelfHost is true", async () => {
platformUtilsService.isSelfHost.mockReturnValue(true);
const mockResponse = createMockMetadataResponse(true, 25);
billingApiService.getOrganizationBillingMetadataSelfHost.mockResolvedValue(mockResponse);
it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => {
const mockResponse = createMockMetadataResponse(true, 15);
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
FeatureFlag.PM25379_UseNewOrganizationMetadataStructure,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
it("caches metadata by organization ID when feature flag is on", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
});
it("maintains separate cache entries for different organization IDs", async () => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(false, 20);
billingApiService.getOrganizationBillingMetadataVNext
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
1,
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith(
2,
mockOrganizationId2,
);
expect(result1).toEqual(mockResponse1);
expect(result2).toEqual(mockResponse2);
expect(result3).toEqual(mockResponse1);
expect(result4).toEqual(mockResponse2);
});
it("calls getOrganizationBillingMetadataVNextSelfHost when feature flag is on and isSelfHost is true", async () => {
platformUtilsService.isSelfHost.mockReturnValue(true);
const mockResponse = createMockMetadataResponse(true, 25);
billingApiService.getOrganizationBillingMetadataVNextSelfHost.mockResolvedValue(
mockResponse,
);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadataVNextSelfHost).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadataSelfHost).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
describe("shareReplay behavior", () => {
beforeEach(() => {
featureFlagSubject.next(true);
});
it("caches metadata by organization ID", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const metadata$ = service.getOrganizationMetadata$(mockOrganizationId);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
});
const subscription1Promise = firstValueFrom(metadata$);
const subscription2Promise = firstValueFrom(metadata$);
const subscription3Promise = firstValueFrom(metadata$);
it("maintains separate cache entries for different organization IDs", async () => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(false, 20);
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const [result1, result2, result3] = await Promise.all([
subscription1Promise,
subscription2Promise,
subscription3Promise,
]);
const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2));
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
expect(result3).toEqual(mockResponse);
});
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenNthCalledWith(
1,
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenNthCalledWith(
2,
mockOrganizationId2,
);
expect(result1).toEqual(mockResponse1);
expect(result2).toEqual(mockResponse2);
expect(result3).toEqual(mockResponse1);
expect(result4).toEqual(mockResponse2);
});
it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => {
const mockResponse = createMockMetadataResponse(true, 10);
billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse);
const metadata$ = service.getOrganizationMetadata$(mockOrganizationId);
const subscription1Promise = firstValueFrom(metadata$);
const subscription2Promise = firstValueFrom(metadata$);
const subscription3Promise = firstValueFrom(metadata$);
const [result1, result2, result3] = await Promise.all([
subscription1Promise,
subscription2Promise,
subscription3Promise,
]);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1);
expect(result1).toEqual(mockResponse);
expect(result2).toEqual(mockResponse);
expect(result3).toEqual(mockResponse);
});
});
describe("refreshMetadataCache", () => {
beforeEach(() => {
featureFlagSubject.next(true);
});
it("refreshes cached metadata when called with feature flag on", (done) => {
it("refreshes cached metadata when called", (done) => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(true, 20);
let invocationCount = 0;
billingApiService.getOrganizationBillingMetadataVNext
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
@@ -221,7 +149,7 @@ describe("DefaultOrganizationMetadataService", () => {
expect(result).toEqual(mockResponse1);
} else if (invocationCount === 2) {
expect(result).toEqual(mockResponse2);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
subscription.unsubscribe();
done();
}
@@ -234,45 +162,13 @@ describe("DefaultOrganizationMetadataService", () => {
}, 10);
});
it("does trigger refresh when feature flag is disabled", async () => {
featureFlagSubject.next(false);
const mockResponse1 = createMockMetadataResponse(false, 10);
const mockResponse2 = createMockMetadataResponse(false, 20);
let invocationCount = 0;
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2);
const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({
next: () => {
invocationCount++;
},
});
// wait for initial invocation
await new Promise((resolve) => setTimeout(resolve, 10));
expect(invocationCount).toBe(1);
service.refreshMetadataCache();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(invocationCount).toBe(2);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2);
subscription.unsubscribe();
});
it("bypasses cache when refreshing metadata", (done) => {
const mockResponse1 = createMockMetadataResponse(true, 10);
const mockResponse2 = createMockMetadataResponse(true, 20);
const mockResponse3 = createMockMetadataResponse(true, 30);
let invocationCount = 0;
billingApiService.getOrganizationBillingMetadataVNext
billingApiService.getOrganizationBillingMetadata
.mockResolvedValueOnce(mockResponse1)
.mockResolvedValueOnce(mockResponse2)
.mockResolvedValueOnce(mockResponse3);
@@ -289,7 +185,7 @@ describe("DefaultOrganizationMetadataService", () => {
service.refreshMetadataCache();
} else if (invocationCount === 3) {
expect(result).toEqual(mockResponse3);
expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3);
expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(3);
subscription.unsubscribe();
done();
}

View File

@@ -1,10 +1,8 @@
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
import { BehaviorSubject, from, Observable, shareReplay, switchMap } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { OrganizationId } from "../../../types/guid";
import { OrganizationMetadataServiceAbstraction } from "../../abstractions/organization-metadata.service.abstraction";
import { OrganizationBillingMetadataResponse } from "../../models/response/organization-billing-metadata.response";
@@ -17,7 +15,6 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
) {}
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
@@ -28,50 +25,26 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
};
getOrganizationMetadata$(orgId: OrganizationId): Observable<OrganizationBillingMetadataResponse> {
return combineLatest([
this.refreshMetadataTrigger,
this.configService.getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure),
]).pipe(
switchMap(([_, featureFlagEnabled]) =>
featureFlagEnabled
? this.vNextGetOrganizationMetadataInternal$(orgId)
: this.getOrganizationMetadataInternal$(orgId),
),
);
}
private vNextGetOrganizationMetadataInternal$(
orgId: OrganizationId,
): Observable<OrganizationBillingMetadataResponse> {
const cacheHit = this.metadataCache.get(orgId);
if (cacheHit) {
return cacheHit;
}
const result = from(this.fetchMetadata(orgId, true)).pipe(
shareReplay({ bufferSize: 1, refCount: false }),
);
this.metadataCache.set(orgId, result);
return result;
}
private getOrganizationMetadataInternal$(
organizationId: OrganizationId,
): Observable<OrganizationBillingMetadataResponse> {
return from(this.fetchMetadata(organizationId, false)).pipe(
shareReplay({ bufferSize: 1, refCount: false }),
return this.refreshMetadataTrigger.pipe(
switchMap(() => {
const cacheHit = this.metadataCache.get(orgId);
if (cacheHit) {
return cacheHit;
}
const result = from(this.fetchMetadata(orgId)).pipe(
shareReplay({ bufferSize: 1, refCount: false }),
);
this.metadataCache.set(orgId, result);
return result;
}),
);
}
private async fetchMetadata(
organizationId: OrganizationId,
featureFlagEnabled: boolean,
): Promise<OrganizationBillingMetadataResponse> {
return featureFlagEnabled
? this.platformUtilsService.isSelfHost()
? await this.billingApiService.getOrganizationBillingMetadataVNextSelfHost(organizationId)
: await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
return this.platformUtilsService.isSelfHost()
? await this.billingApiService.getOrganizationBillingMetadataSelfHost(organizationId)
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
}
}

View File

@@ -19,6 +19,7 @@ export enum FeatureFlag {
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password",
SafariAccountSwitching = "pm-5594-safari-account-switching",
PM31088_MasterPasswordServiceEmitSalt = "pm-31088-master-password-service-emit-salt",
/* Autofill */
UseUndeterminedCipherScenarioTriggeringLogic = "undetermined-cipher-scenario-logic",
@@ -30,7 +31,6 @@ export enum FeatureFlag {
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
PM26462_Milestone_3 = "pm-26462-milestone-3",
@@ -54,7 +54,6 @@ export enum FeatureFlag {
/* Tools */
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
ChromiumImporterWithABE = "pm-25855-chromium-importer-abe",
SendUIRefresh = "pm-28175-send-ui-refresh",
SendEmailOTP = "pm-19051-send-email-verification",
@@ -121,7 +120,6 @@ export const DefaultFeatureFlagValue = {
/* Tools */
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
[FeatureFlag.SendUIRefresh]: FALSE,
[FeatureFlag.SendEmailOTP]: FALSE,
@@ -144,11 +142,11 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
[FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE,
[FeatureFlag.SafariAccountSwitching]: FALSE,
[FeatureFlag.PM31088_MasterPasswordServiceEmitSalt]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
[FeatureFlag.PM26462_Milestone_3]: FALSE,

View File

@@ -17,8 +17,11 @@ import {
mockAccountServiceWith,
} from "../../../../spec";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { LogService } from "../../../platform/abstractions/log.service";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { USER_SERVER_CONFIG } from "../../../platform/services/config/default-config.service";
import { UserId } from "../../../types/guid";
import { MasterKey, UserKey } from "../../../types/key";
import { KeyGenerationService } from "../../crypto";
@@ -92,14 +95,52 @@ describe("MasterPasswordService", () => {
sut.saltForUser$(null as unknown as UserId);
}).toThrow("userId is null or undefined.");
});
// Removable with unwinding of PM31088_MasterPasswordServiceEmitSalt
it("throws when userid present but not in account service", async () => {
await expect(
firstValueFrom(sut.saltForUser$("00000000-0000-0000-0000-000000000001" as UserId)),
).rejects.toThrow("Cannot read properties of undefined (reading 'email')");
});
it("returns salt", async () => {
const salt = await firstValueFrom(sut.saltForUser$(userId));
expect(salt).toBeDefined();
// Removable with unwinding of PM31088_MasterPasswordServiceEmitSalt
it("returns email-derived salt for legacy path", async () => {
const result = await firstValueFrom(sut.saltForUser$(userId));
// mockAccountServiceWith defaults email to "email"
expect(result).toBe("email" as MasterPasswordSalt);
});
describe("saltForUser$ master password unlock data migration path", () => {
// Flagged with PM31088_MasterPasswordServiceEmitSalt PM-31088
beforeEach(() => {
stateProvider.singleUser.getFake(userId, USER_SERVER_CONFIG).nextState({
featureStates: {
[FeatureFlag.PM31088_MasterPasswordServiceEmitSalt]: true,
},
} as unknown as ServerConfig);
});
// Unwinding should promote these tests as part of saltForUser suite.
it("returns salt from master password unlock data", async () => {
const expectedSalt = "custom-salt" as MasterPasswordSalt;
const unlockData = new MasterPasswordUnlockData(
expectedSalt,
new PBKDF2KdfConfig(600_000),
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
stateProvider.singleUser
.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY)
.nextState(unlockData.toJSON());
const result = await firstValueFrom(sut.saltForUser$(userId));
expect(result).toBe(expectedSalt);
});
it("throws when master password unlock data is null", async () => {
stateProvider.singleUser.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY).nextState(null);
await expect(firstValueFrom(sut.saltForUser$(userId))).rejects.toThrow(
"Master password unlock data not found for user.",
);
});
});
});

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable } from "rxjs";
import { firstValueFrom, iif, map, Observable, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
@@ -12,8 +12,10 @@ import { KdfConfig } from "@bitwarden/key-management";
import { PureCrypto } from "@bitwarden/sdk-internal";
import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason";
import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum";
import { LogService } from "../../../platform/abstractions/log.service";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { USER_SERVER_CONFIG } from "../../../platform/services/config/default-config.service";
import {
MASTER_PASSWORD_DISK,
MASTER_PASSWORD_MEMORY,
@@ -102,9 +104,29 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
assertNonNullish(userId, "userId");
return this.accountService.accounts$.pipe(
map((accounts) => accounts[userId].email),
map((email) => this.emailToSalt(email)),
// Note: We can't use the config service as an abstraction here because it creates a circular dependency: ConfigService -> ConfigApiService -> ApiService -> VaultTimeoutSettingsService -> KeyService -> MP service.
return this.stateProvider.getUser(userId, USER_SERVER_CONFIG).state$.pipe(
map((serverConfig) =>
getFeatureFlagValue(serverConfig, FeatureFlag.PM31088_MasterPasswordServiceEmitSalt),
),
switchMap((enabled) =>
iif(
() => enabled,
this.masterPasswordUnlockData$(userId).pipe(
map((unlockData) => {
if (unlockData == null) {
throw new Error("Master password unlock data not found for user.");
}
return unlockData.salt;
}),
),
this.accountService.accounts$.pipe(
map((accounts) => accounts[userId].email),
map((email) => this.emailToSalt(email)),
),
),
),
);
}

View File

@@ -417,6 +417,142 @@ describe("Utils Service", () => {
// });
});
describe("fromArrayToHex(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a hex string", () => {
const arr = new Uint8Array([0x00, 0x01, 0x02, 0x0a, 0xff]);
const hexString = Utils.fromArrayToHex(arr);
expect(hexString).toBe("0001020aff");
});
runInBothEnvironments("should return null for null input", () => {
const hexString = Utils.fromArrayToHex(null);
expect(hexString).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const hexString = Utils.fromArrayToHex(arr);
expect(hexString).toBe("");
});
});
describe("fromArrayToB64(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a b64 string", () => {
const arr = new Uint8Array(asciiHelloWorldArray);
const b64String = Utils.fromArrayToB64(arr);
expect(b64String).toBe(b64HelloWorldString);
});
runInBothEnvironments("should return null for null input", () => {
const b64String = Utils.fromArrayToB64(null);
expect(b64String).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const b64String = Utils.fromArrayToB64(arr);
expect(b64String).toBe("");
});
});
describe("fromArrayToUrlB64(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a URL-safe b64 string", () => {
// Input that produces +, /, and = in standard base64
const arr = new Uint8Array([251, 255, 254]);
const urlB64String = Utils.fromArrayToUrlB64(arr);
// Standard b64 would be "+//+" with padding, URL-safe removes padding and replaces chars
expect(urlB64String).not.toContain("+");
expect(urlB64String).not.toContain("/");
expect(urlB64String).not.toContain("=");
});
runInBothEnvironments("should return null for null input", () => {
const urlB64String = Utils.fromArrayToUrlB64(null);
expect(urlB64String).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const urlB64String = Utils.fromArrayToUrlB64(arr);
expect(urlB64String).toBe("");
});
});
describe("fromArrayToByteString(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a byte string", () => {
const arr = new Uint8Array(asciiHelloWorldArray);
const byteString = Utils.fromArrayToByteString(arr);
expect(byteString).toBe(asciiHelloWorld);
});
runInBothEnvironments("should return null for null input", () => {
const byteString = Utils.fromArrayToByteString(null);
expect(byteString).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const byteString = Utils.fromArrayToByteString(arr);
expect(byteString).toBe("");
});
});
describe("fromArrayToUtf8(...)", () => {
const originalIsNode = Utils.isNode;
afterEach(() => {
Utils.isNode = originalIsNode;
});
runInBothEnvironments("should convert a Uint8Array to a UTF-8 string", () => {
const arr = new Uint8Array(asciiHelloWorldArray);
const utf8String = Utils.fromArrayToUtf8(arr);
expect(utf8String).toBe(asciiHelloWorld);
});
runInBothEnvironments("should return null for null input", () => {
const utf8String = Utils.fromArrayToUtf8(null);
expect(utf8String).toBeNull();
});
runInBothEnvironments("should return empty string for an empty Uint8Array", () => {
const arr = new Uint8Array([]);
const utf8String = Utils.fromArrayToUtf8(arr);
expect(utf8String).toBe("");
});
runInBothEnvironments("should handle multi-byte UTF-8 characters", () => {
// "日本" in UTF-8 bytes
const arr = new Uint8Array([0xe6, 0x97, 0xa5, 0xe6, 0x9c, 0xac]);
const utf8String = Utils.fromArrayToUtf8(arr);
expect(utf8String).toBe("日本");
});
});
describe("Base64 and ArrayBuffer round trip conversions", () => {
const originalIsNode = Utils.isNode;
@@ -447,10 +583,10 @@ describe("Utils Service", () => {
"should correctly round trip convert from base64 to ArrayBuffer and back",
() => {
// Convert known base64 string to ArrayBuffer
const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString).buffer;
const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString);
// Convert the ArrayBuffer back to a base64 string
const roundTrippedB64String = Utils.fromBufferToB64(bufferFromB64);
const roundTrippedB64String = Utils.fromArrayToB64(bufferFromB64);
// Compare the original base64 string with the round-tripped base64 string
expect(roundTrippedB64String).toBe(b64HelloWorldString);

View File

@@ -8,6 +8,8 @@ import { Observable, of, switchMap } from "rxjs";
import { getHostname, parse } from "tldts";
import { Merge } from "type-fest";
import "core-js/proposals/array-buffer-base64";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
@@ -129,6 +131,78 @@ export class Utils {
return arr;
}
/**
* Converts a Uint8Array to a hexadecimal string.
* @param arr - The Uint8Array to convert.
* @returns The hexadecimal string representation, or null if the input is null.
*/
static fromArrayToHex(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
// @ts-expect-error - polyfilled by core-js
return arr.toHex();
}
/**
* Converts a Uint8Array to a Base64 encoded string.
* @param arr - The Uint8Array to convert.
* @returns The Base64 encoded string, or null if the input is null.
*/
static fromArrayToB64(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
// @ts-expect-error - polyfilled by core-js
return arr.toBase64({ alphabet: "base64" });
}
/**
* Converts a Uint8Array to a URL-safe Base64 encoded string.
* @param arr - The Uint8Array to convert.
* @returns The URL-safe Base64 encoded string, or null if the input is null.
*/
static fromArrayToUrlB64(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
// @ts-expect-error - polyfilled by core-js
return arr.toBase64({ alphabet: "base64url" });
}
/**
* Converts a Uint8Array to a byte string (each byte as a character).
* @param arr - The Uint8Array to convert.
* @returns The byte string representation, or null if the input is null.
*/
static fromArrayToByteString(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
let byteString = "";
for (let i = 0; i < arr.length; i++) {
byteString += String.fromCharCode(arr[i]);
}
return byteString;
}
/**
* Converts a Uint8Array to a UTF-8 decoded string.
* @param arr - The Uint8Array containing UTF-8 encoded bytes.
* @returns The decoded UTF-8 string, or null if the input is null.
*/
static fromArrayToUtf8(arr: Uint8Array | null): string | null {
if (arr == null) {
return null;
}
return BufferLib.from(arr).toString("utf8");
}
/**
* Convert binary data into a Base64 string.
*
@@ -302,7 +376,7 @@ export class Utils {
}
static fromUtf8ToUrlB64(utfStr: string): string {
return Utils.fromBufferToUrlB64(Utils.fromUtf8ToArray(utfStr));
return Utils.fromArrayToUrlB64(Utils.fromUtf8ToArray(utfStr));
}
static fromB64ToUtf8(b64Str: string): string {

View File

@@ -115,6 +115,7 @@ import { CipherRequest } from "../vault/models/request/cipher.request";
import { AttachmentUploadDataResponse } from "../vault/models/response/attachment-upload-data.response";
import { AttachmentResponse } from "../vault/models/response/attachment.response";
import { CipherResponse } from "../vault/models/response/cipher.response";
import { DeleteAttachmentResponse } from "../vault/models/response/delete-attachment.response";
import { OptionalCipherResponse } from "../vault/models/response/optional-cipher.response";
import { InsecureUrlNotAllowedError } from "./api-errors";
@@ -590,18 +591,32 @@ export class ApiService implements ApiServiceAbstraction {
return new AttachmentUploadDataResponse(r);
}
deleteCipherAttachment(id: string, attachmentId: string): Promise<any> {
return this.send("DELETE", "/ciphers/" + id + "/attachment/" + attachmentId, null, true, true);
async deleteCipherAttachment(
id: string,
attachmentId: string,
): Promise<DeleteAttachmentResponse> {
const r = await this.send(
"DELETE",
"/ciphers/" + id + "/attachment/" + attachmentId,
null,
true,
true,
);
return new DeleteAttachmentResponse(r);
}
deleteCipherAttachmentAdmin(id: string, attachmentId: string): Promise<any> {
return this.send(
async deleteCipherAttachmentAdmin(
id: string,
attachmentId: string,
): Promise<DeleteAttachmentResponse> {
const r = await this.send(
"DELETE",
"/ciphers/" + id + "/attachment/" + attachmentId + "/admin",
null,
true,
true,
);
return new DeleteAttachmentResponse(r);
}
postShareCipherAttachment(

View File

@@ -0,0 +1,12 @@
import { BaseResponse } from "../../../models/response/base.response";
import { CipherResponse } from "./cipher.response";
export class DeleteAttachmentResponse extends BaseResponse {
cipher: CipherResponse;
constructor(response: any) {
super(response);
this.cipher = new CipherResponse(this.getResponseProperty("Cipher"));
}
}

View File

@@ -77,6 +77,7 @@ import { CipherShareRequest } from "../models/request/cipher-share.request";
import { CipherWithIdRequest } from "../models/request/cipher-with-id.request";
import { CipherRequest } from "../models/request/cipher.request";
import { CipherResponse } from "../models/response/cipher.response";
import { DeleteAttachmentResponse } from "../models/response/delete-attachment.response";
import { AttachmentView } from "../models/view/attachment.view";
import { CipherView } from "../models/view/cipher.view";
import { FieldView } from "../models/view/field.view";
@@ -1482,16 +1483,16 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
admin: boolean = false,
): Promise<CipherData> {
let cipherResponse = null;
let response: DeleteAttachmentResponse;
try {
cipherResponse = admin
response = admin
? await this.apiService.deleteCipherAttachmentAdmin(id, attachmentId)
: await this.apiService.deleteCipherAttachment(id, attachmentId);
} catch (e) {
return Promise.reject((e as ErrorResponse).getSingleMessage());
}
const cipherData = CipherData.fromJSON(cipherResponse?.cipher);
const cipherData = new CipherData(response.cipher);
return await this.deleteAttachment(id, cipherData.revisionDate, attachmentId, userId);
}

View File

@@ -93,12 +93,12 @@ export class CipherFileUploadService implements CipherFileUploadServiceAbstracti
response: CipherResponse,
uploadData: AttachmentUploadDataResponse,
isAdmin: boolean,
) {
return () => {
): () => Promise<void> {
return async () => {
if (isAdmin) {
return this.apiService.deleteCipherAttachmentAdmin(response.id, uploadData.attachmentId);
await this.apiService.deleteCipherAttachmentAdmin(response.id, uploadData.attachmentId);
} else {
return this.apiService.deleteCipherAttachment(response.id, uploadData.attachmentId);
await this.apiService.deleteCipherAttachment(response.id, uploadData.attachmentId);
}
};
}

View File

@@ -38,7 +38,7 @@ export class BerryComponent {
});
protected readonly textColor = computed(() => {
return this.variant() === "contrast" ? "tw-text-fg-dark" : "tw-text-fg-white";
return this.variant() === "contrast" ? "tw-text-fg-heading" : "tw-text-fg-contrast";
});
protected readonly padding = computed(() => {
@@ -67,7 +67,7 @@ export class BerryComponent {
warning: "tw-bg-bg-warning",
danger: "tw-bg-bg-danger",
accentPrimary: "tw-bg-fg-accent-primary-strong",
contrast: "tw-bg-bg-white",
contrast: "tw-bg-bg-primary",
};
return [

View File

@@ -75,7 +75,9 @@ export const statusType: Story = {
<bit-berry [type]="'status'" variant="warning"></bit-berry>
<bit-berry [type]="'status'" variant="danger"></bit-berry>
<bit-berry [type]="'status'" variant="accentPrimary"></bit-berry>
<bit-berry [type]="'status'" variant="contrast"></bit-berry>
<div class="tw-p-2 tw-bg-bg-contrast">
<bit-berry [type]="'status'" variant="contrast"></bit-berry>
</div>
</div>
`,
}),
@@ -153,8 +155,8 @@ export const AllVariants: Story = {
<bit-berry variant="accentPrimary" [value]="5000"></bit-berry>
</div>
<div class="tw-flex tw-items-center tw-gap-4 tw-bg-bg-dark">
<span class="tw-w-20 tw-text-fg-white">Contrast:</span>
<div class="tw-flex tw-items-center tw-gap-4 tw-bg-bg-contrast">
<span class="tw-w-20 tw-text-fg-contrast">Contrast:</span>
<bit-berry type="status" variant="contrast"></bit-berry>
<bit-berry variant="contrast" [value]="5"></bit-berry>
<bit-berry variant="contrast" [value]="50"></bit-berry>

View File

@@ -1,11 +1,9 @@
import { combineLatest, map, Observable } from "rxjs";
import { map, Observable } from "rxjs";
import { ClientType, DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { SemanticLogger } from "@bitwarden/common/tools/log";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
import { DataLoader, ImporterMetadata, Importers, ImportersMetadata, Loader } from "../metadata";
import { ImporterMetadata, Importers, ImportersMetadata } from "../metadata";
import { ImportType } from "../models/import-options";
import { availableLoaders } from "../util";
@@ -15,13 +13,8 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra
protected importers: ImportersMetadata = Importers;
private logger: SemanticLogger;
private chromiumWithABE$: Observable<boolean>;
constructor(protected system: SystemServiceProvider) {
this.logger = system.log({ type: "ImportMetadataService" });
this.chromiumWithABE$ = this.system.configService.getFeatureFlag$(
FeatureFlag.ChromiumImporterWithABE,
);
}
async init(): Promise<void> {
@@ -30,13 +23,13 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra
metadata$(type$: Observable<ImportType>): Observable<ImporterMetadata> {
const client = this.system.environment.getClientType();
const capabilities$ = combineLatest([type$, this.chromiumWithABE$]).pipe(
map(([type, enabled]) => {
const capabilities$ = type$.pipe(
map((type) => {
if (!this.importers) {
return { type, loaders: [] };
}
const loaders = this.availableLoaders(this.importers, type, client, enabled);
const loaders = availableLoaders(this.importers, type, client);
if (!loaders || loaders.length === 0) {
return { type, loaders: [] };
@@ -55,34 +48,4 @@ export class DefaultImportMetadataService implements ImportMetadataServiceAbstra
return capabilities$;
}
/** Determine the available loaders for the given import type and client, considering feature flags and environments */
private availableLoaders(
importers: ImportersMetadata,
type: ImportType,
client: ClientType,
withABESupport: boolean,
): DataLoader[] | undefined {
let loaders = availableLoaders(importers, type, client);
if (withABESupport) {
return loaders;
}
// Special handling for Brave and Chrome CSV imports on Windows Desktop
if (type === "bravecsv" || type === "chromecsv") {
try {
const device = this.system.environment.getDevice();
const isWindowsDesktop = device === DeviceType.WindowsDesktop;
if (isWindowsDesktop) {
// Exclude the Chromium loader if on Windows Desktop without ABE support
loaders = loaders?.filter((loader) => loader !== Loader.chromium);
}
} catch {
loaders = loaders?.filter((loader) => loader !== Loader.chromium);
}
}
return loaders;
}
}

View File

@@ -1,9 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, Subject, firstValueFrom } from "rxjs";
import { Subject, firstValueFrom } from "rxjs";
import { ClientType } from "@bitwarden/client-type";
import { DeviceType } from "@bitwarden/common/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
@@ -17,13 +15,10 @@ describe("ImportMetadataService", () => {
let systemServiceProvider: MockProxy<SystemServiceProvider>;
beforeEach(() => {
const configService = mock<ConfigService>();
const environment = mock<PlatformUtilsService>();
environment.getClientType.mockReturnValue(ClientType.Desktop);
systemServiceProvider = mock<SystemServiceProvider>({
configService,
environment,
log: jest.fn().mockReturnValue({ debug: jest.fn() }),
});
@@ -34,7 +29,6 @@ describe("ImportMetadataService", () => {
describe("metadata$", () => {
let typeSubject: Subject<ImportType>;
let mockLogger: { debug: jest.Mock };
let featureFlagSubject: BehaviorSubject<boolean>;
const environment = mock<PlatformUtilsService>();
environment.getClientType.mockReturnValue(ClientType.Desktop);
@@ -42,13 +36,8 @@ describe("ImportMetadataService", () => {
beforeEach(() => {
typeSubject = new Subject<ImportType>();
mockLogger = { debug: jest.fn() };
featureFlagSubject = new BehaviorSubject<boolean>(false);
const configService = mock<ConfigService>();
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject);
systemServiceProvider = mock<SystemServiceProvider>({
configService,
environment,
log: jest.fn().mockReturnValue(mockLogger),
});
@@ -78,7 +67,6 @@ describe("ImportMetadataService", () => {
afterEach(() => {
typeSubject.complete();
featureFlagSubject.complete();
});
it("should emit metadata when type$ emits", async () => {
@@ -129,86 +117,5 @@ describe("ImportMetadataService", () => {
"capabilities updated",
);
});
it("should update when feature flag changes", async () => {
environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader
const emissions: ImporterMetadata[] = [];
const subscription = sut.metadata$(typeSubject).subscribe((metadata) => {
emissions.push(metadata);
});
typeSubject.next(testType);
featureFlagSubject.next(true);
// Wait for emissions
await new Promise((resolve) => setTimeout(resolve, 0));
expect(emissions).toHaveLength(2);
// Disable ABE - chromium loader should be excluded
expect(emissions[0].loaders).not.toContain(Loader.chromium);
// Enabled ABE - chromium loader should be included
expect(emissions[1].loaders).toContain(Loader.chromium);
subscription.unsubscribe();
});
it("should exclude chromium loader when ABE is disabled and on Windows Desktop", async () => {
environment.getDevice.mockReturnValue(DeviceType.WindowsDesktop);
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
featureFlagSubject.next(false);
const metadataPromise = firstValueFrom(sut.metadata$(typeSubject));
typeSubject.next(testType);
const result = await metadataPromise;
expect(result.loaders).not.toContain(Loader.chromium);
expect(result.loaders).toContain(Loader.file);
});
it("should exclude chromium loader when ABE is disabled and getDevice throws error", async () => {
environment.getDevice.mockImplementation(() => {
throw new Error("Device detection failed");
});
const testType: ImportType = "bravecsv";
featureFlagSubject.next(false);
const metadataPromise = firstValueFrom(sut.metadata$(typeSubject));
typeSubject.next(testType);
const result = await metadataPromise;
expect(result.loaders).not.toContain(Loader.chromium);
expect(result.loaders).toContain(Loader.file);
});
it("should include chromium loader when ABE is disabled and not on Windows Desktop", async () => {
environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop);
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
featureFlagSubject.next(false);
const metadataPromise = firstValueFrom(sut.metadata$(typeSubject));
typeSubject.next(testType);
const result = await metadataPromise;
expect(result.loaders).toContain(Loader.chromium);
expect(result.loaders).toContain(Loader.file);
});
it("should include chromium loader when ABE is enabled regardless of device", async () => {
environment.getDevice.mockReturnValue(DeviceType.MacOsDesktop);
const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders
featureFlagSubject.next(true);
const metadataPromise = firstValueFrom(sut.metadata$(typeSubject));
typeSubject.next(testType);
const result = await metadataPromise;
expect(result.loaders).toContain(Loader.chromium);
});
});
});

View File

@@ -1,4 +1,4 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router";
import { mock } from "jest-mock-extended";
@@ -605,4 +605,150 @@ describe("LockComponent", () => {
expect(component.activeUnlockOption).toBe(UnlockOption.Biometrics);
});
});
describe("listenForUnlockOptionsChanges", () => {
const mockActiveAccount: Account = {
id: userId,
email: "test@example.com",
name: "Test User",
} as Account;
const mockUnlockOptions: UnlockOptions = {
masterPassword: { enabled: true },
pin: { enabled: false },
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available },
prf: { enabled: false },
};
beforeEach(() => {
(component as any).loading = false;
component.activeAccount = mockActiveAccount;
component.activeUnlockOption = null;
component.unlockOptions = null;
mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(mockUnlockOptions));
});
it("skips polling when loading is true", fakeAsync(() => {
(component as any).loading = true;
component["listenForUnlockOptionsChanges"]();
tick(0);
expect(mockLockComponentService.getAvailableUnlockOptions$).not.toHaveBeenCalled();
}));
it("skips polling when activeAccount is null", fakeAsync(() => {
component.activeAccount = null;
component["listenForUnlockOptionsChanges"]();
tick(0);
expect(mockLockComponentService.getAvailableUnlockOptions$).not.toHaveBeenCalled();
}));
it("fetches unlock options when loading is false and activeAccount exists", fakeAsync(() => {
component["listenForUnlockOptionsChanges"]();
tick(0);
expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledWith(userId);
expect(component.unlockOptions).toEqual(mockUnlockOptions);
}));
it("calls getAvailableUnlockOptions$ at 1000ms intervals", fakeAsync(() => {
component["listenForUnlockOptionsChanges"]();
// Initial timer fire at 0ms
tick(0);
expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledTimes(1);
// First poll at 1000ms
tick(1000);
expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledTimes(2);
// Second poll at 2000ms
tick(1000);
expect(mockLockComponentService.getAvailableUnlockOptions$).toHaveBeenCalledTimes(3);
}));
it("calls setDefaultActiveUnlockOption when activeUnlockOption is null", fakeAsync(() => {
component.activeUnlockOption = null;
const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption");
component["listenForUnlockOptionsChanges"]();
tick(0);
expect(setDefaultSpy).toHaveBeenCalledWith(mockUnlockOptions);
}));
it("does NOT call setDefaultActiveUnlockOption when activeUnlockOption is already set", fakeAsync(() => {
component.activeUnlockOption = UnlockOption.MasterPassword;
component.unlockOptions = mockUnlockOptions;
const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption");
component["listenForUnlockOptionsChanges"]();
tick(0);
expect(setDefaultSpy).not.toHaveBeenCalled();
}));
it("calls setDefaultActiveUnlockOption when biometrics becomes enabled", fakeAsync(() => {
component.activeUnlockOption = UnlockOption.MasterPassword;
// Start with biometrics disabled
component.unlockOptions = {
masterPassword: { enabled: true },
pin: { enabled: false },
biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available },
prf: { enabled: false },
};
// Mock response with biometrics enabled
const newUnlockOptions: UnlockOptions = {
masterPassword: { enabled: true },
pin: { enabled: false },
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available },
prf: { enabled: false },
};
mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(newUnlockOptions));
const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption");
const handleBioSpy = jest.spyOn(component as any, "handleBiometricsUnlockEnabled");
component["listenForUnlockOptionsChanges"]();
tick(0);
expect(setDefaultSpy).toHaveBeenCalledWith(newUnlockOptions);
expect(handleBioSpy).toHaveBeenCalled();
}));
it("does NOT call setDefaultActiveUnlockOption when biometrics was already enabled", fakeAsync(() => {
component.activeUnlockOption = UnlockOption.MasterPassword;
// Start with biometrics already enabled
component.unlockOptions = {
masterPassword: { enabled: true },
pin: { enabled: false },
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available },
prf: { enabled: false },
};
// Mock response with biometrics still enabled
const newUnlockOptions: UnlockOptions = {
masterPassword: { enabled: true },
pin: { enabled: false },
biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available },
prf: { enabled: false },
};
mockLockComponentService.getAvailableUnlockOptions$.mockReturnValue(of(newUnlockOptions));
const setDefaultSpy = jest.spyOn(component as any, "setDefaultActiveUnlockOption");
component["listenForUnlockOptionsChanges"]();
tick(0);
expect(setDefaultSpy).not.toHaveBeenCalled();
}));
});
});

View File

@@ -202,7 +202,8 @@ export class LockComponent implements OnInit, OnDestroy {
timer(0, 1000)
.pipe(
mergeMap(async () => {
if (this.activeAccount?.id != null) {
// Only perform polling after the component has loaded. This prevents multiple sources setting the default active unlock option on initialization.
if (this.loading === false && this.activeAccount?.id != null) {
const prevBiometricsEnabled = this.unlockOptions?.biometrics.enabled;
this.unlockOptions = await firstValueFrom(
@@ -210,7 +211,6 @@ export class LockComponent implements OnInit, OnDestroy {
);
if (this.activeUnlockOption == null) {
this.loading = false;
await this.setDefaultActiveUnlockOption(this.unlockOptions);
} else if (!prevBiometricsEnabled && this.unlockOptions?.biometrics.enabled) {
await this.setDefaultActiveUnlockOption(this.unlockOptions);
@@ -275,19 +275,18 @@ export class LockComponent implements OnInit, OnDestroy {
this.lockComponentService.getAvailableUnlockOptions$(activeAccount.id),
);
const canUseBiometrics = [
BiometricsStatus.Available,
...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES,
].includes(await this.biometricService.getBiometricsStatusForUser(activeAccount.id));
if (
!this.unlockOptions?.masterPassword.enabled &&
!this.unlockOptions?.pin.enabled &&
!canUseBiometrics
) {
// User has no available unlock options, force logout. This happens for TDE users without a masterpassword, that don't have a persistent unlock method set.
this.logService.warning("[LockComponent] User cannot unlock again. Logging out!");
await this.logoutService.logout(activeAccount.id);
return;
// The canUseBiometrics query is an expensive operation. Only call if both PIN and master password unlock are unavailable.
if (!this.unlockOptions?.masterPassword.enabled && !this.unlockOptions?.pin.enabled) {
const canUseBiometrics = [
BiometricsStatus.Available,
...BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES,
].includes(await this.biometricService.getBiometricsStatusForUser(activeAccount.id));
if (!canUseBiometrics) {
// User has no available unlock options, force logout. This happens for TDE users without a masterpassword, that don't have a persistent unlock method set.
this.logService.warning("[LockComponent] User cannot unlock again. Logging out!");
await this.logoutService.logout(activeAccount.id);
return;
}
}
await this.setDefaultActiveUnlockOption(this.unlockOptions);

View File

@@ -127,4 +127,57 @@ describe("SendDetailsComponent", () => {
expect(emailsControl?.validator).toBeNull();
expect(passwordControl?.validator).toBeNull();
});
it("should show validation error when emails are cleared while authType is Email", () => {
// Set authType to Email with valid emails
component.sendDetailsForm.patchValue({
authType: AuthType.Email,
emails: "test@example.com",
});
expect(component.sendDetailsForm.get("emails")?.valid).toBe(true);
// Clear emails - should trigger validation error
component.sendDetailsForm.patchValue({ emails: "" });
expect(component.sendDetailsForm.get("emails")?.valid).toBe(false);
expect(component.sendDetailsForm.get("emails")?.hasError("emailsRequiredForEmailAuth")).toBe(
true,
);
});
it("should clear validation error when authType is changed from Email after clearing emails", () => {
// Set authType to Email and then clear emails
component.sendDetailsForm.patchValue({
authType: AuthType.Email,
emails: "test@example.com",
});
component.sendDetailsForm.patchValue({ emails: "" });
expect(component.sendDetailsForm.get("emails")?.valid).toBe(false);
// Change authType to None - emails field should become valid (no longer required)
component.sendDetailsForm.patchValue({ authType: AuthType.None });
expect(component.sendDetailsForm.get("emails")?.valid).toBe(true);
});
it("should force user to change authType by blocking form submission when emails are cleared", () => {
// Set up a send with email verification
component.sendDetailsForm.patchValue({
name: "Test Send",
authType: AuthType.Email,
emails: "user@example.com",
});
expect(component.sendDetailsForm.valid).toBe(true);
// User clears emails field
component.sendDetailsForm.patchValue({ emails: "" });
// Form should now be invalid, preventing save
expect(component.sendDetailsForm.valid).toBe(false);
expect(component.sendDetailsForm.get("emails")?.hasError("emailsRequiredForEmailAuth")).toBe(
true,
);
// User must change authType to continue
component.sendDetailsForm.patchValue({ authType: AuthType.None });
expect(component.sendDetailsForm.valid).toBe(true);
});
});

View File

@@ -224,7 +224,10 @@ export class SendDetailsComponent implements OnInit {
} else if (type === AuthType.Email) {
passwordControl.setValue(null);
passwordControl.clearValidators();
emailsControl.setValidators([Validators.required, this.emailListValidator()]);
emailsControl.setValidators([
this.emailsRequiredForEmailAuthValidator(),
this.emailListValidator(),
]);
} else {
emailsControl.setValue(null);
emailsControl.clearValidators();
@@ -317,6 +320,23 @@ export class SendDetailsComponent implements OnInit {
};
}
emailsRequiredForEmailAuthValidator(): ValidatorFn {
return (control: FormControl): ValidationErrors | null => {
const authType = this.sendDetailsForm?.get("authType")?.value;
const emails = control.value;
if (authType === AuthType.Email && (!emails || emails.trim() === "")) {
return {
emailsRequiredForEmailAuth: {
message: this.i18nService.t("emailsRequiredChangeAccessType"),
},
};
}
return null;
};
}
generatePassword = async () => {
const on$ = new BehaviorSubject<GenerateRequest>({ source: "send", type: Type.password });
const account$ = this.accountService.activeAccount$.pipe(

18
package-lock.json generated
View File

@@ -23,8 +23,8 @@
"@angular/platform-browser": "20.3.16",
"@angular/platform-browser-dynamic": "20.3.16",
"@angular/router": "20.3.16",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.527",
"@bitwarden/sdk-internal": "0.2.0-main.527",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.550",
"@bitwarden/sdk-internal": "0.2.0-main.550",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",
@@ -191,7 +191,7 @@
},
"apps/browser": {
"name": "@bitwarden/browser",
"version": "2026.1.0"
"version": "2026.1.1"
},
"apps/cli": {
"name": "@bitwarden/cli",
@@ -4941,9 +4941,9 @@
"link": true
},
"node_modules/@bitwarden/commercial-sdk-internal": {
"version": "0.2.0-main.527",
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.527.tgz",
"integrity": "sha512-4C4lwOgA2v184G2axUR5Jdb4UMXMhF52a/3c0lAZYbD/8Nid6jziE89nCa9hdfdazuPgWXhVFa3gPrhLZ4uTUQ==",
"version": "0.2.0-main.550",
"resolved": "https://registry.npmjs.org/@bitwarden/commercial-sdk-internal/-/commercial-sdk-internal-0.2.0-main.550.tgz",
"integrity": "sha512-hYdGV3qs+kKrAMTIvMfolWz23XXZ8bJGzMGi+gh5EBpjTE4OsAsLKp0JDgpjlpE+cdheSFXyhTU9D1Ujdqzzrg==",
"license": "BITWARDEN SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT",
"dependencies": {
"type-fest": "^4.41.0"
@@ -5046,9 +5046,9 @@
"link": true
},
"node_modules/@bitwarden/sdk-internal": {
"version": "0.2.0-main.527",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.527.tgz",
"integrity": "sha512-dxPh4XjEGFDBASRBEd/JwUdoMAz10W/0QGygYkPwhKKGzJncfDEAgQ/KrT9wc36ycrDrOOspff7xs/vmmzI0+A==",
"version": "0.2.0-main.550",
"resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.550.tgz",
"integrity": "sha512-uAGgP+Y2FkxOZ74+9C4JHaM+YbJTI3806akeDg7w2yvfNNryIsLncwvb8FoFgiN6dEY1o9YSzuuv0YYUnbAMww==",
"license": "GPL-3.0",
"dependencies": {
"type-fest": "^4.41.0"

View File

@@ -161,8 +161,8 @@
"@angular/platform-browser": "20.3.16",
"@angular/platform-browser-dynamic": "20.3.16",
"@angular/router": "20.3.16",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.527",
"@bitwarden/sdk-internal": "0.2.0-main.527",
"@bitwarden/commercial-sdk-internal": "0.2.0-main.550",
"@bitwarden/sdk-internal": "0.2.0-main.550",
"@electron/fuses": "1.8.0",
"@emotion/css": "11.13.5",
"@koa/multer": "4.0.0",

View File

@@ -24,8 +24,8 @@
"@bitwarden/assets/svg": ["./libs/assets/src/svg/index.ts"],
"@bitwarden/auth/angular": ["./libs/auth/src/angular"],
"@bitwarden/auth/common": ["./libs/auth/src/common"],
"@bitwarden/auto-confirm": ["libs/auto-confirm/src/index.ts"],
"@bitwarden/auto-confirm/angular": ["libs/auto-confirm/src/angular"],
"@bitwarden/auto-confirm": ["./libs/auto-confirm/src/index.ts"],
"@bitwarden/auto-confirm/angular": ["./libs/auto-confirm/src/angular"],
"@bitwarden/billing": ["./libs/billing/src"],
"@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"],
"@bitwarden/browser/*": ["./apps/browser/src/*"],