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:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/release-web.yml
vendored
1
.github/workflows/release-web.yml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"invalidVerificationCode": {
|
||||
"message": "Invalid verification code."
|
||||
},
|
||||
"invalidEmailOrVerificationCode": {
|
||||
"message": "Invalid email or verification code"
|
||||
},
|
||||
"masterPassRequired": {
|
||||
"message": "Master password is required."
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -676,7 +676,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
|
||||
private async decryptViaApprovedAuthRequest(
|
||||
authRequestResponse: AuthRequestResponse,
|
||||
privateKey: ArrayBuffer,
|
||||
privateKey: Uint8Array,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
/**
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
18
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/*"],
|
||||
|
||||
Reference in New Issue
Block a user