1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 18:09:17 +00:00

Merge branch 'main' into auth/pm-20532/tech-breakdown-poc-token-based-send-authn-and-authz

This commit is contained in:
Jared Snider
2025-05-29 12:20:47 -04:00
committed by GitHub
236 changed files with 6752 additions and 5621 deletions

View File

@@ -59,6 +59,7 @@
{
matchPackageNames: [
"@angular-eslint/schematics",
"@eslint/compat",
"@typescript-eslint/rule-tester",
"@typescript-eslint/utils",
"angular-eslint",
@@ -81,6 +82,7 @@
{
matchPackageNames: [
"@angular-eslint/schematics",
"@eslint/compat",
"@typescript-eslint/rule-tester",
"@typescript-eslint/utils",
"angular-eslint",
@@ -405,6 +407,18 @@
commitMessagePrefix: "[deps] KM:",
reviewers: ["team:team-key-management-dev"],
},
{
// Any versions of lowdb above 1.0.0 are not compatible with CommonJS.
matchPackageNames: ["lowdb"],
allowedVersions: "1.0.0",
description: "Higher versions of lowdb are not compatible with CommonJS",
},
{
// Pin types as well since we are not upgrading past v1 (and also v2+ does not need separate types).
matchPackageNames: ["@types/lowdb"],
allowedVersions: "< 2.0.0",
description: "Higher versions of lowdb do not need separate types",
},
],
ignoreDeps: ["@types/koa-bodyparser", "bootstrap", "node-ipc", "@bitwarden/sdk-internal"],
}

View File

@@ -2204,6 +2204,9 @@
"useThisPassword": {
"message": "Use this password"
},
"useThisPassphrase": {
"message": "Use this passphrase"
},
"useThisUsername": {
"message": "Use this username"
},

View File

@@ -10,8 +10,6 @@ import {
ButtonModule,
IconButtonModule,
ItemModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
@@ -27,8 +25,6 @@ import {
IconButtonModule,
ItemModule,
JslibModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
],
})

View File

@@ -18,7 +18,6 @@ import {
} from "@bitwarden/components";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@@ -31,7 +30,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
RouterModule,
PopupPageComponent,
PopupHeaderComponent,
PopupFooterComponent,
PopOutComponent,
ItemModule,
CardComponent,

View File

