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:
14
.github/renovate.json5
vendored
14
.github/renovate.json5
vendored
@@ -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"],
|
||||
}
|
||||
|
||||
@@ -2204,6 +2204,9 @@
|
||||
"useThisPassword": {
|
||||
"message": "Use this password"
|
||||
},
|
||||
"useThisPassphrase": {
|
||||
"message": "Use this passphrase"
|
||||
},
|
||||
"useThisUsername": {
|
||||
"message": "Use this username"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div bitDialogContent>
|
||||
<p>© 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">
|
||||
|
||||
@@ -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` */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -49,7 +49,6 @@ import { PopupCipherView } from "../../views/popup-cipher.view";
|
||||
IconButtonModule,
|
||||
OrgIconDirective,
|
||||
TypographyModule,
|
||||
DecryptionFailureDialogComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
5
apps/desktop/desktop_native/Cargo.lock
generated
5
apps/desktop/desktop_native/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
21
apps/desktop/desktop_native/core/src/autostart/linux.rs
Normal file
21
apps/desktop/desktop_native/core/src/autostart/linux.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
5
apps/desktop/desktop_native/core/src/autostart/mod.rs
Normal file
5
apps/desktop/desktop_native/core/src/autostart/mod.rs
Normal 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::*;
|
||||
@@ -0,0 +1,5 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub async fn set_autostart(_autostart: bool, _params: Vec<String>) -> Result<()> {
|
||||
unimplemented!();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod autofill;
|
||||
pub mod autostart;
|
||||
pub mod biometric;
|
||||
pub mod clipboard;
|
||||
pub mod crypto;
|
||||
|
||||
3
apps/desktop/desktop_native/napi/index.d.ts
vendored
3
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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$
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
58
libs/common/src/platform/spec/mock-deep.spec.ts
Normal file
58
libs/common/src/platform/spec/mock-deep.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
271
libs/common/src/platform/spec/mock-deep.ts
Normal file
271
libs/common/src/platform/spec/mock-deep.ts
Normal 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;
|
||||
81
libs/common/src/platform/spec/mock-sdk.service.ts
Normal file
81
libs/common/src/platform/spec/mock-sdk.service.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ const SomeProvider = {
|
||||
} as LegacyEncryptorProvider,
|
||||
state: SomeStateProvider,
|
||||
log: disabledSemanticLoggerProvider,
|
||||
now: Date.now,
|
||||
} as UserStateSubjectDependencyProvider;
|
||||
|
||||
const SomeExtension: ExtensionMetadata = {
|
||||
|
||||
24
libs/common/src/tools/log/disabled-logger.ts
Normal file
24
libs/common/src/tools/log/disabled-logger.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./factory";
|
||||
export * from "./disabled-logger";
|
||||
export { LogProvider } from "./types";
|
||||
export { SemanticLogger } from "./semantic-logger.abstraction";
|
||||
|
||||
11
libs/common/src/tools/log/types.ts
Normal file
11
libs/common/src/tools/log/types.ts
Normal 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;
|
||||
12
libs/common/src/tools/log/util.ts
Normal file
12
libs/common/src/tools/log/util.ts
Normal 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 🦟",
|
||||
});
|
||||
}
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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>>) {
|
||||
|
||||
13
libs/common/src/tools/rx.rxjs.ts
Normal file
13
libs/common/src/tools/rx.rxjs.ts
Normal 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
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ const SomeProvider = {
|
||||
} as LegacyEncryptorProvider,
|
||||
state: SomeStateProvider,
|
||||
log: disabledSemanticLoggerProvider,
|
||||
now: () => 100,
|
||||
};
|
||||
|
||||
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user