@@ -1579,252 +1579,6 @@ export default class AutofillService implements AutofillServiceInterface {
return [expectedDateFormat, dateFormatPatterns];
}
/**
* Generates the autofill script for the specified page details and identify cipher item.
* @param {AutofillScript} fillScript
* @param {AutofillPageDetails} pageDetails
* @param {{[p: string]: AutofillField}} filledFields
* @param {GenerateFillScriptOptions} options
* @returns {AutofillScript}
* @private
*/
private async generateIdentityFillScript(
fillScript: AutofillScript,
pageDetails: AutofillPageDetails,
filledFields: { [id: string]: AutofillField },
options: GenerateFillScriptOptions,
): Promise<AutofillScript> {
if (await this.configService.getFeatureFlag(FeatureFlag.GenerateIdentityFillScriptRefactor)) {
return this._generateIdentityFillScript(fillScript, pageDetails, filledFields, options);
}
if (!options.cipher.identity) {
return null;
}
const fillFields: { [id: string]: AutofillField } = {};
pageDetails.fields.forEach((f) => {
if (
AutofillService.isExcludedFieldType(f, AutoFillConstants.ExcludedAutofillTypes) ||
["current-password", "new-password"].includes(f.autoCompleteType)
) {
return;
}
for (let i = 0; i < IdentityAutoFillConstants.IdentityAttributes.length; i++) {
const attr = IdentityAutoFillConstants.IdentityAttributes[i];
// eslint-disable-next-line
if (!f.hasOwnProperty(attr) || !f[attr] || !f.viewable) {
continue;
}
// ref https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill
// ref https://developers.google.com/web/fundamentals/design-and-ux/input/forms/
if (
!fillFields.name &&
AutofillService.isFieldMatch(
f[attr],
IdentityAutoFillConstants.FullNameFieldNames,
IdentityAutoFillConstants.FullNameFieldNameValues,
)
) {
fillFields.name = f;
break;
} else if (
!fillFields.firstName &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.FirstnameFieldNames)
) {
fillFields.firstName = f;
break;
} else if (
!fillFields.middleName &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.MiddlenameFieldNames)
) {
fillFields.middleName = f;
break;
} else if (
!fillFields.lastName &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.LastnameFieldNames)
) {
fillFields.lastName = f;
break;
} else if (
!fillFields.title &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.TitleFieldNames)
) {
fillFields.title = f;
break;
} else if (
!fillFields.email &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.EmailFieldNames)
) {
fillFields.email = f;
break;
} else if (
!fillFields.address1 &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.Address1FieldNames)
) {
fillFields.address1 = f;
break;
} else if (
!fillFields.address2 &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.Address2FieldNames)
) {
fillFields.address2 = f;
break;
} else if (
!fillFields.address3 &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.Address3FieldNames)
) {
fillFields.address3 = f;
break;
} else if (
!fillFields.address &&
AutofillService.isFieldMatch(
f[attr],
IdentityAutoFillConstants.AddressFieldNames,
IdentityAutoFillConstants.AddressFieldNameValues,
)
) {
fillFields.address = f;
break;
} else if (
!fillFields.postalCode &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.PostalCodeFieldNames)
) {
fillFields.postalCode = f;
break;
} else if (
!fillFields.city &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.CityFieldNames)
) {
fillFields.city = f;
break;
} else if (
!fillFields.state &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.StateFieldNames)
) {
fillFields.state = f;
break;
} else if (
!fillFields.country &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.CountryFieldNames)
) {
fillFields.country = f;
break;
} else if (
!fillFields.phone &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.PhoneFieldNames)
) {
fillFields.phone = f;
break;
} else if (
!fillFields.username &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.UserNameFieldNames)
) {
fillFields.username = f;
break;
} else if (
!fillFields.company &&
AutofillService.isFieldMatch(f[attr], IdentityAutoFillConstants.CompanyFieldNames)
) {
fillFields.company = f;
break;
}
}
});
const identity = options.cipher.identity;
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "title");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "firstName");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "middleName");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "lastName");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "address1");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "address2");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "address3");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "city");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "postalCode");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "company");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "email");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "phone");
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "username");
let filledState = false;
if (fillFields.state && identity.state && identity.state.length > 2) {
const stateLower = identity.state.toLowerCase();
const isoState =
IdentityAutoFillConstants.IsoStates[stateLower] ||
IdentityAutoFillConstants.IsoProvinces[stateLower];
if (isoState) {
filledState = true;
this.makeScriptActionWithValue(fillScript, isoState, fillFields.state, filledFields);
}
}
if (!filledState) {
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "state");
}
let filledCountry = false;
if (fillFields.country && identity.country && identity.country.length > 2) {
const countryLower = identity.country.toLowerCase();
const isoCountry = IdentityAutoFillConstants.IsoCountries[countryLower];
if (isoCountry) {
filledCountry = true;
this.makeScriptActionWithValue(fillScript, isoCountry, fillFields.country, filledFields);
}
}
if (!filledCountry) {
this.makeScriptAction(fillScript, identity, fillFields, filledFields, "country");
}
if (fillFields.name && (identity.firstName || identity.lastName)) {
let fullName = "";
if (AutofillService.hasValue(identity.firstName)) {
fullName = identity.firstName;
}
if (AutofillService.hasValue(identity.middleName)) {
if (fullName !== "") {
fullName += " ";
}
fullName += identity.middleName;
}
if (AutofillService.hasValue(identity.lastName)) {
if (fullName !== "") {
fullName += " ";
}
fullName += identity.lastName;
}
this.makeScriptActionWithValue(fillScript, fullName, fillFields.name, filledFields);
}
if (fillFields.address && AutofillService.hasValue(identity.address1)) {
let address = "";
if (AutofillService.hasValue(identity.address1)) {
address = identity.address1;
}
if (AutofillService.hasValue(identity.address2)) {
if (address !== "") {
address += ", ";
}
address += identity.address2;
}
if (AutofillService.hasValue(identity.address3)) {
if (address !== "") {
address += ", ";
}
address += identity.address3;
}
this.makeScriptActionWithValue(fillScript, address, fillFields.address, filledFields);
}
return fillScript;
}
/**
* Generates the autofill script for the specified page details and identity cipher item.
*
@@ -1833,7 +1587,7 @@ export default class AutofillService implements AutofillServiceInterface {
* @param filledFields - The fields that have already been filled, passed between method references
* @param options - Contains data used to fill cipher items
*/
private _generateIdentityFillScript(
private generateIdentityFillScript(
fillScript: AutofillScript,
pageDetails: AutofillPageDetails,
filledFields: { [id: string]: AutofillField },

View File

@@ -17,7 +17,6 @@ export default class VaultTimeoutService extends BaseVaultTimeoutService {
// setIntervals. It works by calling the native extension which sleeps for 10s,
// efficiently replicating setInterval.
async checkSafari() {
// eslint-disable-next-line
while (true) {
try {
await SafariApp.sendMessageToApp("sleep");

View File

@@ -7,13 +7,13 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
import { BadgeSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/badge-settings.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import MainBackground from "../../background/main.background";
import IconDetails from "../../vault/background/models/icon-details";
import { BrowserApi } from "../browser/browser-api";
import { BrowserPlatformUtilsService } from "../services/platform-utils/browser-platform-utils.service";
export type BadgeOptions = {
tab?: chrome.tabs.Tab;
@@ -28,6 +28,7 @@ export class UpdateBadge {
private badgeAction: typeof chrome.action | typeof chrome.browserAction;
private sidebarAction: OperaSidebarAction | FirefoxSidebarAction;
private win: Window & typeof globalThis;
private platformUtilsService: PlatformUtilsService;
constructor(win: Window & typeof globalThis, services: MainBackground) {
this.badgeAction = BrowserApi.getBrowserAction();
@@ -38,6 +39,7 @@ export class UpdateBadge {
this.authService = services.authService;
this.cipherService = services.cipherService;
this.accountService = services.accountService;
this.platformUtilsService = services.platformUtilsService;
}
async run(opts?: { tabId?: number; windowId?: number }): Promise<void> {
@@ -129,7 +131,7 @@ export class UpdateBadge {
38: "/images/icon38" + iconSuffix + ".png",
},
};
if (windowId && BrowserPlatformUtilsService.isFirefox()) {
if (windowId && this.platformUtilsService.isFirefox()) {
options.windowId = windowId;
}
@@ -204,9 +206,7 @@ export class UpdateBadge {
}
private get useSyncApiCalls() {
return (
BrowserPlatformUtilsService.isFirefox() || BrowserPlatformUtilsService.isSafari(this.win)
);
return this.platformUtilsService.isFirefox() || this.platformUtilsService.isSafari();
}
private isOperaSidebar(

View File

@@ -185,7 +185,6 @@ class MockVaultPageComponent {}
PopupPageComponent,
PopupHeaderComponent,
MockAddButtonComponent,
MockPopoutButtonComponent,
MockCurrentAccountComponent,
VaultComponent,
],
@@ -290,9 +289,7 @@ class MockSettingsPageComponent {}
PopupHeaderComponent,
PopupFooterComponent,
ButtonModule,
MockAddButtonComponent,
MockPopoutButtonComponent,
MockCurrentAccountComponent,
VaultComponent,
IconButtonModule,
],

View File

@@ -21,6 +21,8 @@ export class BrowserRouterService {
child = child.firstChild;
}
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
const updateUrl = !child?.data?.doNotSaveUrl ?? true;
if (updateUrl) {

View File

@@ -62,6 +62,8 @@ export class PopupRouterCacheService {
child = child.firstChild;
}
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
return !child?.data?.doNotSaveUrl ?? true;
}),
switchMap((event) => this.push(event.url)),

View File

@@ -126,12 +126,11 @@ describe("Browser Utils Service", () => {
configurable: true,
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:58.0) Gecko/20100101 Firefox/58.0",
});
jest.spyOn(BrowserPlatformUtilsService, "isFirefox");
browserPlatformUtilsService.getDevice();
expect(browserPlatformUtilsService.getDevice()).toBe(DeviceType.FirefoxExtension);
expect(BrowserPlatformUtilsService.isFirefox).toHaveBeenCalledTimes(1);
expect(browserPlatformUtilsService.isFirefox()).toBe(true);
});
});

View File

@@ -60,10 +60,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return ClientType.Browser;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
static isFirefox(): boolean {
private static isFirefox(): boolean {
return (
navigator.userAgent.indexOf(" Firefox/") !== -1 ||
navigator.userAgent.indexOf(" Gecko/") !== -1
@@ -74,9 +71,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.FirefoxExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
private static isChrome(globalContext: Window | ServiceWorkerGlobalScope): boolean {
return globalContext.chrome && navigator.userAgent.indexOf(" Chrome/") !== -1;
}
@@ -85,9 +79,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.ChromeExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
private static isEdge(): boolean {
return navigator.userAgent.indexOf(" Edg/") !== -1;
}
@@ -96,9 +87,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.EdgeExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
private static isOpera(globalContext: Window | ServiceWorkerGlobalScope): boolean {
return (
!!globalContext.opr?.addons ||
@@ -111,9 +99,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.OperaExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
private static isVivaldi(): boolean {
return navigator.userAgent.indexOf(" Vivaldi/") !== -1;
}
@@ -122,10 +107,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return this.getDevice() === DeviceType.VivaldiExtension;
}
/**
* @deprecated Do not call this directly, use getDevice() instead
*/
static isSafari(globalContext: Window | ServiceWorkerGlobalScope): boolean {
private static isSafari(globalContext: Window | ServiceWorkerGlobalScope): boolean {
// Opera masquerades as Safari, so make sure we're not there first
return (
!BrowserPlatformUtilsService.isOpera(globalContext) &&
@@ -137,6 +119,10 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return navigator.userAgent.match("Version/([0-9.]*)")?.[1];
}
isSafari(): boolean {
return this.getDevice() === DeviceType.SafariExtension;
}
/**
* Safari previous to version 16.1 had a bug which caused artifacts on hover in large extension popups.
* https://bugs.webkit.org/show_bug.cgi?id=218704
@@ -151,10 +137,6 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
return parts?.[0] < 16 || (parts?.[0] === 16 && parts?.[1] === 0);
}
isSafari(): boolean {
return this.getDevice() === DeviceType.SafariExtension;
}
isIE(): boolean {
return false;
}

View File

@@ -7,7 +7,6 @@ import { GeneratorModule } from "@bitwarden/generator-components";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@@ -22,7 +21,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
PopOutComponent,
PopupHeaderComponent,
PopupPageComponent,
PopupFooterComponent,
RouterModule,
ItemModule,
],

View File

@@ -3,7 +3,7 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router, RouterLink, RouterModule } from "@angular/router";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -31,7 +31,6 @@ import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page
PopOutComponent,
PopupHeaderComponent,
PopupPageComponent,
RouterLink,
RouterModule,
PopupFooterComponent,
IconModule,

View File

@@ -6,7 +6,7 @@
<div bitDialogContent>
<p>&copy; Bitwarden Inc. 2015-{{ year }}</p>
<div #version class="user-select">
<div #version>
<p>{{ "version" | i18n }}: {{ version$ | async }}</p>
<p>SDK: {{ sdkVersion$ | async }}</p>
<ng-container *ngIf="data$ | async as data">

View File

@@ -18,13 +18,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import {
BadgeModule,
CardComponent,
ItemModule,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import { BadgeModule, ItemModule, ToastService, TypographyModule } from "@bitwarden/components";
import BrowserPopupUtils from "../../../../../../platform/popup/browser-popup-utils";
import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/file-popout-utils.service";
@@ -33,7 +27,7 @@ import { FilePopoutUtilsService } from "../../../../../../tools/popup/services/f
standalone: true,
selector: "app-open-attachments",
templateUrl: "./open-attachments.component.html",
imports: [BadgeModule, CommonModule, ItemModule, JslibModule, TypographyModule, CardComponent],
imports: [BadgeModule, CommonModule, ItemModule, JslibModule, TypographyModule],
})
export class OpenAttachmentsComponent implements OnInit {
/** Cipher `id` */

View File

@@ -6,12 +6,7 @@ import { combineLatest, map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import {
IconButtonModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
} from "@bitwarden/components";
import { IconButtonModule, TypographyModule } from "@bitwarden/components";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
@@ -23,11 +18,9 @@ import { VaultListItemsContainerComponent } from "../vault-list-items-container/
standalone: true,
imports: [
CommonModule,
SectionComponent,
TypographyModule,
VaultListItemsContainerComponent,
JslibModule,
SectionHeaderComponent,
IconButtonModule,
],
selector: "app-autofill-vault-list-items",

View File

@@ -74,7 +74,6 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
ScrollingModule,
DisclosureComponent,
DisclosureTriggerForDirective,
DecryptionFailureDialogComponent,
],
selector: "app-vault-list-items-container",
templateUrl: "vault-list-items-container.component.html",

View File

@@ -1,8 +1,8 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { Component, NgZone } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormsModule } from "@angular/forms";
import { Subject, Subscription, debounceTime, filter } from "rxjs";
import { Subject, Subscription, debounceTime, distinctUntilChanged, filter } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SearchModule } from "@bitwarden/components";
@@ -22,13 +22,16 @@ export class VaultV2SearchComponent {
private searchText$ = new Subject<string>();
constructor(private vaultPopupItemsService: VaultPopupItemsService) {
constructor(
private vaultPopupItemsService: VaultPopupItemsService,
private ngZone: NgZone,
) {
this.subscribeToLatestSearchText();
this.subscribeToApplyFilter();
}
onSearchTextChanged() {
this.vaultPopupItemsService.applyFilter(this.searchText);
this.searchText$.next(this.searchText);
}
subscribeToLatestSearchText(): Subscription {
@@ -44,9 +47,13 @@ export class VaultV2SearchComponent {
subscribeToApplyFilter(): Subscription {
return this.searchText$
.pipe(debounceTime(SearchTextDebounceInterval), takeUntilDestroyed())
.pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed())
.subscribe((data) => {
this.vaultPopupItemsService.applyFilter(data);
this.ngZone.runOutsideAngular(() => {
this.ngZone.run(() => {
this.vaultPopupItemsService.applyFilter(data);
});
});
});
}
}

View File

@@ -9,7 +9,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CardComponent, LinkModule, TypographyModule } from "@bitwarden/components";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@@ -26,7 +25,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
PopOutComponent,
CardComponent,
TypographyModule,
CurrentAccountComponent,
LinkModule,
],
})

View File

@@ -49,7 +49,6 @@ import { PopupCipherView } from "../../views/popup-cipher.view";
IconButtonModule,
OrgIconDirective,
TypographyModule,
DecryptionFailureDialogComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})

View File

@@ -10,7 +10,6 @@ import { ItemModule, ToastOptions, ToastService } from "@bitwarden/components";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@@ -22,7 +21,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
JslibModule,
RouterModule,
PopupPageComponent,
PopupFooterComponent,
PopupHeaderComponent,
PopOutComponent,
ItemModule,

View File

@@ -86,7 +86,7 @@
"node-fetch": "2.6.12",
"node-forge": "1.3.1",
"open": "8.4.2",
"papaparse": "5.5.2",
"papaparse": "5.5.3",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",
"tldts": "7.0.1",

View File

@@ -161,7 +161,6 @@ export class CliUtils {
process.stdin.setEncoding("utf8");
process.stdin.on("readable", () => {
// eslint-disable-next-line
while (true) {
const chunk = process.stdin.read();
if (chunk == null) {

View File

@@ -498,9 +498,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.9.0"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "camino"
@@ -863,6 +863,7 @@ dependencies = [
"anyhow",
"arboard",
"argon2",
"ashpd",
"base64",
"bitwarden-russh",
"byteorder",

View File

@@ -13,11 +13,12 @@ aes = "=0.8.4"
anyhow = "=1.0.94"
arboard = { version = "=3.5.0", default-features = false }
argon2 = "=0.5.3"
ashpd = "=0.11.0"
base64 = "=0.22.1"
bindgen = "=0.71.1"
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "3d48f140fd506412d186203238993163a8c4e536" }
byteorder = "=1.5.0"
bytes = "=1.9.0"
bytes = "=1.10.1"
cbc = "=0.1.2"
core-foundation = "=0.10.0"
dirs = "=6.0.0"

View File

@@ -85,6 +85,7 @@ desktop_objc = { path = "../objc" }
[target.'cfg(target_os = "linux")'.dependencies]
oo7 = { workspace = true }
libc = { workspace = true }
ashpd = { workspace = true }
zbus = { workspace = true, optional = true }
zbus_polkit = { workspace = true, optional = true }

View File

@@ -0,0 +1,21 @@
use anyhow::Result;
use ashpd::desktop::background::Background;
pub async fn set_autostart(autostart: bool, params: Vec<String>) -> Result<()> {
let request = if params.is_empty() {
Background::request().auto_start(autostart)
} else {
Background::request().command(params).auto_start(autostart)
};
match request.send().await.and_then(|r| r.response()) {
Ok(response) => {
println!("[ASHPD] Autostart enabled: {:?}", response);
Ok(())
}
Err(err) => {
println!("[ASHPD] Error enabling autostart: {}", err);
Err(anyhow::anyhow!("error enabling autostart {}", err))
}
}
}

View File

@@ -0,0 +1,5 @@
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "unimplemented.rs")]
#[cfg_attr(target_os = "macos", path = "unimplemented.rs")]
mod autostart_impl;
pub use autostart_impl::*;

View File

@@ -0,0 +1,5 @@
use anyhow::Result;
pub async fn set_autostart(_autostart: bool, _params: Vec<String>) -> Result<()> {
unimplemented!();
}

View File

@@ -1,4 +1,5 @@
pub mod autofill;
pub mod autostart;
pub mod biometric;
pub mod clipboard;
pub mod crypto;

View File

@@ -111,6 +111,9 @@ export declare namespace ipc {
send(message: string): number
}
}
export declare namespace autostart {
export function setAutostart(autostart: boolean, params: Array<string>): Promise<void>
}
export declare namespace autofill {
export function runCommand(value: string): Promise<string>
export const enum UserVerification {

View File

@@ -477,6 +477,16 @@ pub mod ipc {
}
}
#[napi]
pub mod autostart {
#[napi]
pub async fn set_autostart(autostart: bool, params: Vec<String>) -> napi::Result<()> {
desktop_core::autostart::set_autostart(autostart, params)
.await
.map_err(|e| napi::Error::from_reason(format!("Error setting autostart - {e} - {e:?}")))
}
}
#[napi]
pub mod autofill {
use desktop_core::ipc::server::{Message, MessageType};

View File

@@ -351,12 +351,6 @@
"other": {
"message": "Other"
},
"generatePassword": {
"message": "Generate password"
},
"generatePassphrase": {
"message": "Generate passphrase"
},
"type": {
"message": "Type"
},
@@ -2633,6 +2627,24 @@
"usernameGenerator": {
"message": "Username generator"
},
"generatePassword": {
"message": "Generate password"
},
"generatePassphrase": {
"message": "Generate passphrase"
},
"passwordGenerated": {
"message": "Password generated"
},
"passphraseGenerated": {
"message": "Passphrase generated"
},
"usernameGenerated": {
"message": "Username generated"
},
"emailGenerated": {
"message": "Email generated"
},
"spinboxBoundariesHint": {
"message": "Value must be between $MIN$ and $MAX$.",
"description": "Explains spin box minimum and maximum values to the user",
@@ -2686,6 +2698,15 @@
"useThisEmail": {
"message": "Use this email"
},
"useThisPassword": {
"message": "Use this password"
},
"useThisPassphrase": {
"message": "Use this passphrase"
},
"useThisUsername": {
"message": "Use this username"
},
"random": {
"message": "Random"
},
@@ -3051,12 +3072,6 @@
"weakAndBreachedMasterPasswordDesc": {
"message": "Weak password identified and found in a data breach. Use a strong and unique password to protect your account. Are you sure you want to use this password?"
},
"useThisPassword": {
"message": "Use this password"
},
"useThisUsername": {
"message": "Use this username"
},
"checkForBreaches": {
"message": "Check known data breaches for this password"
},

View File

@@ -6,8 +6,11 @@ import * as path from "path";
import { app, ipcMain } from "electron";
import { firstValueFrom } from "rxjs";
import { autostart } from "@bitwarden/desktop-napi";
import { Main } from "../main";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
import { isFlatpak } from "../utils";
import { MenuUpdateRequest } from "./menu/menu.updater";
@@ -122,20 +125,24 @@ export class MessagingMain {
private addOpenAtLogin() {
if (process.platform === "linux") {
const data = `[Desktop Entry]
Type=Application
Version=${app.getVersion()}
Name=Bitwarden
Comment=Bitwarden startup script
Exec=${app.getPath("exe")}
StartupNotify=false
Terminal=false`;
if (isFlatpak()) {
autostart.setAutostart(true, []).catch((e) => {});
} else {
const data = `[Desktop Entry]
Type=Application
Version=${app.getVersion()}
Name=Bitwarden
Comment=Bitwarden startup script
Exec=${app.getPath("exe")}
StartupNotify=false
Terminal=false`;
const dir = path.dirname(this.linuxStartupFile());
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
const dir = path.dirname(this.linuxStartupFile());
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
fs.writeFileSync(this.linuxStartupFile(), data);
}
fs.writeFileSync(this.linuxStartupFile(), data);
} else {
app.setLoginItemSettings({ openAtLogin: true });
}
@@ -143,8 +150,12 @@ Terminal=false`;
private removeOpenAtLogin() {
if (process.platform === "linux") {
if (fs.existsSync(this.linuxStartupFile())) {
fs.unlinkSync(this.linuxStartupFile());
if (isFlatpak()) {
autostart.setAutostart(false, []).catch((e) => {});
} else {
if (fs.existsSync(this.linuxStartupFile())) {
fs.unlinkSync(this.linuxStartupFile());
}
}
} else {
app.setLoginItemSettings({ openAtLogin: false });

View File

@@ -13,7 +13,6 @@ import {
IconButtonModule,
DialogService,
} from "@bitwarden/components";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
export interface ApproveSshRequestParams {
cipherName: string;
@@ -30,7 +29,6 @@ export interface ApproveSshRequestParams {
DialogModule,
CommonModule,
JslibModule,
CipherFormGeneratorComponent,
ButtonModule,
IconButtonModule,
ReactiveFormsModule,

View File

@@ -188,13 +188,10 @@ export class DuckDuckGoMessageHandlerService {
}
try {
let decryptedResult = await this.encryptService.decryptString(
const decryptedResult = await this.decryptDuckDuckGoEncString(
message.encryptedCommand as EncString,
this.duckduckgoSharedSecret,
);
decryptedResult = this.trimNullCharsFromMessage(decryptedResult);
return JSON.parse(decryptedResult);
} catch {
this.sendResponse({
@@ -237,7 +234,46 @@ export class DuckDuckGoMessageHandlerService {
ipc.platform.nativeMessaging.sendReply(response);
}
// Trim all null bytes padded at the end of messages. This happens with C encryption libraries.
/*
* Bitwarden type 2 (AES256-CBC-HMAC256) uses PKCS7 padding.
* DuckDuckGo does not use PKCS7 padding; and instead fills the last CBC block with null bytes.
* ref: https://github.com/duckduckgo/apple-browsers/blob/04d678b447869c3a640714718a466b36407db8b6/macOS/DuckDuckGo/PasswordManager/Bitwarden/Services/BWEncryption.m#L141
*
* This is incompatible which means the default encryptService cannot be used to decrypt the message,
* a custom EncString decrypt operation is needed.
*
* This function also trims null characters that are a result of the null-padding from the end of the message.
*/
private async decryptDuckDuckGoEncString(
encString: EncString,
key: SymmetricCryptoKey,
): Promise<string> {
const fastParams = this.cryptoFunctionService.aesDecryptFastParameters(
encString.data,
encString.iv,
encString.mac,
key,
);
const computedMac = await this.cryptoFunctionService.hmacFast(
fastParams.macData,
fastParams.macKey,
"sha256",
);
const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac);
if (!macsEqual) {
return null;
}
const decryptedPaddedString = await this.cryptoFunctionService.aesDecryptFast({
mode: "cbc",
parameters: fastParams,
});
return this.trimNullCharsFromMessage(decryptedPaddedString);
}
// DuckDuckGo does not use PKCS7 padding, but instead leaves the values as null,
// so null characters need to be trimmed from the end of the message for the last
// CBC-block.
private trimNullCharsFromMessage(message: string): string {
const charNull = 0;
const charRightCurlyBrace = 125;

View File

@@ -9,6 +9,7 @@ import {
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from "@angular/core";
import { ViewComponent as BaseViewComponent } from "@bitwarden/angular/vault/components/view.component";
@@ -130,7 +131,7 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
}
async ngOnChanges() {
async ngOnChanges(changes: SimpleChanges) {
if (this.cipher?.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [this.cipherId as CipherId],
@@ -138,6 +139,12 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro
return;
}
this.passwordReprompted = this.masterPasswordAlreadyPrompted;
if (changes["cipherId"]) {
if (changes["cipherId"].currentValue !== changes["cipherId"].previousValue) {
this.showPrivateKey = false;
}
}
}
viewHistory() {

View File

@@ -37,6 +37,31 @@ export function getNestedCollectionTree(
return nodes;
}
export function getNestedCollectionTree_vNext(
collections: (CollectionView | CollectionAdminView)[],
): TreeNode<CollectionView | CollectionAdminView>[] {
if (!collections) {
return [];
}
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
// modifies the names of collections.
// These changes risk affecting collections store in StateService.
const clonedCollections = collections
.sort((a, b) => a.name.localeCompare(b.name))
.map(cloneCollection);
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
clonedCollections.forEach((collection) => {
const parts =
collection.name != null
? collection.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter)
: [];
ServiceUtils.nestedTraverse_vNext(nodes, 0, parts, collection, null, NestingDelimiter);
});
return nodes;
}
export function getFlatCollectionTree(
nodes: TreeNode<CollectionAdminView>[],
): CollectionAdminView[];

View File

@@ -125,7 +125,11 @@ import {
BulkCollectionsDialogResult,
} from "./bulk-collections-dialog";
import { CollectionAccessRestrictedComponent } from "./collection-access-restricted.component";
import { getNestedCollectionTree, getFlatCollectionTree } from "./utils";
import {
getNestedCollectionTree,
getFlatCollectionTree,
getNestedCollectionTree_vNext,
} from "./utils";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
@@ -420,9 +424,16 @@ export class VaultComponent implements OnInit, OnDestroy {
}),
);
const nestedCollections$ = allCollections$.pipe(
map((collections) => getNestedCollectionTree(collections)),
shareReplay({ refCount: true, bufferSize: 1 }),
const nestedCollections$ = combineLatest([
this.allCollectionsWithoutUnassigned$,
this.configService.getFeatureFlag$(FeatureFlag.OptimizeNestedTraverseTypescript),
]).pipe(
map(
([collections, shouldOptimize]) =>
(shouldOptimize
? getNestedCollectionTree_vNext(collections)
: getNestedCollectionTree(collections)) as TreeNode<CollectionAdminView>[],
),
);
const collections$ = combineLatest([

View File

@@ -110,8 +110,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
protected rowHeight = 69;
protected rowHeightClass = `tw-h-[69px]`;
private organizationUsersCount = 0;
get occupiedSeatCount(): number {
return this.dataSource.activeUserCount;
return this.organizationUsersCount;
}
constructor(
@@ -218,6 +220,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
);
this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone;
this.organizationUsersCount = billingMetadata.organizationOccupiedSeats;
await this.load();

View File

@@ -7,7 +7,7 @@ import { BehaviorSubject, map } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Generators } from "@bitwarden/generator-core";
import { BuiltIn, Profile } from "@bitwarden/generator-core";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -26,14 +26,22 @@ export class PasswordGeneratorPolicy extends BasePolicy {
export class PasswordGeneratorPolicyComponent extends BasePolicyComponent {
// these properties forward the application default settings to the UI
// for HTML attribute bindings
protected readonly minLengthMin = Generators.password.settings.constraints.length.min;
protected readonly minLengthMax = Generators.password.settings.constraints.length.max;
protected readonly minNumbersMin = Generators.password.settings.constraints.minNumber.min;
protected readonly minNumbersMax = Generators.password.settings.constraints.minNumber.max;
protected readonly minSpecialMin = Generators.password.settings.constraints.minSpecial.min;
protected readonly minSpecialMax = Generators.password.settings.constraints.minSpecial.max;
protected readonly minNumberWordsMin = Generators.passphrase.settings.constraints.numWords.min;
protected readonly minNumberWordsMax = Generators.passphrase.settings.constraints.numWords.max;
protected readonly minLengthMin =
BuiltIn.password.profiles[Profile.account].constraints.default.length.min;
protected readonly minLengthMax =
BuiltIn.password.profiles[Profile.account].constraints.default.length.max;
protected readonly minNumbersMin =
BuiltIn.password.profiles[Profile.account].constraints.default.minNumber.min;
protected readonly minNumbersMax =
BuiltIn.password.profiles[Profile.account].constraints.default.minNumber.max;
protected readonly minSpecialMin =
BuiltIn.password.profiles[Profile.account].constraints.default.minSpecial.min;
protected readonly minSpecialMax =
BuiltIn.password.profiles[Profile.account].constraints.default.minSpecial.max;
protected readonly minNumberWordsMin =
BuiltIn.passphrase.profiles[Profile.account].constraints.default.numWords.min;
protected readonly minNumberWordsMax =
BuiltIn.passphrase.profiles[Profile.account].constraints.default.numWords.max;
data = this.formBuilder.group({
overridePasswordType: [null],

View File

@@ -9,12 +9,16 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
// eslint-disable-next-line no-restricted-imports
import { ExposedPasswordsReportComponent } from "../../../dirt/reports/pages/organizations/exposed-passwords-report.component";
// eslint-disable-next-line no-restricted-imports
import { InactiveTwoFactorReportComponent } from "../../../dirt/reports/pages/organizations/inactive-two-factor-report.component";
// eslint-disable-next-line no-restricted-imports
import { ReusedPasswordsReportComponent } from "../../../dirt/reports/pages/organizations/reused-passwords-report.component";
// eslint-disable-next-line no-restricted-imports
import { UnsecuredWebsitesReportComponent } from "../../../dirt/reports/pages/organizations/unsecured-websites-report.component";
// eslint-disable-next-line no-restricted-imports
import { WeakPasswordsReportComponent } from "../../../dirt/reports/pages/organizations/weak-passwords-report.component";
/* eslint no-restricted-imports: "error" */
import { isPaidOrgGuard } from "../guards/is-paid-org.guard";
import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
import { organizationRedirectGuard } from "../guards/org-redirect.guard";

View File

@@ -8,16 +8,27 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
import { PurgeVaultComponent } from "../../../vault/settings/purge-vault.component";
import { ChangeEmailComponent } from "./change-email.component";
import { DangerZoneComponent } from "./danger-zone.component";
import { DeauthorizeSessionsComponent } from "./deauthorize-sessions.component";
import { DeleteAccountDialogComponent } from "./delete-account-dialog.component";
import { ProfileComponent } from "./profile.component";
import { SetAccountVerifyDevicesDialogComponent } from "./set-account-verify-devices-dialog.component";
@Component({
selector: "app-account",
templateUrl: "account.component.html",
standalone: false,
standalone: true,
imports: [
SharedModule,
HeaderModule,
ProfileComponent,
ChangeEmailComponent,
DangerZoneComponent,
],
})
export class AccountComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();

View File

@@ -24,6 +24,10 @@ import {
ToastService,
} from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { SelectableAvatarComponent } from "./selectable-avatar.component";
type ChangeAvatarDialogData = {
profile: ProfileResponse;
};
@@ -31,7 +35,8 @@ type ChangeAvatarDialogData = {
@Component({
templateUrl: "change-avatar-dialog.component.html",
encapsulation: ViewEncapsulation.None,
standalone: false,
standalone: true,
imports: [SharedModule, SelectableAvatarComponent],
})
export class ChangeAvatarDialogComponent implements OnInit, OnDestroy {
profile: ProfileResponse;

View File

@@ -33,8 +33,7 @@ describe("ChangeEmailComponent", () => {
accountService = mockAccountServiceWith("UserId" as UserId);
await TestBed.configureTestingModule({
declarations: [ChangeEmailComponent],
imports: [ReactiveFormsModule, SharedModule],
imports: [ReactiveFormsModule, SharedModule, ChangeEmailComponent],
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: ApiService, useValue: apiService },

View File

@@ -14,10 +14,13 @@ import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { SharedModule } from "../../../shared";
@Component({
selector: "app-change-email",
templateUrl: "change-email.component.html",
standalone: false,
standalone: true,
imports: [SharedModule],
})
export class ChangeEmailComponent implements OnInit {
tokenSent = false;

View File

@@ -3,8 +3,8 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
/**
* Component for the Danger Zone section of the Account/Organization Settings page.
@@ -13,6 +13,6 @@ import { TypographyModule } from "@bitwarden/components";
selector: "app-danger-zone",
templateUrl: "danger-zone.component.html",
standalone: true,
imports: [TypographyModule, JslibModule, CommonModule],
imports: [CommonModule, TypographyModule, I18nPipe],
})
export class DangerZoneComponent {}

View File

@@ -1,6 +1,7 @@
import { Component } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { Verification } from "@bitwarden/common/auth/types/verification";
@@ -9,10 +10,12 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
@Component({
selector: "app-deauthorize-sessions",
templateUrl: "deauthorize-sessions.component.html",
standalone: false,
standalone: true,
imports: [SharedModule, UserVerificationFormInputComponent],
})
export class DeauthorizeSessionsComponent {
deauthForm = this.formBuilder.group({

View File

@@ -3,15 +3,19 @@
import { Component } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
@Component({
templateUrl: "delete-account-dialog.component.html",
standalone: false,
standalone: true,
imports: [SharedModule, UserVerificationFormInputComponent],
})
export class DeleteAccountDialogComponent {
deleteForm = this.formBuilder.group({

View File

@@ -14,12 +14,17 @@ import { ProfileResponse } from "@bitwarden/common/models/response/profile.respo
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { DynamicAvatarComponent } from "../../../components/dynamic-avatar.component";
import { SharedModule } from "../../../shared";
import { AccountFingerprintComponent } from "../../../shared/components/account-fingerprint/account-fingerprint.component";
import { ChangeAvatarDialogComponent } from "./change-avatar-dialog.component";
@Component({
selector: "app-profile",
templateUrl: "profile.component.html",
standalone: false,
standalone: true,
imports: [SharedModule, DynamicAvatarComponent, AccountFingerprintComponent],
})
export class ProfileComponent implements OnInit, OnDestroy {
loading = true;

View File

@@ -1,7 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { NgClass } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { AvatarModule } from "@bitwarden/components";
@Component({
selector: "selectable-avatar",
template: `<span
@@ -24,7 +27,8 @@ import { Component, EventEmitter, Input, Output } from "@angular/core";
>
</bit-avatar>
</span>`,
standalone: false,
standalone: true,
imports: [NgClass, AvatarModule],
})
export class SelectableAvatarComponent {
@Input() id: string;

View File

@@ -3,12 +3,15 @@
import { Component, Inject } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { ApiKeyResponse } from "@bitwarden/common/auth/models/response/api-key.response";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { DIALOG_DATA, DialogConfig, DialogService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
export type ApiKeyDialogData = {
keyType: string;
isRotation?: boolean;
@@ -21,9 +24,9 @@ export type ApiKeyDialogData = {
apiKeyDescription: string;
};
@Component({
selector: "app-api-key",
templateUrl: "api-key.component.html",
standalone: false,
standalone: true,
imports: [SharedModule, UserVerificationFormInputComponent],
})
export class ApiKeyComponent {
clientId: string;

View File

@@ -1,6 +1,4 @@
<div class="tabbed-header">
<h1>{{ "changeMasterPassword" | i18n }}</h1>
</div>
<h1 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "changeMasterPassword" | i18n }}</h1>
<div class="tw-max-w-lg tw-mb-12">
<bit-callout type="warning">{{ "loggedOutWarning" | i18n }}</bit-callout>

View File

@@ -8,12 +8,15 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { DialogService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { ApiKeyComponent } from "./api-key.component";
import { ChangeKdfModule } from "./change-kdf/change-kdf.module";
@Component({
selector: "app-security-keys",
templateUrl: "security-keys.component.html",
standalone: false,
standalone: true,
imports: [SharedModule, ChangeKdfModule],
})
export class SecurityKeysComponent implements OnInit {
showChangeKdf = true;

View File

@@ -4,10 +4,13 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { HeaderModule } from "../../../layouts/header/header.module";
import { SharedModule } from "../../../shared";
@Component({
selector: "app-security",
templateUrl: "security.component.html",
standalone: false,
standalone: true,
imports: [SharedModule, HeaderModule],
})
export class SecurityComponent implements OnInit {
showChangePassword = true;

View File

@@ -607,6 +607,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
return (
plan.PasswordManager.additionalStoragePricePerGb *
// TODO: Eslint upgrade. Please resolve this since the null check does nothing
// eslint-disable-next-line no-constant-binary-expression
Math.abs(this.sub?.maxStorageGb ? this.sub?.maxStorageGb - 1 : 0 || 0)
);
}

View File

@@ -148,6 +148,8 @@ export class OrganizationPaymentMethodComponent implements OnDestroy {
paymentSource,
);
}
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
// If the flag `launchPaymentModalAutomatically` is set to true,
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.

View File

@@ -143,6 +143,8 @@ export class PaymentMethodComponent implements OnInit, OnDestroy {
[this.billing, this.sub] = await Promise.all([billingPromise, subPromise]);
}
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
this.isUnpaid = this.subscription?.status === "unpaid" ?? false;
this.loading = false;
// If the flag `launchPaymentModalAutomatically` is set to true,

View File

@@ -74,6 +74,9 @@ export class RouterService {
const titleId: string = child?.snapshot?.data?.titleId;
const rawTitle: string = child?.snapshot?.data?.title;
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
const updateUrl = !child?.snapshot?.data?.doNotSaveUrl ?? true;
if (titleId != null || rawTitle != null) {

View File

@@ -5,6 +5,7 @@ import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
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 { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
@@ -130,6 +131,12 @@ export default {
return new I18nMockService(translations);
},
},
{
provide: PolicyService,
useValue: {
policyAppliesToUser$: () => of(false),
},
},
],
}),
applicationConfig({

View File

@@ -5,6 +5,7 @@ import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
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 { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
@@ -126,6 +127,12 @@ export default {
});
},
},
{
provide: PolicyService,
useValue: {
policyAppliesToUser$: () => of(false),
},
},
],
}),
applicationConfig({

View File

@@ -7,6 +7,7 @@ import { Observable, firstValueFrom, of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
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 { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
@@ -27,6 +28,7 @@ describe("ProductSwitcherService", () => {
let accountService: FakeAccountService;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let activeRouteParams = convertToParamMap({ organizationId: "1234" });
let singleOrgPolicyEnabled = false;
const getLastSync = jest.fn().mockResolvedValue(new Date("2024-05-14"));
const userId = Utils.newGuid() as UserId;
@@ -77,6 +79,12 @@ describe("ProductSwitcherService", () => {
provide: SyncService,
useValue: { getLastSync },
},
{
provide: PolicyService,
useValue: {
policyAppliesToUser$: () => of(singleOrgPolicyEnabled),
},
},
],
});
});
@@ -184,6 +192,14 @@ describe("ProductSwitcherService", () => {
expect(products.bento.find((p) => p.name === "Admin Console")).toBeDefined();
expect(products.other.find((p) => p.name === "Organizations")).toBeUndefined();
});
it("does not include Organizations when the user's single org policy is enabled", async () => {
singleOrgPolicyEnabled = true;
initiateService();
const products = await firstValueFrom(service.products$);
expect(products.other.find((p) => p.name === "Organizations")).not.toBeDefined();
});
});
describe("Provider Portal", () => {

View File

@@ -6,6 +6,7 @@ import {
combineLatest,
concatMap,
filter,
firstValueFrom,
map,
Observable,
ReplaySubject,
@@ -18,10 +19,12 @@ import {
canAccessOrgAdmin,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { ProviderType } from "@bitwarden/common/admin-console/enums";
import { PolicyType, ProviderType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
@@ -104,6 +107,7 @@ export class ProductSwitcherService {
private syncService: SyncService,
private accountService: AccountService,
private platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
) {
this.pollUntilSynced();
}
@@ -235,7 +239,15 @@ export class ProductSwitcherService {
if (acOrg) {
bento.push(products.ac);
} else {
other.push(products.orgs);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
const userHasSingleOrgPolicy = await firstValueFrom(
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, activeUserId),
);
if (!userHasSingleOrgPolicy) {
other.push(products.orgs);
}
}
if (providers.length > 0) {

View File

@@ -15,23 +15,12 @@ import { AcceptFamilySponsorshipComponent } from "../admin-console/organizations
import { RecoverDeleteComponent } from "../auth/recover-delete.component";
import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component";
import { SetPasswordComponent } from "../auth/set-password.component";
import { AccountComponent } from "../auth/settings/account/account.component";
import { ChangeAvatarDialogComponent } from "../auth/settings/account/change-avatar-dialog.component";
import { ChangeEmailComponent } from "../auth/settings/account/change-email.component";
import { DangerZoneComponent } from "../auth/settings/account/danger-zone.component";
import { DeauthorizeSessionsComponent } from "../auth/settings/account/deauthorize-sessions.component";
import { DeleteAccountDialogComponent } from "../auth/settings/account/delete-account-dialog.component";
import { ProfileComponent } from "../auth/settings/account/profile.component";
import { SelectableAvatarComponent } from "../auth/settings/account/selectable-avatar.component";
import { EmergencyAccessConfirmComponent } from "../auth/settings/emergency-access/confirm/emergency-access-confirm.component";
import { EmergencyAccessAddEditComponent } from "../auth/settings/emergency-access/emergency-access-add-edit.component";
import { EmergencyAccessComponent } from "../auth/settings/emergency-access/emergency-access.component";
import { EmergencyAccessTakeoverComponent } from "../auth/settings/emergency-access/takeover/emergency-access-takeover.component";
import { EmergencyAccessViewComponent } from "../auth/settings/emergency-access/view/emergency-access-view.component";
import { ApiKeyComponent } from "../auth/settings/security/api-key.component";
import { ChangeKdfModule } from "../auth/settings/security/change-kdf/change-kdf.module";
import { SecurityKeysComponent } from "../auth/settings/security/security-keys.component";
import { SecurityComponent } from "../auth/settings/security/security.component";
import { UserVerificationModule } from "../auth/shared/components/user-verification";
import { UpdatePasswordComponent } from "../auth/update-password.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
@@ -39,7 +28,6 @@ import { VerifyEmailTokenComponent } from "../auth/verify-email-token.component"
import { VerifyRecoverDeleteComponent } from "../auth/verify-recover-delete.component";
import { SponsoredFamiliesComponent } from "../billing/settings/sponsored-families.component";
import { SponsoringOrgRowComponent } from "../billing/settings/sponsoring-org-row.component";
import { DynamicAvatarComponent } from "../components/dynamic-avatar.component";
// eslint-disable-next-line no-restricted-imports -- Temporarily disabled until DIRT refactors these out of this module
import { ExposedPasswordsReportComponent as OrgExposedPasswordsReportComponent } from "../dirt/reports/pages/organizations/exposed-passwords-report.component";
// eslint-disable-next-line no-restricted-imports -- Temporarily disabled until DIRT refactors these out of this module
@@ -68,8 +56,6 @@ import { SharedModule } from "./shared.module";
imports: [
SharedModule,
UserVerificationModule,
ChangeKdfModule,
DynamicAvatarComponent,
AccountFingerprintComponent,
OrganizationBadgeModule,
PipesModule,
@@ -85,11 +71,6 @@ import { SharedModule } from "./shared.module";
],
declarations: [
AcceptFamilySponsorshipComponent,
AccountComponent,
ApiKeyComponent,
ChangeEmailComponent,
DeauthorizeSessionsComponent,
DeleteAccountDialogComponent,
EmergencyAccessAddEditComponent,
EmergencyAccessComponent,
EmergencyAccessConfirmComponent,
@@ -104,15 +85,10 @@ import { SharedModule } from "./shared.module";
OrgUserConfirmComponent,
OrgWeakPasswordsReportComponent,
PremiumBadgeComponent,
ProfileComponent,
ChangeAvatarDialogComponent,
PurgeVaultComponent,
RecoverDeleteComponent,
RecoverTwoFactorComponent,
RemovePasswordComponent,
SecurityComponent,
SecurityKeysComponent,
SelectableAvatarComponent,
SetPasswordComponent,
SponsoredFamiliesComponent,
FreeBitwardenFamiliesComponent,
@@ -125,12 +101,6 @@ import { SharedModule } from "./shared.module";
exports: [
UserVerificationModule,
PremiumBadgeComponent,
AccountComponent,
ApiKeyComponent,
ChangeEmailComponent,
DeauthorizeSessionsComponent,
DeleteAccountDialogComponent,
DynamicAvatarComponent,
EmergencyAccessAddEditComponent,
EmergencyAccessComponent,
EmergencyAccessConfirmComponent,
@@ -146,15 +116,10 @@ import { SharedModule } from "./shared.module";
OrgUserConfirmComponent,
OrgWeakPasswordsReportComponent,
PremiumBadgeComponent,
ProfileComponent,
ChangeAvatarDialogComponent,
PurgeVaultComponent,
RecoverDeleteComponent,
RecoverTwoFactorComponent,
RemovePasswordComponent,
SecurityComponent,
SecurityKeysComponent,
SelectableAvatarComponent,
SetPasswordComponent,
SponsoredFamiliesComponent,
FreeBitwardenFamiliesComponent,

View File

@@ -49,7 +49,9 @@ import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -82,6 +84,7 @@ import {
import {
getNestedCollectionTree,
getFlatCollectionTree,
getNestedCollectionTree_vNext,
} from "../../admin-console/organizations/collections";
import {
CollectionDialogAction,
@@ -270,6 +273,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private trialFlowService: TrialFlowService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private billingNotificationService: BillingNotificationService,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -326,8 +330,15 @@ export class VaultComponent implements OnInit, OnDestroy {
const filter$ = this.routedVaultFilterService.filter$;
const allCollections$ = this.collectionService.decryptedCollections$;
const nestedCollections$ = allCollections$.pipe(
map((collections) => getNestedCollectionTree(collections)),
const nestedCollections$ = combineLatest([
allCollections$,
this.configService.getFeatureFlag$(FeatureFlag.OptimizeNestedTraverseTypescript),
]).pipe(
map(([collections, shouldOptimize]) =>
shouldOptimize
? getNestedCollectionTree_vNext(collections)
: getNestedCollectionTree(collections),
),
);
this.searchText$

View File

@@ -547,12 +547,6 @@
"message": "Toggle collapse",
"description": "Toggling an expand/collapse state."
},
"generatePassword": {
"message": "Generate password"
},
"generatePassphrase": {
"message": "Generate passphrase"
},
"checkPassword": {
"message": "Check if password has been exposed."
},
@@ -6825,6 +6819,24 @@
"generateEmail": {
"message": "Generate email"
},
"generatePassword": {
"message": "Generate password"
},
"generatePassphrase": {
"message": "Generate passphrase"
},
"passwordGenerated": {
"message": "Password generated"
},
"passphraseGenerated": {
"message": "Passphrase generated"
},
"usernameGenerated": {
"message": "Username generated"
},
"emailGenerated": {
"message": "Email generated"
},
"spinboxBoundariesHint": {
"message": "Value must be between $MIN$ and $MAX$.",
"description": "Explains spin box minimum and maximum values to the user",
@@ -6888,6 +6900,9 @@
"useThisPassword": {
"message": "Use this password"
},
"useThisPassphrase": {
"message": "Use this passphrase"
},
"useThisUsername": {
"message": "Use this username"
},

View File

@@ -27,6 +27,9 @@ export const mockCiphers: any[] = [
createLoginUriView("accounts.google.com"),
createLoginUriView("https://www.google.com"),
createLoginUriView("https://www.google.com/login"),
createLoginUriView("www.invalid@uri@.com"),
createLoginUriView("www.invaliduri!.com"),
createLoginUriView("this_is-not|a-valid-uri123@+"),
],
},
edit: false,

View File

@@ -50,7 +50,7 @@ describe("RiskInsightsReportService", () => {
let testCase = testCaseResults[0];
expect(testCase).toBeTruthy();
expect(testCase.cipherMembers).toHaveLength(2);
expect(testCase.trimmedUris).toHaveLength(2);
expect(testCase.trimmedUris).toHaveLength(5);
expect(testCase.weakPasswordDetail).toBeTruthy();
expect(testCase.exposedPasswordDetail).toBeTruthy();
expect(testCase.reusedPasswordCount).toEqual(2);
@@ -69,12 +69,16 @@ describe("RiskInsightsReportService", () => {
it("should generate the raw data + uri report correctly", async () => {
const result = await firstValueFrom(service.generateRawDataUriReport$("orgId"));
expect(result).toHaveLength(8);
expect(result).toHaveLength(11);
// Two ciphers that have google.com as their uri. There should be 2 results
const googleResults = result.filter((x) => x.trimmedUri === "google.com");
expect(googleResults).toHaveLength(2);
// There is an invalid uri and it should not be trimmed
const invalidUriResults = result.filter((x) => x.trimmedUri === "this_is-not|a-valid-uri123@+");
expect(invalidUriResults).toHaveLength(1);
// Verify the details for one of the googles matches the password health info
// expected
const firstGoogle = googleResults.filter(
@@ -88,7 +92,7 @@ describe("RiskInsightsReportService", () => {
it("should generate applications health report data correctly", async () => {
const result = await firstValueFrom(service.generateApplicationsReport$("orgId"));
expect(result).toHaveLength(5);
expect(result).toHaveLength(8);
// Two ciphers have google.com associated with them. The first cipher
// has 2 members and the second has 4. However, the 2 members in the first
@@ -132,7 +136,7 @@ describe("RiskInsightsReportService", () => {
expect(reportSummary.totalMemberCount).toEqual(7);
expect(reportSummary.totalAtRiskMemberCount).toEqual(6);
expect(reportSummary.totalApplicationCount).toEqual(5);
expect(reportSummary.totalAtRiskApplicationCount).toEqual(4);
expect(reportSummary.totalApplicationCount).toEqual(8);
expect(reportSummary.totalAtRiskApplicationCount).toEqual(7);
});
});

View File

@@ -433,7 +433,7 @@ export class RiskInsightsReportService {
const cipherUris: string[] = [];
const uris = cipher.login?.uris ?? [];
uris.map((u: { uri: string }) => {
const uri = Utils.getDomain(u.uri);
const uri = Utils.getDomain(u.uri) ?? u.uri;
if (!cipherUris.includes(uri)) {
cipherUris.push(uri);
}

View File

@@ -1,5 +1,5 @@
// @ts-check
import { fixupPluginRules } from "@eslint/compat";
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import angular from "angular-eslint";
@@ -31,8 +31,8 @@ export default tseslint.config(
reportUnusedDisableDirectives: "error",
},
plugins: {
rxjs: rxjs,
"rxjs-angular": angularRxjs,
rxjs: fixupPluginRules(rxjs),
"rxjs-angular": fixupPluginRules(angularRxjs),
"@bitwarden/platform": platformPlugins,
},
languageOptions: {

View File

@@ -7,6 +7,9 @@ import { MasterPasswordPolicyOptions } from "../../models/domain/master-password
import { Policy } from "../../models/domain/policy";
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
/**
* The primary service for retrieving and evaluating policies from sync data.
*/
export abstract class PolicyService {
/**
* All policies for the provided user from sync data.
@@ -24,7 +27,7 @@ export abstract class PolicyService {
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
/**
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
* @returns true if any policy of the specified type applies to the specified user, otherwise false.
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
* {@link Policy} objects and then filter by Policy.data.
@@ -35,8 +38,12 @@ export abstract class PolicyService {
/**
* Combines all Master Password policies that apply to the user.
* If you are evaluating Master Password policies before the first sync has completed,
* you must supply your own `policies` value.
* @param userId The user against whom the policy needs to be enforced.
* @param policies The policies to be evaluated; if null or undefined, it will default to using policies from sync data.
* @returns a set of options which represent the minimum Master Password settings that the user must
* comply with in order to comply with **all** Master Password policies.
* comply with in order to comply with **all** applicable Master Password policies.
*/
abstract masterPasswordPolicyOptions$: (
userId: UserId,
@@ -62,7 +69,17 @@ export abstract class PolicyService {
) => [ResetPasswordPolicyOptions, boolean];
}
/**
* An "internal" extension of the `PolicyService` which allows the update of policy data in the local sync data.
* This does not update any policies on the server.
*/
export abstract class InternalPolicyService extends PolicyService {
/**
* Upsert a policy in the local sync data. This does not update any policies on the server.
*/
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
/**
* Replace a policy in the local sync data. This does not update any policies on the server.
*/
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
}

View File

@@ -11,6 +11,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
invoiceCreatedDate: Date | null;
subPeriodEndDate: Date | null;
isSubscriptionCanceled: boolean;
organizationOccupiedSeats: number;
constructor(response: any) {
super(response);
@@ -25,6 +26,7 @@ export class OrganizationBillingMetadataResponse extends BaseResponse {
this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate"));
this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate"));
this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled");
this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats");
}
private parseDate(dateString: any): Date | null {

View File

@@ -13,6 +13,7 @@ export enum FeatureFlag {
/* Admin Console Team */
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions",
OptimizeNestedTraverseTypescript = "pm-21695-optimize-nested-traverse-typescript",
/* Auth */
PM16117_ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor",
@@ -22,7 +23,6 @@ export enum FeatureFlag {
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
IdpAutoSubmitLogin = "idp-auto-submit-login",
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
@@ -60,6 +60,7 @@ export enum FeatureFlag {
CipherKeyEncryption = "cipher-key-encryption",
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
EndUserNotifications = "pm-10609-end-user-notifications",
RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
@@ -82,12 +83,12 @@ export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.LimitItemDeletion]: FALSE,
[FeatureFlag.SeparateCustomRolePermissions]: FALSE,
[FeatureFlag.OptimizeNestedTraverseTypescript]: FALSE,
/* Autofill */
[FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
@@ -109,6 +110,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
[FeatureFlag.EndUserNotifications]: FALSE,
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
[FeatureFlag.RemoveCardItemTypePolicy]: FALSE,
/* Auth */
[FeatureFlag.PM16117_ChangeExistingPasswordRefactor]: FALSE,

View File

@@ -89,6 +89,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
) {
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
map((options) => {
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
return options?.trustedDeviceOption != null ?? false;
}),
);
@@ -97,6 +99,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
supportsDeviceTrustByUserId$(userId: UserId): Observable<boolean> {
return this.userDecryptionOptionsService.userDecryptionOptionsById$(userId).pipe(
map((options) => {
// TODO: Eslint upgrade. Please resolve this since the ?? does nothing
// eslint-disable-next-line no-constant-binary-expression
return options?.trustedDeviceOption != null ?? false;
}),
);

View File

@@ -1,9 +1,10 @@
import { Observable } from "rxjs";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { BitwardenClient, Uuid } from "@bitwarden/sdk-internal";
import { UserId } from "../../../types/guid";
import { Rc } from "../../misc/reference-counting/rc";
import { Utils } from "../../misc/utils";
export class UserNotLoggedInError extends Error {
constructor(userId: UserId) {
@@ -11,6 +12,30 @@ export class UserNotLoggedInError extends Error {
}
}
export class InvalidUuid extends Error {
constructor(uuid: string) {
super(`Invalid UUID: ${uuid}`);
}
}
/**
* Converts a string to UUID. Will throw an error if the UUID is non valid.
*/
export function asUuid<T extends Uuid>(uuid: string): T {
if (Utils.isGuid(uuid)) {
return uuid as T;
}
throw new InvalidUuid(uuid);
}
/**
* Converts a UUID to the string representation.
*/
export function uuidToString<T extends Uuid>(uuid: T): string {
return uuid as unknown as string;
}
export abstract class SdkService {
/**
* Retrieve the version of the SDK.
@@ -28,15 +53,18 @@ export abstract class SdkService {
* This client can be used for operations that require a user context, such as retrieving ciphers
* and operations involving crypto. It can also be used for operations that don't require a user context.
*
* - If the user is not logged when the subscription is created, the observable will complete
* immediately with {@link UserNotLoggedInError}.
* - If the user is logged in, the observable will emit the client and complete whithout an error
* when the user logs out.
*
* **WARNING:** Do not use `firstValueFrom(userClient$)`! Any operations on the client must be done within the observable.
* The client will be destroyed when the observable is no longer subscribed to.
* Please let platform know if you need a client that is not destroyed when the observable is no longer subscribed to.
*
* @param userId The user id for which to retrieve the client
*
* @throws {UserNotLoggedInError} If the user is not logged in
*/
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined>;
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient>>;
/**
* This method is used during/after an authentication procedure to set a new client for a specific user.

View File

@@ -132,15 +132,13 @@ describe("DefaultSdkService", () => {
);
keyService.userKey$.calledWith(userId).mockReturnValue(userKey$);
const subject = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
service.userClient$(userId).subscribe(subject);
await new Promise(process.nextTick);
const userClientTracker = new ObservableTracker(service.userClient$(userId), false);
await userClientTracker.pauseUntilReceived(1);
userKey$.next(undefined);
await new Promise(process.nextTick);
await userClientTracker.expectCompletion();
expect(mockClient.free).toHaveBeenCalledTimes(1);
expect(subject.value).toBe(undefined);
});
});

View File

@@ -71,7 +71,7 @@ export class DefaultSdkService implements SdkService {
private userAgent: string | null = null,
) {}
userClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined> {
userClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
return this.sdkClientOverrides.pipe(
takeWhile((clients) => clients[userId] !== UnsetClient, false),
map((clients) => {
@@ -88,6 +88,7 @@ export class DefaultSdkService implements SdkService {
return this.internalClient$(userId);
}),
takeWhile((client) => client !== undefined, false),
throwIfEmpty(() => new UserNotLoggedInError(userId)),
);
}
@@ -112,7 +113,7 @@ export class DefaultSdkService implements SdkService {
* @param userId The user id for which to create the client
* @returns An observable that emits the client for the user
*/
private internalClient$(userId: UserId): Observable<Rc<BitwardenClient> | undefined> {
private internalClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
const cached = this.sdkClientCache.get(userId);
if (cached !== undefined) {
return cached;
@@ -152,7 +153,15 @@ export class DefaultSdkService implements SdkService {
const settings = this.toSettings(env);
const client = await this.sdkClientFactory.createSdkClient(settings);
await this.initializeClient(client, account, kdfParams, privateKey, userKey, orgKeys);
await this.initializeClient(
userId,
client,
account,
kdfParams,
privateKey,
userKey,
orgKeys,
);
return client;
};
@@ -182,6 +191,7 @@ export class DefaultSdkService implements SdkService {
}
private async initializeClient(
userId: UserId,
client: BitwardenClient,
account: AccountInfo,
kdfParams: KdfConfig,
@@ -190,6 +200,7 @@ export class DefaultSdkService implements SdkService {
orgKeys: Record<OrganizationId, EncryptedOrganizationKeyData> | null,
) {
await client.crypto().initialize_user_crypto({
userId,
email: account.email,
method: { decryptedKey: { decrypted_user_key: userKey.keyB64 } },
kdfParams:

View File

@@ -0,0 +1,58 @@
import { mockDeep } from "./mock-deep";
class ToBeMocked {
property = "value";
method() {
return "method";
}
sub() {
return new SubToBeMocked();
}
}
class SubToBeMocked {
subProperty = "subValue";
sub() {
return new SubSubToBeMocked();
}
}
class SubSubToBeMocked {
subSubProperty = "subSubValue";
}
describe("deepMock", () => {
it("can mock properties", () => {
const mock = mockDeep<ToBeMocked>();
mock.property.replaceProperty("mocked value");
expect(mock.property).toBe("mocked value");
});
it("can mock methods", () => {
const mock = mockDeep<ToBeMocked>();
mock.method.mockReturnValue("mocked method");
expect(mock.method()).toBe("mocked method");
});
it("can mock sub-properties", () => {
const mock = mockDeep<ToBeMocked>();
mock.sub.mockDeep().subProperty.replaceProperty("mocked sub value");
expect(mock.sub().subProperty).toBe("mocked sub value");
});
it("can mock sub-sub-properties", () => {
const mock = mockDeep<ToBeMocked>();
mock.sub.mockDeep().sub.mockDeep().subSubProperty.replaceProperty("mocked sub-sub value");
expect(mock.sub().sub().subSubProperty).toBe("mocked sub-sub value");
});
it("returns the same mock object when calling mockDeep multiple times", () => {
const mock = mockDeep<ToBeMocked>();
const subMock1 = mock.sub.mockDeep();
const subMock2 = mock.sub.mockDeep();
expect(subMock1).toBe(subMock2);
});
});

View File

@@ -0,0 +1,271 @@
// This is a modification of the code found in https://github.com/marchaos/jest-mock-extended
// to better support deep mocking of objects.
// MIT License
// Copyright (c) 2019 Marc McIntyre
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { jest } from "@jest/globals";
import { FunctionLike } from "jest-mock";
import { calledWithFn, MatchersOrLiterals } from "jest-mock-extended";
import { PartialDeep } from "type-fest";
type ProxiedProperty = string | number | symbol;
export interface GlobalConfig {
// ignoreProps is required when we don't want to return anything for a mock (for example, when mocking a promise).
ignoreProps?: ProxiedProperty[];
}
const DEFAULT_CONFIG: GlobalConfig = {
ignoreProps: ["then"],
};
let GLOBAL_CONFIG = DEFAULT_CONFIG;
export const JestMockExtended = {
DEFAULT_CONFIG,
configure: (config: GlobalConfig) => {
// Shallow merge so they can override anything they want.
GLOBAL_CONFIG = { ...DEFAULT_CONFIG, ...config };
},
resetConfig: () => {
GLOBAL_CONFIG = DEFAULT_CONFIG;
},
};
export interface CalledWithMock<T extends FunctionLike> extends jest.Mock<T> {
calledWith: (...args: [...MatchersOrLiterals<Parameters<T>>]) => jest.Mock<T>;
}
export interface MockDeepMock<R> {
mockDeep: () => DeepMockProxy<R>;
}
export interface ReplaceProperty<T> {
/**
* mockDeep will by default return a jest.fn() for all properties,
* but this allows you to replace the property with a value.
* @param value The value to replace the property with.
*/
replaceProperty(value: T): void;
}
export type _MockProxy<T> = {
[K in keyof T]: T[K] extends FunctionLike ? T[K] & CalledWithMock<T[K]> : T[K];
};
export type MockProxy<T> = _MockProxy<T> & T;
export type _DeepMockProxy<T> = {
// This supports deep mocks in the else branch
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? T[K] & CalledWithMock<T[K]> & MockDeepMock<R>
: T[K] & ReplaceProperty<T[K]> & _DeepMockProxy<T[K]>;
};
// we intersect with T here instead of on the mapped type above to
// prevent immediate type resolution on a recursive type, this will
// help to improve performance for deeply nested recursive mocking
// at the same time, this intersection preserves private properties
export type DeepMockProxy<T> = _DeepMockProxy<T> & T;
export type _DeepMockProxyWithFuncPropSupport<T> = {
// This supports deep mocks in the else branch
[K in keyof T]: T[K] extends FunctionLike
? CalledWithMock<T[K]> & DeepMockProxy<T[K]>
: DeepMockProxy<T[K]>;
};
export type DeepMockProxyWithFuncPropSupport<T> = _DeepMockProxyWithFuncPropSupport<T> & T;
export interface MockOpts {
deep?: boolean;
fallbackMockImplementation?: (...args: any[]) => any;
}
export const mockClear = (mock: MockProxy<any>) => {
for (const key of Object.keys(mock)) {
if (mock[key] === null || mock[key] === undefined) {
continue;
}
if (mock[key]._isMockObject) {
mockClear(mock[key]);
}
if (mock[key]._isMockFunction) {
mock[key].mockClear();
}
}
// This is a catch for if they pass in a jest.fn()
if (!mock._isMockObject) {
return mock.mockClear();
}
};
export const mockReset = (mock: MockProxy<any>) => {
for (const key of Object.keys(mock)) {
if (mock[key] === null || mock[key] === undefined) {
continue;
}
if (mock[key]._isMockObject) {
mockReset(mock[key]);
}
if (mock[key]._isMockFunction) {
mock[key].mockReset();
}
}
// This is a catch for if they pass in a jest.fn()
// Worst case, we will create a jest.fn() (since this is a proxy)
// below in the get and call mockReset on it
if (!mock._isMockObject) {
return mock.mockReset();
}
};
export function mockDeep<T>(
opts: {
funcPropSupport?: true;
fallbackMockImplementation?: MockOpts["fallbackMockImplementation"];
},
mockImplementation?: PartialDeep<T>,
): DeepMockProxyWithFuncPropSupport<T>;
export function mockDeep<T>(mockImplementation?: PartialDeep<T>): DeepMockProxy<T>;
export function mockDeep(arg1: any, arg2?: any) {
const [opts, mockImplementation] =
typeof arg1 === "object" &&
(typeof arg1.fallbackMockImplementation === "function" || arg1.funcPropSupport === true)
? [arg1, arg2]
: [{}, arg1];
return mock(mockImplementation, {
deep: true,
fallbackMockImplementation: opts.fallbackMockImplementation,
});
}
const overrideMockImp = (obj: PartialDeep<any>, opts?: MockOpts) => {
const proxy = new Proxy<MockProxy<any>>(obj, handler(opts));
for (const name of Object.keys(obj)) {
if (typeof obj[name] === "object" && obj[name] !== null) {
proxy[name] = overrideMockImp(obj[name], opts);
} else {
proxy[name] = obj[name];
}
}
return proxy;
};
const handler = (opts?: MockOpts): ProxyHandler<any> => ({
ownKeys(target: MockProxy<any>) {
return Reflect.ownKeys(target);
},
set: (obj: MockProxy<any>, property: ProxiedProperty, value: any) => {
obj[property] = value;
return true;
},
get: (obj: MockProxy<any>, property: ProxiedProperty) => {
const fn = calledWithFn({ fallbackMockImplementation: opts?.fallbackMockImplementation });
if (!(property in obj)) {
if (GLOBAL_CONFIG.ignoreProps?.includes(property)) {
return undefined;
}
// Jest's internal equality checking does some wierd stuff to check for iterable equality
if (property === Symbol.iterator) {
return obj[property];
}
if (property === "_deepMock") {
return obj[property];
}
// So this calls check here is totally not ideal - jest internally does a
// check to see if this is a spy - which we want to say no to, but blindly returning
// an proxy for calls results in the spy check returning true. This is another reason
// why deep is opt in.
if (opts?.deep && property !== "calls") {
obj[property] = new Proxy<MockProxy<any>>(fn, handler(opts));
obj[property].replaceProperty = <T extends typeof obj, K extends keyof T>(value: T[K]) => {
obj[property] = value;
};
obj[property].mockDeep = () => {
if (obj[property]._deepMock) {
return obj[property]._deepMock;
}
const mock = mockDeep({
fallbackMockImplementation: opts?.fallbackMockImplementation,
});
(obj[property] as CalledWithMock<any>).mockReturnValue(mock);
obj[property]._deepMock = mock;
return mock;
};
obj[property]._isMockObject = true;
} else {
obj[property] = calledWithFn({
fallbackMockImplementation: opts?.fallbackMockImplementation,
});
}
}
// @ts-expect-error Hack by author of jest-mock-extended
if (obj instanceof Date && typeof obj[property] === "function") {
// @ts-expect-error Hack by author of jest-mock-extended
return obj[property].bind(obj);
}
return obj[property];
},
});
const mock = <T, MockedReturn extends MockProxy<T> & T = MockProxy<T> & T>(
mockImplementation: PartialDeep<T> = {} as PartialDeep<T>,
opts?: MockOpts,
): MockedReturn => {
// @ts-expect-error private
mockImplementation!._isMockObject = true;
return overrideMockImp(mockImplementation, opts);
};
export const mockFn = <T extends FunctionLike>(): CalledWithMock<T> & T => {
// @ts-expect-error Hack by author of jest-mock-extended
return calledWithFn();
};
export const stub = <T extends object>(): T => {
return new Proxy<T>({} as T, {
get: (obj, property: ProxiedProperty) => {
if (property in obj) {
// @ts-expect-error Hack by author of jest-mock-extended
return obj[property];
}
return jest.fn();
},
});
};
export default mock;

View File

@@ -0,0 +1,81 @@
import {
BehaviorSubject,
distinctUntilChanged,
map,
Observable,
takeWhile,
throwIfEmpty,
} from "rxjs";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { UserId } from "../../types/guid";
import { SdkService, UserNotLoggedInError } from "../abstractions/sdk/sdk.service";
import { Rc } from "../misc/reference-counting/rc";
import { DeepMockProxy, mockDeep } from "./mock-deep";
export class MockSdkService implements SdkService {
private userClients$ = new BehaviorSubject<{
[userId: UserId]: Rc<BitwardenClient> | undefined;
}>({});
private _client$ = new BehaviorSubject(mockDeep<BitwardenClient>());
client$ = this._client$.asObservable();
version$ = new BehaviorSubject("0.0.1-test").asObservable();
userClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
return this.userClients$.pipe(
takeWhile((clients) => clients[userId] !== undefined, false),
map((clients) => clients[userId] as Rc<BitwardenClient>),
distinctUntilChanged(),
throwIfEmpty(() => new UserNotLoggedInError(userId)),
);
}
setClient(): void {
throw new Error("Not supported in mock service");
}
/**
* Returns the non-user scoped client mock.
* This is what is returned by the `client$` observable.
*/
get client(): DeepMockProxy<BitwardenClient> {
return this._client$.value;
}
readonly simulate = {
/**
* Simulates a user login, and returns a user-scoped mock for the user.
* This will be return by the `userClient$` observable.
*
* @param userId The userId to simulate login for.
* @returns A user-scoped mock for the user.
*/
userLogin: (userId: UserId) => {
const client = mockDeep<BitwardenClient>();
this.userClients$.next({
...this.userClients$.getValue(),
[userId]: new Rc(client),
});
return client;
},
/**
* Simulates a user logout, and disposes the user-scoped mock for the user.
* This will remove the user-scoped mock from the `userClient$` observable.
*
* @param userId The userId to simulate logout for.
*/
userLogout: (userId: UserId) => {
const clients = this.userClients$.value;
clients[userId]?.markForDisposal();
this.userClients$.next({
...clients,
[userId]: undefined,
});
},
};
}

View File

@@ -130,23 +130,23 @@ describe("DefaultSyncService", () => {
const user1 = "user1" as UserId;
const emptySyncResponse = new SyncResponse({
profile: {
id: user1,
},
folders: [],
collections: [],
ciphers: [],
sends: [],
domains: [],
policies: [],
});
describe("fullSync", () => {
beforeEach(() => {
accountService.activeAccount$ = of({ id: user1 } as Account);
Matrix.autoMockMethod(authService.authStatusFor$, () => of(AuthenticationStatus.Unlocked));
apiService.getSync.mockResolvedValue(
new SyncResponse({
profile: {
id: user1,
},
folders: [],
collections: [],
ciphers: [],
sends: [],
domains: [],
policies: [],
}),
);
apiService.getSync.mockResolvedValue(emptySyncResponse);
Matrix.autoMockMethod(userDecryptionOptionsService.userDecryptionOptionsById$, () =>
of({ hasMasterPassword: true } satisfies UserDecryptionOptions),
);
@@ -201,5 +201,44 @@ describe("DefaultSyncService", () => {
expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1);
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
describe("in-flight syncs", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("does not call getSync when one is already in progress", async () => {
const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false), sut.fullSync(false)];
jest.advanceTimersByTime(100);
await Promise.all(fullSyncPromises);
expect(apiService.getSync).toHaveBeenCalledTimes(1);
});
it("does not call refreshIdentityToken when one is already in progress", async () => {
const fullSyncPromises = [sut.fullSync(true), sut.fullSync(false), sut.fullSync(false)];
jest.advanceTimersByTime(100);
await Promise.all(fullSyncPromises);
expect(apiService.refreshIdentityToken).toHaveBeenCalledTimes(1);
});
it("resets the in-flight properties when the complete", async () => {
const fullSyncPromises = [sut.fullSync(true), sut.fullSync(true)];
await Promise.all(fullSyncPromises);
expect(sut["inFlightApiCalls"].refreshToken).toBeNull();
expect(sut["inFlightApiCalls"].sync).toBeNull();
});
});
});
});

View File

@@ -58,11 +58,21 @@ import { MessageSender } from "../messaging";
import { StateProvider } from "../state";
import { CoreSyncService } from "./core-sync.service";
import { SyncResponse } from "./sync.response";
import { SyncOptions } from "./sync.service";
export class DefaultSyncService extends CoreSyncService {
syncInProgress = false;
/** The promises associated with any in-flight api calls. */
private inFlightApiCalls: {
refreshToken: Promise<void> | null;
sync: Promise<SyncResponse> | null;
} = {
refreshToken: null,
sync: null,
};
constructor(
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
accountService: AccountService,
@@ -141,9 +151,24 @@ export class DefaultSyncService extends CoreSyncService {
try {
if (!skipTokenRefresh) {
await this.apiService.refreshIdentityToken();
// Store the promise so multiple calls to refresh the token are not made
if (this.inFlightApiCalls.refreshToken === null) {
this.inFlightApiCalls.refreshToken = this.apiService.refreshIdentityToken();
}
await this.inFlightApiCalls.refreshToken;
}
const response = await this.apiService.getSync();
// Store the promise so multiple calls to sync are not made
if (this.inFlightApiCalls.sync === null) {
this.inFlightApiCalls.sync = this.apiService.getSync();
} else {
this.logService.debug(
"Sync: Sync network call already in progress, returning existing promise",
);
}
const response = await this.inFlightApiCalls.sync;
await this.syncProfile(response.profile);
await this.syncFolders(response.folders, response.profile.id);
@@ -162,6 +187,9 @@ export class DefaultSyncService extends CoreSyncService {
} else {
return this.syncCompleted(false, userId);
}
} finally {
this.inFlightApiCalls.refreshToken = null;
this.inFlightApiCalls.sync = null;
}
}

View File

@@ -60,6 +60,7 @@ const SomeProvider = {
} as LegacyEncryptorProvider,
state: SomeStateProvider,
log: disabledSemanticLoggerProvider,
now: Date.now,
} as UserStateSubjectDependencyProvider;
const SomeExtension: ExtensionMetadata = {

View File

@@ -0,0 +1,24 @@
import { Jsonify } from "type-fest";
import { deepFreeze } from "../util";
import { SemanticLogger } from "./semantic-logger.abstraction";
/** All disabled loggers emitted by this module are `===` to this logger. */
export const DISABLED_LOGGER: SemanticLogger = deepFreeze({
debug<T>(_content: Jsonify<T>, _message?: string): void {},
info<T>(_content: Jsonify<T>, _message?: string): void {},
warn<T>(_content: Jsonify<T>, _message?: string): void {},
error<T>(_content: Jsonify<T>, _message?: string): void {},
panic<T>(content: Jsonify<T>, message?: string): never {
if (typeof content === "string" && !message) {
throw new Error(content);
} else {
throw new Error(message);
}
},
});

View File

@@ -1,22 +0,0 @@
import { Jsonify } from "type-fest";
import { SemanticLogger } from "./semantic-logger.abstraction";
/** Disables semantic logs. Still panics. */
export class DisabledSemanticLogger implements SemanticLogger {
debug<T>(_content: Jsonify<T>, _message?: string): void {}
info<T>(_content: Jsonify<T>, _message?: string): void {}
warn<T>(_content: Jsonify<T>, _message?: string): void {}
error<T>(_content: Jsonify<T>, _message?: string): void {}
panic<T>(content: Jsonify<T>, message?: string): never {
if (typeof content === "string" && !message) {
throw new Error(content);
} else {
throw new Error(message);
}
}
}

View File

@@ -3,11 +3,10 @@ import { Jsonify } from "type-fest";
import { LogService } from "../../platform/abstractions/log.service";
import { DefaultSemanticLogger } from "./default-semantic-logger";
import { DisabledSemanticLogger } from "./disabled-semantic-logger";
import { DISABLED_LOGGER } from "./disabled-logger";
import { SemanticLogger } from "./semantic-logger.abstraction";
/** A type for injection of a log provider */
export type LogProvider = <Context>(context: Jsonify<Context>) => SemanticLogger;
import { LogProvider } from "./types";
import { warnLoggingEnabled } from "./util";
/** Instantiates a semantic logger that emits nothing when a message
* is logged.
@@ -18,38 +17,72 @@ export type LogProvider = <Context>(context: Jsonify<Context>) => SemanticLogger
export function disabledSemanticLoggerProvider<Context extends object>(
_context: Jsonify<Context>,
): SemanticLogger {
return new DisabledSemanticLogger();
return DISABLED_LOGGER;
}
/** Instantiates a semantic logger that emits logs to the console.
* @param context a static payload that is cloned when the logger
* logs a message. The `messages`, `level`, and `content` fields
* are reserved for use by loggers.
* @param settings specializes how the semantic logger functions.
* If this is omitted, the logger suppresses debug messages.
* @param logService writes semantic logs to the console
*/
export function consoleSemanticLoggerProvider<Context extends object>(
logger: LogService,
context: Jsonify<Context>,
): SemanticLogger {
return new DefaultSemanticLogger(logger, context);
export function consoleSemanticLoggerProvider(logService: LogService): LogProvider {
function provider<Context extends object>(context: Jsonify<Context>) {
const logger = new DefaultSemanticLogger(logService, context);
warnLoggingEnabled(logService, "consoleSemanticLoggerProvider", context);
return logger;
}
return provider;
}
/** Instantiates a semantic logger that emits logs to the console.
/** Instantiates a semantic logger that emits logs to the console when the
* context's `type` matches its values.
* @param logService writes semantic logs to the console
* @param types the values to match against
*/
export function enableLogForTypes(logService: LogService, types: string[]): LogProvider {
if (types.length) {
warnLoggingEnabled(logService, "enableLogForTypes", { types });
}
function provider<Context extends object>(context: Jsonify<Context>) {
const { type } = context as { type?: unknown };
if (typeof type === "string" && types.includes(type)) {
const logger = new DefaultSemanticLogger(logService, context);
warnLoggingEnabled(logService, "enableLogForTypes", {
targetType: type,
available: types,
loggerContext: context,
});
return logger;
} else {
return DISABLED_LOGGER;
}
}
return provider;
}
/** Instantiates a semantic logger that emits logs to the console when its enabled.
* @param enable logs are emitted when this is true
* @param logService writes semantic logs to the console
* @param context a static payload that is cloned when the logger
* logs a message. The `messages`, `level`, and `content` fields
* are reserved for use by loggers.
* @param settings specializes how the semantic logger functions.
* If this is omitted, the logger suppresses debug messages.
* logs a message.
*
* @remarks The `message`, `level`, `provider`, and `content` fields
* are reserved for use by the semantic logging system.
*/
export function ifEnabledSemanticLoggerProvider<Context extends object>(
enable: boolean,
logger: LogService,
logService: LogService,
context: Jsonify<Context>,
) {
if (enable) {
return consoleSemanticLoggerProvider(logger, context);
const logger = new DefaultSemanticLogger(logService, context);
warnLoggingEnabled(logService, "ifEnabledSemanticLoggerProvider", context);
return logger;
} else {
return disabledSemanticLoggerProvider(context);
return DISABLED_LOGGER;
}
}

View File

@@ -1,2 +1,4 @@
export * from "./factory";
export * from "./disabled-logger";
export { LogProvider } from "./types";
export { SemanticLogger } from "./semantic-logger.abstraction";

View File

@@ -0,0 +1,11 @@
import { Jsonify } from "type-fest";
import { SemanticLogger } from "./semantic-logger.abstraction";
/** Creates a semantic logger.
* @param context all logs emitted by the logger are extended with
* these fields.
* @remarks The `message`, `level`, `provider`, and `content` fields
* are reserved for use by the semantic logging system.
*/
export type LogProvider = <Context extends object>(context: Jsonify<Context>) => SemanticLogger;

View File

@@ -0,0 +1,12 @@
import { LogService } from "../../platform/abstractions/log.service";
// show our GRIT - these functions implement generalized logging
// controls and should return DISABLED_LOGGER in production.
export function warnLoggingEnabled(logService: LogService, method: string, context?: any) {
logService.warning({
method,
context,
provider: "tools/log",
message: "Semantic logging enabled. 🦟 Please report this bug if you see it 🦟",
});
}

View File

@@ -17,7 +17,7 @@ export class PrivateClassifier<Data> implements Classifier<Data, Record<string,
}
const secret = picked as Jsonify<Data>;
return { disclosed: {}, secret };
return { disclosed: null, secret };
}
declassify(_disclosed: Jsonify<Record<keyof Data, never>>, secret: Jsonify<Data>) {

View File

@@ -16,7 +16,7 @@ export class PublicClassifier<Data> implements Classifier<Data, Data, Record<str
}
const disclosed = picked as Jsonify<Data>;
return { disclosed, secret: "" };
return { disclosed, secret: null };
}
declassify(disclosed: Jsonify<Data>, _secret: Jsonify<Record<keyof Data, never>>) {

View File

@@ -0,0 +1,13 @@
import { Observable } from "rxjs";
/**
* Used to infer types from arguments to functions like {@link withLatestReady}.
* So that you can have `forkJoin([Observable<A>, PromiseLike<B>]): Observable<[A, B]>`
* et al.
* @remarks this type definition is derived from rxjs' {@link ObservableInputTuple}.
* The difference is it *only* works with observables, while the rx version works
* with any thing that can become an observable.
*/
export type ObservableTuple<T> = {
[K in keyof T]: Observable<T[K]>;
};

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,13 @@ import {
startWith,
pairwise,
MonoTypeOperatorFunction,
Cons,
scan,
filter,
} from "rxjs";
import { ObservableTuple } from "./rx.rxjs";
/** Returns its input. */
function identity(value: any): any {
return value;
@@ -164,26 +169,30 @@ export function ready<T>(watch$: Observable<any> | Observable<any>[]) {
);
}
export function withLatestReady<Source, Watch>(
watch$: Observable<Watch>,
): OperatorFunction<Source, [Source, Watch]> {
export function withLatestReady<Source, Watch extends readonly unknown[]>(
...watches$: [...ObservableTuple<Watch>]
): OperatorFunction<Source, Cons<Source, Watch>> {
return connect((source$) => {
// these subscriptions are safe because `source$` connects only after there
// is an external subscriber.
const source = new ReplaySubject<Source>(1);
source$.subscribe(source);
const watch = new ReplaySubject<Watch>(1);
watch$.subscribe(watch);
const watches = watches$.map((w) => {
const watch$ = new ReplaySubject<unknown>(1);
w.subscribe(watch$);
return watch$;
}) as [...ObservableTuple<Watch>];
// `concat` is subscribed immediately after it's returned, at which point
// `zip` blocks until all items in `watching$` are ready. If that occurs
// `zip` blocks until all items in `watches` are ready. If that occurs
// after `source$` is hot, then the replay subject sends the last-captured
// emission through immediately. Otherwise, `ready` waits for the next
// emission
return concat(zip(watch).pipe(first(), ignoreElements()), source).pipe(
withLatestFrom(watch),
// emission through immediately. Otherwise, `withLatestFrom` waits for the
// next emission
return concat(zip(watches).pipe(first(), ignoreElements()), source).pipe(
withLatestFrom(...watches),
takeUntil(anyComplete(source)),
);
) as Observable<Cons<Source, Watch>>;
});
}
@@ -238,3 +247,54 @@ export function pin<T>(options?: {
}),
);
}
/** maps a value to a result and keeps a cache of the mapping
* @param mapResult - maps the stream to a result; this function must return
* a value. It must not return null or undefined.
* @param options.size - the number of entries in the cache
* @param options.key - maps the source to a cache key
* @remarks this method is useful for optimization of expensive
* `mapResult` calls. It's also useful when an interned reference type
* is needed.
*/
export function memoizedMap<Source, Result extends NonNullable<any>>(
mapResult: (source: Source) => Result,
options?: { size?: number; key?: (source: Source) => unknown },
): OperatorFunction<Source, Result> {
return pipe(
// scan's accumulator contains the cache
scan(
([cache], source) => {
const key: unknown = options?.key?.(source) ?? source;
// cache hit?
let result = cache?.get(key);
if (result) {
return [cache, result] as const;
}
// cache miss
result = mapResult(source);
cache?.set(key, result);
// trim cache
const overage = cache.size - (options?.size ?? 1);
if (overage > 0) {
Array.from(cache?.keys() ?? [])
.slice(0, overage)
.forEach((k) => cache?.delete(k));
}
return [cache, result] as const;
},
// FIXME: upgrade to a least-recently-used cache
[new Map(), null] as [Map<unknown, Result>, Source | null],
),
// encapsulate cache
map(([, result]) => result),
// preserve `NonNullable` constraint on `Result`
filter((result): result is Result => !!result),
);
}

View File

@@ -15,4 +15,9 @@ export abstract class UserStateSubjectDependencyProvider {
// FIXME: remove `log` and inject the system provider into the USS instead
/** Provides semantic logging */
abstract log: <Context extends object>(_context: Jsonify<Context>) => SemanticLogger;
/** Get the system time as a number of seconds since the unix epoch
* @remarks this can be turned into a date using `new Date(provider.now())`
*/
abstract now: () => number;
}

View File

@@ -90,6 +90,7 @@ const SomeProvider = {
} as LegacyEncryptorProvider,
state: SomeStateProvider,
log: disabledSemanticLoggerProvider,
now: () => 100,
};
function fooMaxLength(maxLength: number): StateConstraints<TestType> {

View File

@@ -477,7 +477,12 @@ export class UserStateSubject<
* @returns the subscription
*/
subscribe(observer?: Partial<Observer<State>> | ((value: State) => void) | null): Subscription {
return this.output.pipe(map((wc) => wc.state)).subscribe(observer);
return this.output
.pipe(
map((wc) => wc.state),
distinctUntilChanged(),
)
.subscribe(observer);
}
// using subjects to ensure the right semantics are followed;

View File

@@ -2,6 +2,11 @@ import { Simplify } from "type-fest";
import { IntegrationId } from "./integration";
/** When this is a string, it contains the i18n key. When it is an object, the `literal` member
* contains text that should not be translated.
*/
export type I18nKeyOrLiteral = string | { literal: string };
/** Constraints that are shared by all primitive field types */
type PrimitiveConstraint = {
/** `true` indicates the field is required; otherwise the field is optional */

View File

@@ -1,3 +1,5 @@
import { I18nKeyOrLiteral } from "./types";
/** Recursively freeze an object's own keys
* @param value the value to freeze
* @returns `value`
@@ -10,10 +12,22 @@ export function deepFreeze<T extends object>(value: T): Readonly<T> {
for (const key of keys) {
const own = value[key];
if ((own && typeof own === "object") || typeof own === "function") {
if (own && typeof own === "object") {
deepFreeze(own);
}
}
return Object.freeze(value);
}
/** Type guard that returns `true` when the value is an i18n key. */
export function isI18nKey(value: I18nKeyOrLiteral): value is string {
return typeof value === "string";
}
/** Type guard that returns `true` when the value requires no translation.
* @remarks the literal value can be accessed using the `.literal` property.
*/
export function isLiteral(value: I18nKeyOrLiteral): value is { literal: string } {
return typeof value === "object" && "literal" in value;
}

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