1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-18 01:03:35 +00:00

[PM-5189] Merging main and fixing conflicts

This commit is contained in:
Cesar Gonzalez
2024-05-02 16:31:29 -05:00
108 changed files with 2198 additions and 941 deletions

View File

@@ -1120,9 +1120,6 @@
"commandLockVaultDesc": {
"message": "Lock the vault"
},
"privateModeWarning": {
"message": "Private mode support is experimental and some features are limited."
},
"customFields": {
"message": "Custom fields"
},

View File

@@ -89,7 +89,6 @@
<p class="text-center" *ngIf="!fido2Data.isFido2Session">
<button type="button" appStopClick (click)="logOut()">{{ "logOut" | i18n }}</button>
</p>
<app-private-mode-warning></app-private-mode-warning>
<app-callout *ngIf="biometricError" type="error">{{ biometricError }}</app-callout>
<p class="text-center text-muted" *ngIf="pendingBiometric">
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i> {{ "awaitDesktop" | i18n }}

View File

@@ -57,7 +57,6 @@
</button>
</div>
</div>
<app-private-mode-warning></app-private-mode-warning>
<div class="content login-buttons">
<button type="submit" class="btn primary block" [disabled]="form.loading">
<span [hidden]="form.loading"

View File

@@ -13,6 +13,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { EventType } from "@bitwarden/common/enums";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import {
@@ -75,13 +76,14 @@ describe("AutofillService", () => {
const logService = mock<LogService>();
const userVerificationService = mock<UserVerificationService>();
const billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
const platformUtilsService = mock<PlatformUtilsService>();
let inlineMenuVisibilitySettingMock$!: BehaviorSubject<InlineMenuVisibilitySetting>;
beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
inlineMenuVisibilitySettingMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus);
autofillSettingsService = mock<AutofillSettingsServiceAbstraction>();
autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilitySettingMock$;
scriptInjectorService = new BrowserScriptInjectorService();
autofillService = new AutofillService(
cipherService,
autofillSettingsService,

View File

@@ -110,17 +110,13 @@ export default class AutofillService implements AutofillServiceInterface {
frameId = 0,
triggeringOnPageLoad = true,
): Promise<void> {
// Autofill settings loaded from state can await the active account state indefinitely if
// not guarded by an active account check (e.g. the user is logged in)
// Autofill user settings loaded from state can await the active account state indefinitely
// if not guarded by an active account check (e.g. the user is logged in)
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
// These settings are not available until the user logs in
let overlayVisibility: InlineMenuVisibilitySetting = AutofillOverlayVisibility.Off;
let autoFillOnPageLoadIsEnabled = false;
const overlayVisibility = await this.getInlineMenuVisibility();
if (activeAccount) {
overlayVisibility = await this.getInlineMenuVisibility();
}
const mainAutofillScript = overlayVisibility
? "bootstrap-autofill-overlay.js"
: "bootstrap-autofill.js";

View File

@@ -106,10 +106,8 @@ import { DefaultConfigService } from "@bitwarden/common/platform/services/config
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { SystemService } from "@bitwarden/common/platform/services/system.service";
@@ -213,11 +211,14 @@ import { UpdateBadge } from "../platform/listeners/update-badge";
/* eslint-disable no-restricted-imports */
import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender";
/* eslint-enable no-restricted-imports */
import { OffscreenDocumentService } from "../platform/offscreen-document/abstractions/offscreen-document";
import { DefaultOffscreenDocumentService } from "../platform/offscreen-document/offscreen-document.service";
import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service";
import { BrowserCryptoService } from "../platform/services/browser-crypto.service";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import BrowserLocalStorageService from "../platform/services/browser-local-storage.service";
import BrowserMemoryStorageService from "../platform/services/browser-memory-storage.service";
import { BrowserMultithreadEncryptServiceImplementation } from "../platform/services/browser-multithread-encrypt.service.implementation";
import { BrowserScriptInjectorService } from "../platform/services/browser-script-injector.service";
import { DefaultBrowserStateService } from "../platform/services/default-browser-state.service";
import I18nService from "../platform/services/i18n.service";
@@ -337,6 +338,7 @@ export default class MainBackground {
userAutoUnlockKeyService: UserAutoUnlockKeyService;
scriptInjectorService: BrowserScriptInjectorService;
kdfConfigService: kdfConfigServiceAbstraction;
offscreenDocumentService: OffscreenDocumentService;
onUpdatedRan: boolean;
onReplacedRan: boolean;
@@ -356,10 +358,7 @@ export default class MainBackground {
private isSafari: boolean;
private nativeMessagingBackground: NativeMessagingBackground;
constructor(
public isPrivateMode: boolean = false,
public popupOnlyContext: boolean = false,
) {
constructor(public popupOnlyContext: boolean = false) {
// Services
const lockedCallback = async (userId?: string) => {
if (this.notificationsService != null) {
@@ -397,11 +396,14 @@ export default class MainBackground {
),
);
this.offscreenDocumentService = new DefaultOffscreenDocumentService();
this.platformUtilsService = new BackgroundPlatformUtilsService(
this.messagingService,
(clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs),
async () => this.biometricUnlock(),
self,
this.offscreenDocumentService,
);
// Creates a session key for mv3 storage of large memory items
@@ -443,10 +445,14 @@ export default class MainBackground {
this.secureStorageService = this.storageService; // secure storage is not supported in browsers, so we use local storage and warn users when it is used
this.memoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
? new BrowserMemoryStorageService() // mv3 stores to storage.session
: new BackgroundMemoryStorageService(); // mv2 stores to memory
: popupOnlyContext
? new ForegroundMemoryStorageService()
: new BackgroundMemoryStorageService(); // mv2 stores to memory
this.memoryStorageService = BrowserApi.isManifestVersion(3)
? this.memoryStorageForStateProviders // manifest v3 can reuse the same storage. They are split for v2 due to lacking a good sync mechanism, which isn't true for v3
: new MemoryStorageService();
: popupOnlyContext
? new ForegroundMemoryStorageService()
: new BackgroundMemoryStorageService();
this.largeObjectMemoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
? mv3MemoryStorageCreator() // mv3 stores to local-backed session storage
: this.memoryStorageForStateProviders; // mv2 stores to the same location
@@ -469,14 +475,14 @@ export default class MainBackground {
storageServiceProvider,
);
this.encryptService =
flagEnabled("multithreadDecryption") && BrowserApi.isManifestVersion(2)
? new MultithreadEncryptServiceImplementation(
this.cryptoFunctionService,
this.logService,
true,
)
: new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true);
this.encryptService = flagEnabled("multithreadDecryption")
? new BrowserMultithreadEncryptServiceImplementation(
this.cryptoFunctionService,
this.logService,
true,
this.offscreenDocumentService,
)
: new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true);
this.singleUserStateProvider = new DefaultSingleUserStateProvider(
storageServiceProvider,
@@ -737,7 +743,6 @@ export default class MainBackground {
this.cipherService,
this.folderService,
this.collectionService,
this.cryptoService,
this.platformUtilsService,
this.messagingService,
this.searchService,
@@ -808,7 +813,10 @@ export default class MainBackground {
);
this.totpService = new TotpService(this.cryptoFunctionService, this.logService);
this.scriptInjectorService = new BrowserScriptInjectorService();
this.scriptInjectorService = new BrowserScriptInjectorService(
this.platformUtilsService,
this.logService,
);
this.autofillService = new AutofillService(
this.cipherService,
this.autofillSettingsService,
@@ -1110,27 +1118,9 @@ export default class MainBackground {
await this.idleBackground.init();
this.webRequestBackground?.startListening();
if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) {
// Set Private Mode windows to the default icon - they do not share state with the background page
const privateWindows = await BrowserApi.getPrivateModeWindows();
privateWindows.forEach(async (win) => {
await new UpdateBadge(self).setBadgeIcon("", win.id);
});
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserApi.onWindowCreated(async (win) => {
if (win.incognito) {
await new UpdateBadge(self).setBadgeIcon("", win.id);
}
});
}
return new Promise<void>((resolve) => {
setTimeout(async () => {
if (!this.isPrivateMode) {
await this.refreshBadge();
}
await this.refreshBadge();
await this.fullSync(true);
setTimeout(() => this.notificationsService.init(), 2500);
resolve();

View File

@@ -12,10 +12,6 @@ import {
internalMasterPasswordServiceFactory,
MasterPasswordServiceInitOptions,
} from "../../auth/background/service-factories/master-password-service.factory";
import {
CryptoServiceInitOptions,
cryptoServiceFactory,
} from "../../platform/background/service-factories/crypto-service.factory";
import {
CachedServices,
factory,
@@ -70,7 +66,6 @@ export type VaultTimeoutServiceInitOptions = VaultTimeoutServiceFactoryOptions &
CipherServiceInitOptions &
FolderServiceInitOptions &
CollectionServiceInitOptions &
CryptoServiceInitOptions &
PlatformUtilsServiceInitOptions &
MessagingServiceInitOptions &
SearchServiceInitOptions &
@@ -94,7 +89,6 @@ export function vaultTimeoutServiceFactory(
await cipherServiceFactory(cache, opts),
await folderServiceFactory(cache, opts),
await collectionServiceFactory(cache, opts),
await cryptoServiceFactory(cache, opts),
await platformUtilsServiceFactory(cache, opts),
await messagingServiceFactory(cache, opts),
await searchServiceFactory(cache, opts),

View File

@@ -51,7 +51,7 @@
"default_popup": "popup/index.html"
},
"permissions": [
"<all_urls>",
"activeTab",
"tabs",
"contextMenus",
"storage",
@@ -66,7 +66,7 @@
"webNavigation"
],
"optional_permissions": ["nativeMessaging", "privacy"],
"host_permissions": ["<all_urls>"],
"host_permissions": ["https://*/*", "http://*/*"],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"sandbox": "sandbox allow-scripts; script-src 'self'"

View File

@@ -1,10 +1,20 @@
import {
LogServiceInitOptions,
logServiceFactory,
} from "../../background/service-factories/log-service.factory";
import { BrowserScriptInjectorService } from "../../services/browser-script-injector.service";
import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
PlatformUtilsServiceInitOptions,
platformUtilsServiceFactory,
} from "./platform-utils-service.factory";
type BrowserScriptInjectorServiceOptions = FactoryOptions;
export type BrowserScriptInjectorServiceInitOptions = BrowserScriptInjectorServiceOptions;
export type BrowserScriptInjectorServiceInitOptions = BrowserScriptInjectorServiceOptions &
PlatformUtilsServiceInitOptions &
LogServiceInitOptions;
export function browserScriptInjectorServiceFactory(
cache: { browserScriptInjectorService?: BrowserScriptInjectorService } & CachedServices,
@@ -14,6 +24,10 @@ export function browserScriptInjectorServiceFactory(
cache,
"browserScriptInjectorService",
opts,
async () => new BrowserScriptInjectorService(),
async () =>
new BrowserScriptInjectorService(
await platformUtilsServiceFactory(cache, opts),
await logServiceFactory(cache, opts),
),
);
}

View File

@@ -30,6 +30,7 @@ export function platformUtilsServiceFactory(
opts.platformUtilsServiceOptions.clipboardWriteCallback,
opts.platformUtilsServiceOptions.biometricCallback,
opts.platformUtilsServiceOptions.win,
null,
),
);
}

View File

@@ -525,32 +525,6 @@ describe("BrowserApi", () => {
});
});
describe("createOffscreenDocument", () => {
it("creates the offscreen document with the supplied reasons and justification", async () => {
const reasons = [chrome.offscreen.Reason.CLIPBOARD];
const justification = "justification";
await BrowserApi.createOffscreenDocument(reasons, justification);
expect(chrome.offscreen.createDocument).toHaveBeenCalledWith({
url: "offscreen-document/index.html",
reasons,
justification,
});
});
});
describe("closeOffscreenDocument", () => {
it("closes the offscreen document", () => {
const callbackMock = jest.fn();
BrowserApi.closeOffscreenDocument(callbackMock);
expect(chrome.offscreen.closeDocument).toHaveBeenCalled();
expect(callbackMock).toHaveBeenCalled();
});
});
describe("registerContentScriptsMv2", () => {
const details: browser.contentScripts.RegisteredContentScriptOptions = {
matches: ["<all_urls>"],

View File

@@ -204,10 +204,6 @@ export class BrowserApi {
chrome.tabs.sendMessage<TabMessage, T>(tabId, message, options, responseCallback);
}
static async getPrivateModeWindows(): Promise<browser.windows.Window[]> {
return (await browser.windows.getAll()).filter((win) => win.incognito);
}
static async onWindowCreated(callback: (win: chrome.windows.Window) => any) {
// FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener
// and test that it doesn't break.
@@ -574,34 +570,6 @@ export class BrowserApi {
chrome.privacy.services.passwordSavingEnabled.set({ value });
}
/**
* Opens the offscreen document with the given reasons and justification.
*
* @param reasons - List of reasons for opening the offscreen document.
* @see https://developer.chrome.com/docs/extensions/reference/api/offscreen#type-Reason
* @param justification - Custom written justification for opening the offscreen document.
*/
static async createOffscreenDocument(reasons: chrome.offscreen.Reason[], justification: string) {
await chrome.offscreen.createDocument({
url: "offscreen-document/index.html",
reasons,
justification,
});
}
/**
* Closes the offscreen document.
*
* @param callback - Optional callback to execute after the offscreen document is closed.
*/
static closeOffscreenDocument(callback?: () => void) {
chrome.offscreen.closeDocument(() => {
if (callback) {
callback();
}
});
}
/**
* Handles registration of static content scripts within manifest v2.
*

View File

@@ -1,7 +1,8 @@
type OffscreenDocumentExtensionMessage = {
export type OffscreenDocumentExtensionMessage = {
[key: string]: any;
command: string;
text?: string;
decryptRequest?: string;
};
type OffscreenExtensionMessageEventParams = {
@@ -9,18 +10,21 @@ type OffscreenExtensionMessageEventParams = {
sender: chrome.runtime.MessageSender;
};
type OffscreenDocumentExtensionMessageHandlers = {
export type OffscreenDocumentExtensionMessageHandlers = {
[key: string]: ({ message, sender }: OffscreenExtensionMessageEventParams) => any;
offscreenCopyToClipboard: ({ message }: OffscreenExtensionMessageEventParams) => any;
offscreenReadFromClipboard: () => any;
offscreenDecryptItems: ({ message }: OffscreenExtensionMessageEventParams) => Promise<string>;
};
interface OffscreenDocument {
export interface OffscreenDocument {
init(): void;
}
export {
OffscreenDocumentExtensionMessage,
OffscreenDocumentExtensionMessageHandlers,
OffscreenDocument,
};
export abstract class OffscreenDocumentService {
abstract withDocument<T>(
reasons: chrome.offscreen.Reason[],
justification: string,
callback: () => Promise<T> | T,
): Promise<T>;
}

View File

@@ -0,0 +1,101 @@
import { DefaultOffscreenDocumentService } from "./offscreen-document.service";
class TestCase {
synchronicity: string;
private _callback: () => Promise<any> | any;
get callback() {
return jest.fn(this._callback);
}
constructor(synchronicity: string, callback: () => Promise<any> | any) {
this.synchronicity = synchronicity;
this._callback = callback;
}
toString() {
return this.synchronicity;
}
}
describe.each([
new TestCase("synchronous callback", () => 42),
new TestCase("asynchronous callback", () => Promise.resolve(42)),
])("DefaultOffscreenDocumentService %s", (testCase) => {
let sut: DefaultOffscreenDocumentService;
const reasons = [chrome.offscreen.Reason.TESTING];
const justification = "justification is testing";
const url = "offscreen-document/index.html";
const api = {
createDocument: jest.fn(),
closeDocument: jest.fn(),
hasDocument: jest.fn().mockResolvedValue(false),
Reason: chrome.offscreen.Reason,
};
let callback: jest.Mock<() => Promise<number> | number>;
beforeEach(() => {
callback = testCase.callback;
chrome.offscreen = api;
sut = new DefaultOffscreenDocumentService();
});
afterEach(() => {
jest.resetAllMocks();
});
describe("withDocument", () => {
it("creates a document when none exists", async () => {
await sut.withDocument(reasons, justification, () => {});
expect(chrome.offscreen.createDocument).toHaveBeenCalledWith({
url,
reasons,
justification,
});
});
it("does not create a document when one exists", async () => {
api.hasDocument.mockResolvedValue(true);
await sut.withDocument(reasons, justification, callback);
expect(chrome.offscreen.createDocument).not.toHaveBeenCalled();
});
describe.each([true, false])("hasDocument returns %s", (hasDocument) => {
beforeEach(() => {
api.hasDocument.mockResolvedValue(hasDocument);
});
it("calls the callback", async () => {
await sut.withDocument(reasons, justification, callback);
expect(callback).toHaveBeenCalled();
});
it("returns the callback result", async () => {
const result = await sut.withDocument(reasons, justification, callback);
expect(result).toBe(42);
});
it("closes the document when the callback completes and no other callbacks are running", async () => {
await sut.withDocument(reasons, justification, callback);
expect(chrome.offscreen.closeDocument).toHaveBeenCalled();
});
it("does not close the document when the callback completes and other callbacks are running", async () => {
await Promise.all([
sut.withDocument(reasons, justification, callback),
sut.withDocument(reasons, justification, callback),
sut.withDocument(reasons, justification, callback),
sut.withDocument(reasons, justification, callback),
]);
expect(chrome.offscreen.closeDocument).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@@ -0,0 +1,41 @@
export class DefaultOffscreenDocumentService implements DefaultOffscreenDocumentService {
private workerCount = 0;
constructor() {}
async withDocument<T>(
reasons: chrome.offscreen.Reason[],
justification: string,
callback: () => Promise<T> | T,
): Promise<T> {
this.workerCount++;
try {
if (!(await this.documentExists())) {
await this.create(reasons, justification);
}
return await callback();
} finally {
this.workerCount--;
if (this.workerCount === 0) {
await this.close();
}
}
}
private async create(reasons: chrome.offscreen.Reason[], justification: string): Promise<void> {
await chrome.offscreen.createDocument({
url: "offscreen-document/index.html",
reasons,
justification,
});
}
private async close(): Promise<void> {
await chrome.offscreen.closeDocument();
}
private async documentExists(): Promise<boolean> {
return await chrome.offscreen.hasDocument();
}
}

View File

@@ -1,7 +1,25 @@
import { mock } from "jest-mock-extended";
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { flushPromises, sendMockExtensionMessage } from "../../autofill/spec/testing-utils";
import { BrowserApi } from "../browser/browser-api";
import BrowserClipboardService from "../services/browser-clipboard.service";
jest.mock(
"@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation",
() => ({
MultithreadEncryptServiceImplementation: class MultithreadEncryptServiceImplementation {
getDecryptedItemsFromWorker = async <T extends InitializerMetadata>(
items: Decryptable<T>[],
_key: SymmetricCryptoKey,
): Promise<string> => JSON.stringify(items);
},
}),
);
describe("OffscreenDocument", () => {
const browserApiMessageListenerSpy = jest.spyOn(BrowserApi, "messageListener");
const browserClipboardServiceCopySpy = jest.spyOn(BrowserClipboardService, "copy");
@@ -60,5 +78,37 @@ describe("OffscreenDocument", () => {
expect(browserClipboardServiceReadSpy).toHaveBeenCalledWith(window);
});
});
describe("handleOffscreenDecryptItems", () => {
it("returns an empty array as a string if the decrypt request is not present in the message", async () => {
let response: string | undefined;
sendMockExtensionMessage(
{ command: "offscreenDecryptItems" },
mock<chrome.runtime.MessageSender>(),
(res: string) => (response = res),
);
await flushPromises();
expect(response).toBe("[]");
});
it("decrypts the items and sends back the response as a string", async () => {
const items = [{ id: "test" }];
const key = { id: "test" };
const decryptRequest = JSON.stringify({ items, key });
let response: string | undefined;
sendMockExtensionMessage(
{ command: "offscreenDecryptItems", decryptRequest },
mock<chrome.runtime.MessageSender>(),
(res: string) => {
response = res;
},
);
await flushPromises();
expect(response).toBe(JSON.stringify(items));
});
});
});
});

View File

@@ -1,21 +1,35 @@
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { BrowserApi } from "../browser/browser-api";
import BrowserClipboardService from "../services/browser-clipboard.service";
import {
OffscreenDocument as OffscreenDocumentInterface,
OffscreenDocumentExtensionMessage,
OffscreenDocumentExtensionMessageHandlers,
OffscreenDocument as OffscreenDocumentInterface,
} from "./abstractions/offscreen-document";
class OffscreenDocument implements OffscreenDocumentInterface {
private consoleLogService: ConsoleLogService = new ConsoleLogService(false);
private readonly consoleLogService: ConsoleLogService;
private encryptService: MultithreadEncryptServiceImplementation;
private readonly extensionMessageHandlers: OffscreenDocumentExtensionMessageHandlers = {
offscreenCopyToClipboard: ({ message }) => this.handleOffscreenCopyToClipboard(message),
offscreenReadFromClipboard: () => this.handleOffscreenReadFromClipboard(),
offscreenDecryptItems: ({ message }) => this.handleOffscreenDecryptItems(message),
};
constructor() {
const cryptoFunctionService = new WebCryptoFunctionService(self);
this.consoleLogService = new ConsoleLogService(false);
this.encryptService = new MultithreadEncryptServiceImplementation(
cryptoFunctionService,
this.consoleLogService,
true,
);
}
/**
* Initializes the offscreen document extension.
*/
@@ -39,6 +53,23 @@ class OffscreenDocument implements OffscreenDocumentInterface {
return await BrowserClipboardService.read(self);
}
/**
* Decrypts the items in the message using the encrypt service.
*
* @param message - The extension message containing the items to decrypt
*/
private async handleOffscreenDecryptItems(
message: OffscreenDocumentExtensionMessage,
): Promise<string> {
const { decryptRequest } = message;
if (!decryptRequest) {
return "[]";
}
const request = JSON.parse(decryptRequest);
return await this.encryptService.getDecryptedItemsFromWorker(request.items, request.key);
}
/**
* Sets up the listener for extension messages.
*/

View File

@@ -138,28 +138,6 @@ describe("BrowserPopupUtils", () => {
});
});
describe("inPrivateMode", () => {
it("returns false if the background requires initialization", () => {
jest.spyOn(BrowserPopupUtils, "backgroundInitializationRequired").mockReturnValue(false);
expect(BrowserPopupUtils.inPrivateMode()).toBe(false);
});
it("returns false if the manifest version is for version 3", () => {
jest.spyOn(BrowserPopupUtils, "backgroundInitializationRequired").mockReturnValue(true);
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(3);
expect(BrowserPopupUtils.inPrivateMode()).toBe(false);
});
it("returns true if the background does not require initalization and the manifest version is version 2", () => {
jest.spyOn(BrowserPopupUtils, "backgroundInitializationRequired").mockReturnValue(true);
jest.spyOn(BrowserApi, "manifestVersion", "get").mockReturnValue(2);
expect(BrowserPopupUtils.inPrivateMode()).toBe(true);
});
});
describe("openPopout", () => {
beforeEach(() => {
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({

View File

@@ -89,13 +89,6 @@ class BrowserPopupUtils {
return !BrowserApi.getBackgroundPage();
}
/**
* Identifies if the popup is loading in private mode.
*/
static inPrivateMode() {
return BrowserPopupUtils.backgroundInitializationRequired() && !BrowserApi.isManifestVersion(3);
}
/**
* Opens a popout window of any extension page. If the popout window is already open, it will be focused.
*

View File

@@ -0,0 +1,97 @@
import { mock, MockProxy } from "jest-mock-extended";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { InitializerKey } from "@bitwarden/common/platform/services/cryptography/initializer-key";
import { makeStaticByteArray } from "@bitwarden/common/spec";
import { BrowserApi } from "../browser/browser-api";
import { OffscreenDocumentService } from "../offscreen-document/abstractions/offscreen-document";
import { BrowserMultithreadEncryptServiceImplementation } from "./browser-multithread-encrypt.service.implementation";
describe("BrowserMultithreadEncryptServiceImplementation", () => {
let cryptoFunctionServiceMock: MockProxy<CryptoFunctionService>;
let logServiceMock: MockProxy<LogService>;
let offscreenDocumentServiceMock: MockProxy<OffscreenDocumentService>;
let encryptService: BrowserMultithreadEncryptServiceImplementation;
const manifestVersionSpy = jest.spyOn(BrowserApi, "manifestVersion", "get");
const sendMessageWithResponseSpy = jest.spyOn(BrowserApi, "sendMessageWithResponse");
const encType = EncryptionType.AesCbc256_HmacSha256_B64;
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 100), encType);
const items: Decryptable<InitializerMetadata>[] = [
{
decrypt: jest.fn(),
initializerKey: InitializerKey.Cipher,
},
];
beforeEach(() => {
cryptoFunctionServiceMock = mock<CryptoFunctionService>();
logServiceMock = mock<LogService>();
offscreenDocumentServiceMock = mock<OffscreenDocumentService>({
withDocument: jest.fn((_, __, callback) => callback() as any),
});
encryptService = new BrowserMultithreadEncryptServiceImplementation(
cryptoFunctionServiceMock,
logServiceMock,
false,
offscreenDocumentServiceMock,
);
manifestVersionSpy.mockReturnValue(3);
sendMessageWithResponseSpy.mockResolvedValue(JSON.stringify([]));
});
afterEach(() => {
jest.clearAllMocks();
});
it("decrypts items using web workers if the chrome.offscreen API is not supported", async () => {
manifestVersionSpy.mockReturnValue(2);
await encryptService.decryptItems([], key);
expect(offscreenDocumentServiceMock.withDocument).not.toHaveBeenCalled();
});
it("decrypts items using the chrome.offscreen API if it is supported", async () => {
sendMessageWithResponseSpy.mockResolvedValue(JSON.stringify(items));
await encryptService.decryptItems(items, key);
expect(offscreenDocumentServiceMock.withDocument).toHaveBeenCalledWith(
[chrome.offscreen.Reason.WORKERS],
"Use web worker to decrypt items.",
expect.any(Function),
);
expect(BrowserApi.sendMessageWithResponse).toHaveBeenCalledWith("offscreenDecryptItems", {
decryptRequest: expect.any(String),
});
});
it("returns an empty array if the passed items are not defined", async () => {
const result = await encryptService.decryptItems(null, key);
expect(result).toEqual([]);
});
it("returns an empty array if the offscreen document message returns an empty value", async () => {
sendMessageWithResponseSpy.mockResolvedValue("");
const result = await encryptService.decryptItems(items, key);
expect(result).toEqual([]);
});
it("returns an empty array if the offscreen document message returns an empty array", async () => {
sendMessageWithResponseSpy.mockResolvedValue("[]");
const result = await encryptService.decryptItems(items, key);
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,91 @@
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { BrowserApi } from "../browser/browser-api";
import { OffscreenDocumentService } from "../offscreen-document/abstractions/offscreen-document";
export class BrowserMultithreadEncryptServiceImplementation extends MultithreadEncryptServiceImplementation {
constructor(
cryptoFunctionService: CryptoFunctionService,
logService: LogService,
logMacFailures: boolean,
private offscreenDocumentService: OffscreenDocumentService,
) {
super(cryptoFunctionService, logService, logMacFailures);
}
/**
* Handles decryption of items, will use the offscreen document if supported.
*
* @param items - The items to decrypt.
* @param key - The key to use for decryption.
*/
async decryptItems<T extends InitializerMetadata>(
items: Decryptable<T>[],
key: SymmetricCryptoKey,
): Promise<T[]> {
if (!this.isOffscreenDocumentSupported()) {
return await super.decryptItems(items, key);
}
return await this.decryptItemsInOffscreenDocument(items, key);
}
/**
* Decrypts items using the offscreen document api.
*
* @param items - The items to decrypt.
* @param key - The key to use for decryption.
*/
private async decryptItemsInOffscreenDocument<T extends InitializerMetadata>(
items: Decryptable<T>[],
key: SymmetricCryptoKey,
): Promise<T[]> {
if (items == null || items.length < 1) {
return [];
}
const request = {
id: Utils.newGuid(),
items: items,
key: key,
};
const response = await this.offscreenDocumentService.withDocument(
[chrome.offscreen.Reason.WORKERS],
"Use web worker to decrypt items.",
async () => {
return (await BrowserApi.sendMessageWithResponse("offscreenDecryptItems", {
decryptRequest: JSON.stringify(request),
})) as string;
},
);
if (!response) {
return [];
}
const responseItems = JSON.parse(response);
if (responseItems?.length < 1) {
return [];
}
return this.initializeItems(responseItems);
}
/**
* Checks if the offscreen document api is supported.
*/
private isOffscreenDocumentSupported() {
return (
BrowserApi.isManifestVersion(3) &&
typeof chrome !== "undefined" &&
typeof chrome.offscreen !== "undefined"
);
}
}

View File

@@ -1,3 +1,8 @@
import { mock } from "jest-mock-extended";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BrowserApi } from "../browser/browser-api";
import {
@@ -20,9 +25,11 @@ describe("ScriptInjectorService", () => {
let scriptInjectorService: BrowserScriptInjectorService;
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
jest.spyOn(BrowserApi, "isManifestVersion");
const platformUtilsService = mock<PlatformUtilsService>();
const logService = mock<LogService>();
beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService();
scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
});
describe("inject", () => {

View File

@@ -1,3 +1,6 @@
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BrowserApi } from "../browser/browser-api";
import {
@@ -7,6 +10,13 @@ import {
} from "./abstractions/script-injector.service";
export class BrowserScriptInjectorService extends ScriptInjectorService {
constructor(
private readonly platformUtilsService: PlatformUtilsService,
private readonly logService: LogService,
) {
super();
}
/**
* Facilitates the injection of a script into a tab context. Will adjust
* behavior between manifest v2 and v3 based on the passed configuration.
@@ -23,9 +33,26 @@ export class BrowserScriptInjectorService extends ScriptInjectorService {
const injectionDetails = this.buildInjectionDetails(injectDetails, file);
if (BrowserApi.isManifestVersion(3)) {
await BrowserApi.executeScriptInTab(tabId, injectionDetails, {
world: mv3Details?.world ?? "ISOLATED",
});
try {
await BrowserApi.executeScriptInTab(tabId, injectionDetails, {
world: mv3Details?.world ?? "ISOLATED",
});
} catch (error) {
// Swallow errors for host permissions, since this is believed to be a Manifest V3 Chrome bug
// @TODO remove when the bugged behaviour is resolved
if (
error.message !==
"Cannot access contents of the page. Extension manifest must request permission to access the respective host."
) {
throw error;
}
if (this.platformUtilsService.isDev()) {
this.logService.warning(
`BrowserApi.executeScriptInTab exception for ${injectDetails.file} in tab ${tabId}: ${error.message}`,
);
}
}
return;
}

View File

@@ -62,15 +62,6 @@ export class DefaultBrowserStateService
await super.addAccount(account);
}
async getIsAuthenticated(options?: StorageOptions): Promise<boolean> {
// Firefox Private Mode can clash with non-Private Mode because they both read from the same onDiskOptions
// Check that there is an account in memory before considering the user authenticated
return (
(await super.getIsAuthenticated(options)) &&
(await this.getAccount(await this.defaultInMemoryOptions())) != null
);
}
// Overriding the base class to prevent deleting the cache on save. We register a storage listener
// to delete the cache in the constructor above.
protected override async saveAccountToDisk(

View File

@@ -1,5 +1,7 @@
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { OffscreenDocumentService } from "../../offscreen-document/abstractions/offscreen-document";
import { BrowserPlatformUtilsService } from "./browser-platform-utils.service";
export class BackgroundPlatformUtilsService extends BrowserPlatformUtilsService {
@@ -8,8 +10,9 @@ export class BackgroundPlatformUtilsService extends BrowserPlatformUtilsService
clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void,
biometricCallback: () => Promise<boolean>,
win: Window & typeof globalThis,
offscreenDocumentService: OffscreenDocumentService,
) {
super(clipboardWriteCallback, biometricCallback, win);
super(clipboardWriteCallback, biometricCallback, win, offscreenDocumentService);
}
override showToast(

View File

@@ -1,15 +1,22 @@
import { MockProxy, mock } from "jest-mock-extended";
import { DeviceType } from "@bitwarden/common/enums";
import { flushPromises } from "../../../autofill/spec/testing-utils";
import { SafariApp } from "../../../browser/safariApp";
import { BrowserApi } from "../../browser/browser-api";
import { OffscreenDocumentService } from "../../offscreen-document/abstractions/offscreen-document";
import BrowserClipboardService from "../browser-clipboard.service";
import { BrowserPlatformUtilsService } from "./browser-platform-utils.service";
class TestBrowserPlatformUtilsService extends BrowserPlatformUtilsService {
constructor(clipboardSpy: jest.Mock, win: Window & typeof globalThis) {
super(clipboardSpy, null, win);
constructor(
clipboardSpy: jest.Mock,
win: Window & typeof globalThis,
offscreenDocumentService: OffscreenDocumentService,
) {
super(clipboardSpy, null, win, offscreenDocumentService);
}
showToast(
@@ -24,13 +31,16 @@ class TestBrowserPlatformUtilsService extends BrowserPlatformUtilsService {
describe("Browser Utils Service", () => {
let browserPlatformUtilsService: BrowserPlatformUtilsService;
let offscreenDocumentService: MockProxy<OffscreenDocumentService>;
const clipboardWriteCallbackSpy = jest.fn();
beforeEach(() => {
offscreenDocumentService = mock();
(window as any).matchMedia = jest.fn().mockReturnValueOnce({});
browserPlatformUtilsService = new TestBrowserPlatformUtilsService(
clipboardWriteCallbackSpy,
window,
offscreenDocumentService,
);
});
@@ -223,23 +233,23 @@ describe("Browser Utils Service", () => {
.spyOn(browserPlatformUtilsService, "getDevice")
.mockReturnValue(DeviceType.ChromeExtension);
getManifestVersionSpy.mockReturnValue(3);
jest.spyOn(BrowserApi, "createOffscreenDocument");
jest.spyOn(BrowserApi, "sendMessageWithResponse").mockResolvedValue(undefined);
jest.spyOn(BrowserApi, "closeOffscreenDocument");
browserPlatformUtilsService.copyToClipboard(text);
await flushPromises();
expect(triggerOffscreenCopyToClipboardSpy).toHaveBeenCalledWith(text);
expect(clipboardServiceCopySpy).not.toHaveBeenCalled();
expect(BrowserApi.createOffscreenDocument).toHaveBeenCalledWith(
expect(offscreenDocumentService.withDocument).toHaveBeenCalledWith(
[chrome.offscreen.Reason.CLIPBOARD],
"Write text to the clipboard.",
expect.any(Function),
);
const callback = offscreenDocumentService.withDocument.mock.calls[0][2];
await callback();
expect(BrowserApi.sendMessageWithResponse).toHaveBeenCalledWith("offscreenCopyToClipboard", {
text,
});
expect(BrowserApi.closeOffscreenDocument).toHaveBeenCalled();
});
it("skips the clipboardWriteCallback if the clipboard is clearing", async () => {
@@ -298,18 +308,21 @@ describe("Browser Utils Service", () => {
.spyOn(browserPlatformUtilsService, "getDevice")
.mockReturnValue(DeviceType.ChromeExtension);
getManifestVersionSpy.mockReturnValue(3);
jest.spyOn(BrowserApi, "createOffscreenDocument");
jest.spyOn(BrowserApi, "sendMessageWithResponse").mockResolvedValue("test");
jest.spyOn(BrowserApi, "closeOffscreenDocument");
offscreenDocumentService.withDocument.mockImplementationOnce((_, __, callback) =>
Promise.resolve("test"),
);
await browserPlatformUtilsService.readFromClipboard();
expect(BrowserApi.createOffscreenDocument).toHaveBeenCalledWith(
expect(offscreenDocumentService.withDocument).toHaveBeenCalledWith(
[chrome.offscreen.Reason.CLIPBOARD],
"Read text from the clipboard.",
expect.any(Function),
);
const callback = offscreenDocumentService.withDocument.mock.calls[0][2];
await callback();
expect(BrowserApi.sendMessageWithResponse).toHaveBeenCalledWith("offscreenReadFromClipboard");
expect(BrowserApi.closeOffscreenDocument).toHaveBeenCalled();
});
it("returns an empty string from the offscreen document if the response is not of type string", async () => {
@@ -317,9 +330,10 @@ describe("Browser Utils Service", () => {
.spyOn(browserPlatformUtilsService, "getDevice")
.mockReturnValue(DeviceType.ChromeExtension);
getManifestVersionSpy.mockReturnValue(3);
jest.spyOn(BrowserApi, "createOffscreenDocument");
jest.spyOn(BrowserApi, "sendMessageWithResponse").mockResolvedValue(1);
jest.spyOn(BrowserApi, "closeOffscreenDocument");
offscreenDocumentService.withDocument.mockImplementationOnce((_, __, callback) =>
Promise.resolve(1),
);
const result = await browserPlatformUtilsService.readFromClipboard();

View File

@@ -6,6 +6,7 @@ import {
import { SafariApp } from "../../../browser/safariApp";
import { BrowserApi } from "../../browser/browser-api";
import { OffscreenDocumentService } from "../../offscreen-document/abstractions/offscreen-document";
import BrowserClipboardService from "../browser-clipboard.service";
export abstract class BrowserPlatformUtilsService implements PlatformUtilsService {
@@ -15,6 +16,7 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
private clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void,
private biometricCallback: () => Promise<boolean>,
private globalContext: Window | ServiceWorkerGlobalScope,
private offscreenDocumentService: OffscreenDocumentService,
) {}
static getDevice(globalContext: Window | ServiceWorkerGlobalScope): DeviceType {
@@ -316,24 +318,26 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
* Triggers the offscreen document API to copy the text to the clipboard.
*/
private async triggerOffscreenCopyToClipboard(text: string) {
await BrowserApi.createOffscreenDocument(
await this.offscreenDocumentService.withDocument(
[chrome.offscreen.Reason.CLIPBOARD],
"Write text to the clipboard.",
async () => {
await BrowserApi.sendMessageWithResponse("offscreenCopyToClipboard", { text });
},
);
await BrowserApi.sendMessageWithResponse("offscreenCopyToClipboard", { text });
BrowserApi.closeOffscreenDocument();
}
/**
* Triggers the offscreen document API to read the text from the clipboard.
*/
private async triggerOffscreenReadFromClipboard() {
await BrowserApi.createOffscreenDocument(
const response = await this.offscreenDocumentService.withDocument(
[chrome.offscreen.Reason.CLIPBOARD],
"Read text from the clipboard.",
async () => {
return await BrowserApi.sendMessageWithResponse("offscreenReadFromClipboard");
},
);
const response = await BrowserApi.sendMessageWithResponse("offscreenReadFromClipboard");
BrowserApi.closeOffscreenDocument();
if (typeof response === "string") {
return response;
}

View File

@@ -1,5 +1,7 @@
import { ToastService } from "@bitwarden/components";
import { OffscreenDocumentService } from "../../offscreen-document/abstractions/offscreen-document";
import { BrowserPlatformUtilsService } from "./browser-platform-utils.service";
export class ForegroundPlatformUtilsService extends BrowserPlatformUtilsService {
@@ -8,8 +10,9 @@ export class ForegroundPlatformUtilsService extends BrowserPlatformUtilsService
clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void,
biometricCallback: () => Promise<boolean>,
win: Window & typeof globalThis,
offscreenDocumentService: OffscreenDocumentService,
) {
super(clipboardWriteCallback, biometricCallback, win);
super(clipboardWriteCallback, biometricCallback, win, offscreenDocumentService);
}
override showToast(

View File

@@ -26,6 +26,7 @@ import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.comp
import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
import { PremiumComponent } from "../billing/popup/settings/premium.component";
import BrowserPopupUtils from "../platform/popup/browser-popup-utils";
import { GeneratorComponent } from "../tools/popup/generator/generator.component";
import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.component";
@@ -51,7 +52,6 @@ import { ExcludedDomainsComponent } from "./settings/excluded-domains.component"
import { FoldersComponent } from "./settings/folders.component";
import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component";
import { OptionsComponent } from "./settings/options.component";
import { PremiumComponent } from "./settings/premium.component";
import { SettingsComponent } from "./settings/settings.component";
import { SyncComponent } from "./settings/sync.component";
import { TabsComponent } from "./tabs.component";

View File

@@ -35,6 +35,7 @@ import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.comp
import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
import { PremiumComponent } from "../billing/popup/settings/premium.component";
import { HeaderComponent } from "../platform/popup/header.component";
import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component";
@@ -70,14 +71,12 @@ import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.
import { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component";
import { PopOutComponent } from "./components/pop-out.component";
import { PrivateModeWarningComponent } from "./components/private-mode-warning.component";
import { UserVerificationComponent } from "./components/user-verification.component";
import { ServicesModule } from "./services/services.module";
import { ExcludedDomainsComponent } from "./settings/excluded-domains.component";
import { FoldersComponent } from "./settings/folders.component";
import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component";
import { OptionsComponent } from "./settings/options.component";
import { PremiumComponent } from "./settings/premium.component";
import { SettingsComponent } from "./settings/settings.component";
import { SyncComponent } from "./settings/sync.component";
import { VaultTimeoutInputComponent } from "./settings/vault-timeout-input.component";
@@ -150,7 +149,6 @@ import "../platform/popup/locales";
PasswordHistoryComponent,
PopOutComponent,
PremiumComponent,
PrivateModeWarningComponent,
RegisterComponent,
SendAddEditComponent,
SendGroupingsComponent,

View File

@@ -1,6 +0,0 @@
<app-callout class="app-private-mode-warning" type="warning" *ngIf="showWarning">
{{ "privateModeWarning" | i18n }}
<a href="https://bitwarden.com/help/article/private-mode/" target="_blank" rel="noreferrer">{{
"learnMore" | i18n
}}</a>
</app-callout>

View File

@@ -1,15 +0,0 @@
import { Component, OnInit } from "@angular/core";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
@Component({
selector: "app-private-mode-warning",
templateUrl: "private-mode-warning.component.html",
})
export class PrivateModeWarningComponent implements OnInit {
showWarning = false;
ngOnInit() {
this.showWarning = BrowserPopupUtils.inPrivateMode();
}
}

View File

@@ -111,11 +111,6 @@ app-home {
}
}
.app-private-mode-warning {
display: block;
padding-top: 1rem;
}
body.body-sm,
body.body-xs {
app-home {

View File

@@ -1,22 +0,0 @@
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SearchService } from "@bitwarden/common/services/search.service";
export class PopupSearchService extends SearchService {
constructor(logService: LogService, i18nService: I18nService, stateProvider: StateProvider) {
super(logService, i18nService, stateProvider);
}
clearIndex(): Promise<void> {
throw new Error("Not available.");
}
indexCiphers(): Promise<void> {
throw new Error("Not available.");
}
async getIndexForSearch() {
return await super.getIndexForSearch();
}
}

View File

@@ -19,7 +19,6 @@ import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -28,7 +27,9 @@ import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/a
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@@ -53,6 +54,7 @@ import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@@ -61,6 +63,7 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
@@ -96,10 +99,13 @@ import { runInsideAngular } from "../../platform/browser/run-inside-angular.oper
/* eslint-disable no-restricted-imports */
import { ChromeMessageSender } from "../../platform/messaging/chrome-message.sender";
/* eslint-enable no-restricted-imports */
import { OffscreenDocumentService } from "../../platform/offscreen-document/abstractions/offscreen-document";
import { DefaultOffscreenDocumentService } from "../../platform/offscreen-document/offscreen-document.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service";
import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
import { BrowserCryptoService } from "../../platform/services/browser-crypto.service";
import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service";
@@ -118,20 +124,18 @@ import { VaultFilterService } from "../../vault/services/vault-filter.service";
import { DebounceNavigationService } from "./debounce-navigation.service";
import { InitService } from "./init.service";
import { PopupCloseWarningService } from "./popup-close-warning.service";
import { PopupSearchService } from "./popup-search.service";
const OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE = new SafeInjectionToken<
AbstractStorageService & ObservableStorageService
>("OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE");
const needsBackgroundInit = BrowserPopupUtils.backgroundInitializationRequired();
const isPrivateMode = BrowserPopupUtils.inPrivateMode();
const mainBackground: MainBackground = needsBackgroundInit
? createLocalBgService()
: BrowserApi.getBackgroundPage().bitwardenMain;
function createLocalBgService() {
const localBgService = new MainBackground(isPrivateMode, true);
const localBgService = new MainBackground(true);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
localBgService.bootstrap();
@@ -176,26 +180,11 @@ const safeProviders: SafeProvider[] = [
useFactory: getBgService<SsoLoginServiceAbstraction>("ssoLoginService"),
deps: [],
}),
safeProvider({
provide: SearchServiceAbstraction,
useClass: PopupSearchService,
deps: [LogService, I18nServiceAbstraction, StateProvider],
}),
safeProvider({
provide: CipherService,
useFactory: getBgService<CipherService>("cipherService"),
deps: [],
}),
safeProvider({
provide: CryptoFunctionService,
useFactory: () => new WebCryptoFunctionService(window),
deps: [],
}),
safeProvider({
provide: CollectionService,
useFactory: getBgService<CollectionService>("collectionService"),
deps: [],
}),
safeProvider({
provide: LogService,
useFactory: (platformUtilsService: PlatformUtilsService) =>
@@ -220,12 +209,48 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: CryptoService,
useFactory: (encryptService: EncryptService) => {
const cryptoService = getBgService<CryptoService>("cryptoService")();
useFactory: (
masterPasswordService: InternalMasterPasswordServiceAbstraction,
keyGenerationService: KeyGenerationService,
cryptoFunctionService: CryptoFunctionService,
encryptService: EncryptService,
platformUtilsService: PlatformUtilsService,
logService: LogService,
stateService: StateServiceAbstraction,
accountService: AccountServiceAbstraction,
stateProvider: StateProvider,
biometricStateService: BiometricStateService,
kdfConfigService: KdfConfigService,
) => {
const cryptoService = new BrowserCryptoService(
masterPasswordService,
keyGenerationService,
cryptoFunctionService,
encryptService,
platformUtilsService,
logService,
stateService,
accountService,
stateProvider,
biometricStateService,
kdfConfigService,
);
new ContainerService(cryptoService, encryptService).attachToGlobal(self);
return cryptoService;
},
deps: [EncryptService],
deps: [
InternalMasterPasswordServiceAbstraction,
KeyGenerationService,
CryptoFunctionService,
EncryptService,
PlatformUtilsService,
LogService,
StateServiceAbstraction,
AccountServiceAbstraction,
StateProvider,
BiometricStateService,
KdfConfigService,
],
}),
safeProvider({
provide: TotpServiceAbstraction,
@@ -247,9 +272,17 @@ const safeProviders: SafeProvider[] = [
useFactory: getBgService<DevicesServiceAbstraction>("devicesService"),
deps: [],
}),
safeProvider({
provide: OffscreenDocumentService,
useClass: DefaultOffscreenDocumentService,
deps: [],
}),
safeProvider({
provide: PlatformUtilsService,
useFactory: (toastService: ToastService) => {
useFactory: (
toastService: ToastService,
offscreenDocumentService: OffscreenDocumentService,
) => {
return new ForegroundPlatformUtilsService(
toastService,
(clipboardValue: string, clearMs: number) => {
@@ -266,9 +299,10 @@ const safeProviders: SafeProvider[] = [
return response.result;
},
window,
offscreenDocumentService,
);
},
deps: [ToastService],
deps: [ToastService, OffscreenDocumentService],
}),
safeProvider({
provide: PasswordGenerationServiceAbstraction,
@@ -312,7 +346,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: ScriptInjectorService,
useClass: BrowserScriptInjectorService,
deps: [],
deps: [PlatformUtilsService, LogService],
}),
safeProvider({
provide: KeyConnectorService,

View File

@@ -5,6 +5,8 @@ import { PolicyService } from "@bitwarden/common/admin-console/services/policy/p
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { Importer, ImportResult, ImportServiceAbstraction } from "@bitwarden/importer/core";
@@ -38,10 +40,12 @@ describe("FilelessImporterBackground ", () => {
const notificationBackground = mock<NotificationBackground>();
const importService = mock<ImportServiceAbstraction>();
const syncService = mock<SyncService>();
const platformUtilsService = mock<PlatformUtilsService>();
const logService = mock<LogService>();
let scriptInjectorService: BrowserScriptInjectorService;
beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService();
scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
filelessImporterBackground = new FilelessImporterBackground(
configService,
authService,

View File

@@ -70,13 +70,13 @@ export class Fido2Background implements Fido2BackgroundInterface {
*/
async injectFido2ContentScriptsInAllTabs() {
const tabs = await BrowserApi.tabsQuery({});
for (let index = 0; index < tabs.length; index++) {
const tab = tabs[index];
if (!tab.url?.startsWith("https")) {
continue;
}
void this.injectFido2ContentScripts(tab);
if (tab.url?.startsWith("https")) {
void this.injectFido2ContentScripts(tab);
}
}
}

View File

@@ -15,17 +15,43 @@ jest.mock("../../../autofill/utils", () => ({
}),
}));
const originalGlobalThis = globalThis;
const mockGlobalThisDocument = {
...originalGlobalThis.document,
contentType: "text/html",
location: {
...originalGlobalThis.document.location,
href: "https://localhost",
origin: "https://localhost",
protocol: "https:",
},
};
describe("Fido2 Content Script", () => {
beforeAll(() => {
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(
() => mockGlobalThisDocument,
);
});
afterEach(() => {
jest.resetModules();
});
afterAll(() => {
jest.clearAllMocks();
});
let messenger: Messenger;
const messengerForDOMCommunicationSpy = jest
.spyOn(Messenger, "forDOMCommunication")
.mockImplementation((window) => {
const windowOrigin = window.location.origin;
.mockImplementation((context) => {
const windowOrigin = context.location.origin;
messenger = new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => window.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener),
postMessage: (message, port) => context.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => context.addEventListener("message", listener),
removeEventListener: (listener) => context.removeEventListener("message", listener),
});
messenger.destroy = jest.fn();
return messenger;
@@ -33,16 +59,6 @@ describe("Fido2 Content Script", () => {
const portSpy: MockProxy<chrome.runtime.Port> = createPortSpyMock(Fido2PortName.InjectedScript);
chrome.runtime.connect = jest.fn(() => portSpy);
afterEach(() => {
Object.defineProperty(document, "contentType", {
value: "text/html",
writable: true,
});
jest.clearAllMocks();
jest.resetModules();
});
it("destroys the messenger when the port is disconnected", () => {
require("./content-script");
@@ -151,11 +167,31 @@ describe("Fido2 Content Script", () => {
await expect(result).rejects.toEqual(errorMessage);
});
it("skips initializing the content script if the document content type is not 'text/html'", () => {
Object.defineProperty(document, "contentType", {
value: "application/json",
writable: true,
});
it("skips initializing if the document content type is not 'text/html'", () => {
jest.clearAllMocks();
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(() => ({
...mockGlobalThisDocument,
contentType: "application/json",
}));
require("./content-script");
expect(messengerForDOMCommunicationSpy).not.toHaveBeenCalled();
});
it("skips initializing if the document location protocol is not 'https'", () => {
jest.clearAllMocks();
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(() => ({
...mockGlobalThisDocument,
location: {
...mockGlobalThisDocument.location,
href: "http://localhost",
origin: "http://localhost",
protocol: "http:",
},
}));
require("./content-script");

View File

@@ -15,7 +15,11 @@ import {
import { MessageWithMetadata, Messenger } from "./messaging/messenger";
(function (globalContext) {
if (globalContext.document.contentType !== "text/html") {
const shouldExecuteContentScript =
globalContext.document.contentType === "text/html" &&
globalContext.document.location.protocol === "https:";
if (!shouldExecuteContentScript) {
return;
}

View File

@@ -6,9 +6,14 @@ import { MessageType } from "./messaging/message";
import { Messenger } from "./messaging/messenger";
(function (globalContext) {
if (globalContext.document.contentType !== "text/html") {
const shouldExecuteContentScript =
globalContext.document.contentType === "text/html" &&
globalContext.document.location.protocol === "https:";
if (!shouldExecuteContentScript) {
return;
}
const BrowserPublicKeyCredential = globalContext.PublicKeyCredential;
const BrowserNavigatorCredentials = navigator.credentials;
const BrowserAuthenticatorAttestationResponse = globalContext.AuthenticatorAttestationResponse;

View File

@@ -10,17 +10,29 @@ import { WebauthnUtils } from "../webauthn-utils";
import { MessageType } from "./messaging/message";
import { Messenger } from "./messaging/messenger";
const originalGlobalThis = globalThis;
const mockGlobalThisDocument = {
...originalGlobalThis.document,
contentType: "text/html",
location: {
...originalGlobalThis.document.location,
href: "https://localhost",
origin: "https://localhost",
protocol: "https:",
},
};
let messenger: Messenger;
jest.mock("./messaging/messenger", () => {
return {
Messenger: class extends jest.requireActual("./messaging/messenger").Messenger {
static forDOMCommunication: any = jest.fn((window) => {
const windowOrigin = window.location.origin;
static forDOMCommunication: any = jest.fn((context) => {
const windowOrigin = context.location.origin;
messenger = new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => window.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener),
postMessage: (message, port) => context.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => context.addEventListener("message", listener),
removeEventListener: (listener) => context.removeEventListener("message", listener),
});
messenger.destroy = jest.fn();
return messenger;
@@ -31,6 +43,10 @@ jest.mock("./messaging/messenger", () => {
jest.mock("../webauthn-utils");
describe("Fido2 page script with native WebAuthn support", () => {
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(
() => mockGlobalThisDocument,
);
const mockCredentialCreationOptions = createCredentialCreationOptionsMock();
const mockCreateCredentialsResult = createCreateCredentialResultMock();
const mockCredentialRequestOptions = createCredentialRequestOptionsMock();
@@ -39,9 +55,12 @@ describe("Fido2 page script with native WebAuthn support", () => {
require("./page-script");
afterEach(() => {
jest.resetModules();
});
afterAll(() => {
jest.clearAllMocks();
jest.resetModules();
});
describe("creating WebAuthn credentials", () => {
@@ -118,4 +137,42 @@ describe("Fido2 page script with native WebAuthn support", () => {
expect(messenger.destroy).toHaveBeenCalled();
});
});
describe("content script execution", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
it("skips initializing if the document content type is not 'text/html'", () => {
jest.spyOn(Messenger, "forDOMCommunication");
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(() => ({
...mockGlobalThisDocument,
contentType: "json/application",
}));
require("./content-script");
expect(Messenger.forDOMCommunication).not.toHaveBeenCalled();
});
it("skips initializing if the document location protocol is not 'https'", () => {
jest.spyOn(Messenger, "forDOMCommunication");
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(() => ({
...mockGlobalThisDocument,
location: {
...mockGlobalThisDocument.location,
href: "http://localhost",
origin: "http://localhost",
protocol: "http:",
},
}));
require("./content-script");
expect(Messenger.forDOMCommunication).not.toHaveBeenCalled();
});
});
});

View File

@@ -9,17 +9,29 @@ import { WebauthnUtils } from "../webauthn-utils";
import { MessageType } from "./messaging/message";
import { Messenger } from "./messaging/messenger";
const originalGlobalThis = globalThis;
const mockGlobalThisDocument = {
...originalGlobalThis.document,
contentType: "text/html",
location: {
...originalGlobalThis.document.location,
href: "https://localhost",
origin: "https://localhost",
protocol: "https:",
},
};
let messenger: Messenger;
jest.mock("./messaging/messenger", () => {
return {
Messenger: class extends jest.requireActual("./messaging/messenger").Messenger {
static forDOMCommunication: any = jest.fn((window) => {
const windowOrigin = window.location.origin;
static forDOMCommunication: any = jest.fn((context) => {
const windowOrigin = context.location.origin;
messenger = new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => window.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener),
postMessage: (message, port) => context.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => context.addEventListener("message", listener),
removeEventListener: (listener) => context.removeEventListener("message", listener),
});
messenger.destroy = jest.fn();
return messenger;
@@ -30,15 +42,22 @@ jest.mock("./messaging/messenger", () => {
jest.mock("../webauthn-utils");
describe("Fido2 page script without native WebAuthn support", () => {
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(
() => mockGlobalThisDocument,
);
const mockCredentialCreationOptions = createCredentialCreationOptionsMock();
const mockCreateCredentialsResult = createCreateCredentialResultMock();
const mockCredentialRequestOptions = createCredentialRequestOptionsMock();
const mockCredentialAssertResult = createAssertCredentialResultMock();
require("./page-script");
afterEach(() => {
jest.resetModules();
});
afterAll(() => {
jest.clearAllMocks();
jest.resetModules();
});
describe("creating WebAuthn credentials", () => {

View File

@@ -292,8 +292,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
const ciphers = await this.cipherService.getAllDecryptedForUrl(
this.url,
otherTypes.length > 0 ? otherTypes : null,
null,
false,
);
this.loginCiphers = [];

View File

@@ -611,7 +611,6 @@ export class Main {
this.cipherService,
this.folderService,
this.collectionService,
this.cryptoService,
this.platformUtilsService,
this.messagingService,
this.searchService,

View File

@@ -146,6 +146,8 @@ export class NativeMessagingMain {
allowed_origins: [
// Chrome extension
"chrome-extension://nngceckbapebfimnlniiiahkandclblb/",
// Chrome beta extension
"chrome-extension://hccnnhgbibccigepcmlgppchkpfdophk/",
// Edge extension
"chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh/",
// Opera extension

View File

@@ -50,7 +50,12 @@
</bit-tab>
<bit-tab label="{{ 'collections' | i18n }}">
<p>{{ "editGroupCollectionsDesc" | i18n }}</p>
<p>
{{ "editGroupCollectionsDesc" | i18n }}
<span *ngIf="!(allowAdminAccessToAllCollectionItems$ | async)">
{{ "editGroupCollectionsRestrictionsDesc" | i18n }}
</span>
</p>
<div *ngIf="!(flexibleCollectionsEnabled$ | async)" class="tw-my-3">
<input type="checkbox" formControlName="accessAll" id="accessAll" />
<label class="tw-mb-0 tw-text-lg" for="accessAll">{{

View File

@@ -11,13 +11,13 @@ import {
of,
shareReplay,
Subject,
switchMap,
takeUntil,
} from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
@@ -26,12 +26,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { CollectionData } from "@bitwarden/common/vault/models/data/collection.data";
import { Collection } from "@bitwarden/common/vault/models/domain/collection";
import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response";
import { DialogService } from "@bitwarden/components";
import { CollectionAdminService } from "../../../vault/core/collection-admin.service";
import { CollectionAdminView } from "../../../vault/core/views/collection-admin.view";
import { InternalGroupService as GroupService, GroupView } from "../core";
import {
AccessItemType,
@@ -95,9 +93,15 @@ export const openGroupAddEditDialog = (
templateUrl: "group-add-edit.component.html",
})
export class GroupAddEditComponent implements OnInit, OnDestroy {
protected flexibleCollectionsEnabled$ = this.organizationService
private organization$ = this.organizationService
.get$(this.organizationId)
.pipe(map((o) => o?.flexibleCollections));
.pipe(shareReplay({ refCount: true }));
protected flexibleCollectionsEnabled$ = this.organization$.pipe(
map((o) => o?.flexibleCollections),
);
private flexibleCollectionsV1Enabled$ = this.configService.getFeatureFlag$(
FeatureFlag.FlexibleCollectionsV1,
);
protected PermissionMode = PermissionMode;
protected ResultType = GroupAddEditDialogResultType;
@@ -131,27 +135,9 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private get orgCollections$() {
return from(this.apiService.getCollections(this.organizationId)).pipe(
switchMap((response) => {
return from(
this.collectionService.decryptMany(
response.data.map(
(r) => new Collection(new CollectionData(r as CollectionDetailsResponse)),
),
),
);
}),
map((collections) =>
collections.map<AccessItemView>((c) => ({
id: c.id,
type: AccessItemType.Collection,
labelName: c.name,
listName: c.name,
})),
),
);
}
private orgCollections$ = from(this.collectionAdminService.getAll(this.organizationId)).pipe(
shareReplay({ refCount: true, bufferSize: 1 }),
);
private get orgMembers$(): Observable<Array<AccessItemView & { userId: UserId }>> {
return from(this.organizationUserService.getAllUsers(this.organizationId)).pipe(
@@ -197,23 +183,24 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
shareReplay({ refCount: true, bufferSize: 1 }),
);
restrictGroupAccess$ = combineLatest([
this.organizationService.get$(this.organizationId),
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1),
this.groupDetails$,
allowAdminAccessToAllCollectionItems$ = combineLatest([
this.organization$,
this.flexibleCollectionsV1Enabled$,
]).pipe(
map(
([organization, flexibleCollectionsV1Enabled, group]) =>
// Feature flag conditionals
flexibleCollectionsV1Enabled &&
organization.flexibleCollections &&
// Business logic conditionals
!organization.allowAdminAccessToAllCollectionItems &&
group !== undefined,
),
shareReplay({ refCount: true, bufferSize: 1 }),
map(([organization, flexibleCollectionsV1Enabled]) => {
if (!flexibleCollectionsV1Enabled || !organization.flexibleCollections) {
return true;
}
return organization.allowAdminAccessToAllCollectionItems;
}),
);
restrictGroupAccess$ = combineLatest([
this.allowAdminAccessToAllCollectionItems$,
this.groupDetails$,
]).pipe(map(([allowAdminAccess, groupDetails]) => !allowAdminAccess && groupDetails != null));
constructor(
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
private dialogRef: DialogRef<GroupAddEditDialogResultType>,
@@ -221,7 +208,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private organizationUserService: OrganizationUserService,
private groupService: GroupService,
private i18nService: I18nService,
private collectionService: CollectionService,
private platformUtilsService: PlatformUtilsService,
private logService: LogService,
private formBuilder: FormBuilder,
@@ -230,6 +216,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private organizationService: OrganizationService,
private configService: ConfigService,
private accountService: AccountService,
private collectionAdminService: CollectionAdminService,
) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
}
@@ -244,48 +231,61 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.groupDetails$,
this.restrictGroupAccess$,
this.accountService.activeAccount$,
this.organization$,
this.flexibleCollectionsV1Enabled$,
])
.pipe(takeUntil(this.destroy$))
.subscribe(([collections, members, group, restrictGroupAccess, activeAccount]) => {
this.collections = collections;
this.members = members;
this.group = group;
if (this.group != undefined) {
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
// collections/members set above, otherwise no selected values will be patched below
this.changeDetectorRef.detectChanges();
this.groupForm.patchValue({
name: this.group.name,
externalId: this.group.externalId,
accessAll: this.group.accessAll,
members: this.group.members.map((m) => ({
id: m,
type: AccessItemType.Member,
})),
collections: this.group.collections.map((gc) => ({
id: gc.id,
type: AccessItemType.Collection,
permission: convertToPermission(gc),
})),
});
}
// If the current user is not already in the group and cannot add themselves, remove them from the list
if (restrictGroupAccess) {
const organizationUserId = this.members.find((m) => m.userId === activeAccount.id).id;
const isAlreadyInGroup = this.groupForm.value.members.some(
(m) => m.id === organizationUserId,
.subscribe(
([
collections,
members,
group,
restrictGroupAccess,
activeAccount,
organization,
flexibleCollectionsV1Enabled,
]) => {
this.members = members;
this.group = group;
this.collections = mapToAccessItemViews(
collections,
organization,
flexibleCollectionsV1Enabled,
group,
);
if (!isAlreadyInGroup) {
this.members = this.members.filter((m) => m.id !== organizationUserId);
}
}
if (this.group != undefined) {
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
// collections/members set above, otherwise no selected values will be patched below
this.changeDetectorRef.detectChanges();
this.loading = false;
});
this.groupForm.patchValue({
name: this.group.name,
externalId: this.group.externalId,
accessAll: this.group.accessAll,
members: this.group.members.map((m) => ({
id: m,
type: AccessItemType.Member,
})),
collections: mapToAccessSelections(group, this.collections),
});
}
// If the current user is not already in the group and cannot add themselves, remove them from the list
if (restrictGroupAccess) {
const organizationUserId = this.members.find((m) => m.userId === activeAccount.id).id;
const isAlreadyInGroup = this.groupForm.value.members.some(
(m) => m.id === organizationUserId,
);
if (!isAlreadyInGroup) {
this.members = this.members.filter((m) => m.id !== organizationUserId);
}
}
this.loading = false;
},
);
}
ngOnDestroy() {
@@ -355,3 +355,46 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.dialogRef.close(GroupAddEditDialogResultType.Deleted);
};
}
/**
* Maps the group's current collection access to AccessItemValues to populate the access-selector's FormControl
*/
function mapToAccessSelections(group: GroupView, items: AccessItemView[]): AccessItemValue[] {
return (
group.collections
// The FormControl value only represents editable collection access - exclude readonly access selections
.filter((selection) => !items.find((item) => item.id == selection.id).readonly)
.map((gc) => ({
id: gc.id,
type: AccessItemType.Collection,
permission: convertToPermission(gc),
}))
);
}
/**
* Maps the organization's collections to AccessItemViews to populate the access-selector's multi-select
*/
function mapToAccessItemViews(
collections: CollectionAdminView[],
organization: Organization,
flexibleCollectionsV1Enabled: boolean,
group?: GroupView,
): AccessItemView[] {
return (
collections
.map<AccessItemView>((c) => {
const accessSelection = group?.collections.find((access) => access.id == c.id) ?? undefined;
return {
id: c.id,
type: AccessItemType.Collection,
labelName: c.name,
listName: c.name,
readonly: !c.canEditGroupAccess(organization, flexibleCollectionsV1Enabled),
readonlyPermission: accessSelection ? convertToPermission(accessSelection) : undefined,
};
})
// Remove any collection views that are not already assigned and that we don't have permissions to assign access to
.filter((item) => !item.readonly || group?.collections.some((access) => access.id == item.id))
);
}

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute } from "@angular/router";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -27,11 +28,20 @@ export class ExposedPasswordsReportComponent extends BaseExposedPasswordsReportC
organizationService: OrganizationService,
private route: ActivatedRoute,
passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) {
super(cipherService, auditService, organizationService, modalService, passwordRepromptService);
super(
cipherService,
auditService,
organizationService,
modalService,
passwordRepromptService,
i18nService,
);
}
async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId);

View File

@@ -3,6 +3,7 @@ import { ActivatedRoute } from "@angular/router";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -24,11 +25,20 @@ export class InactiveTwoFactorReportComponent extends BaseInactiveTwoFactorRepor
logService: LogService,
passwordRepromptService: PasswordRepromptService,
organizationService: OrganizationService,
i18nService: I18nService,
) {
super(cipherService, organizationService, modalService, logService, passwordRepromptService);
super(
cipherService,
organizationService,
modalService,
logService,
passwordRepromptService,
i18nService,
);
}
async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId);

View File

@@ -3,6 +3,7 @@ import { ActivatedRoute } from "@angular/router";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -25,11 +26,13 @@ export class ReusedPasswordsReportComponent extends BaseReusedPasswordsReportCom
private route: ActivatedRoute,
organizationService: OrganizationService,
passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) {
super(cipherService, organizationService, modalService, passwordRepromptService);
super(cipherService, organizationService, modalService, passwordRepromptService, i18nService);
}
async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId);

View File

@@ -3,6 +3,7 @@ import { ActivatedRoute } from "@angular/router";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault";
@@ -22,11 +23,13 @@ export class UnsecuredWebsitesReportComponent extends BaseUnsecuredWebsitesRepor
private route: ActivatedRoute,
organizationService: OrganizationService,
passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) {
super(cipherService, organizationService, modalService, passwordRepromptService);
super(cipherService, organizationService, modalService, passwordRepromptService, i18nService);
}
async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId);

View File

@@ -3,6 +3,7 @@ import { ActivatedRoute } from "@angular/router";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -27,6 +28,7 @@ export class WeakPasswordsReportComponent extends BaseWeakPasswordsReportCompone
private route: ActivatedRoute,
organizationService: OrganizationService,
passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) {
super(
cipherService,
@@ -34,10 +36,12 @@ export class WeakPasswordsReportComponent extends BaseWeakPasswordsReportCompone
organizationService,
modalService,
passwordRepromptService,
i18nService,
);
}
async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId);

View File

@@ -21,9 +21,10 @@
ariaCurrentWhenActive="page"
>
<i class="bwi {{ product.icon }} tw-text-4xl !tw-m-0 !tw-mb-1"></i>
<span class="tw-text-center tw-text-sm tw-leading-snug group-hover:tw-underline">{{
product.name
}}</span>
<span
class="tw-max-w-24 tw-text-center tw-text-sm tw-leading-snug group-hover:tw-underline"
>{{ product.name }}</span
>
</a>
</section>

View File

@@ -1,12 +1,13 @@
import { Component, ViewChild } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, concatMap } from "rxjs";
import { ActivatedRoute, ParamMap, Router } from "@angular/router";
import { combineLatest, concatMap, map } from "rxjs";
import {
canAccessOrgAdmin,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { MenuComponent } from "@bitwarden/components";
type ProductSwitcherItem = {
@@ -48,6 +49,13 @@ export class ProductSwitcherContentComponent {
this.organizationService.organizations$,
this.route.paramMap,
]).pipe(
map(([orgs, paramMap]): [Organization[], ParamMap] => {
return [
// Sort orgs by name to match the order within the sidebar
orgs.sort((a, b) => a.name.localeCompare(b.name)),
paramMap,
];
}),
concatMap(async ([orgs, paramMap]) => {
const routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId"));
// If the active route org doesn't have access to SM, find the first org that does.
@@ -89,7 +97,7 @@ export class ProductSwitcherContentComponent {
},
ac: {
name: "Admin Console",
icon: "bwi-business",
icon: "bwi-user-monitor",
appRoute: ["/organizations", acOrg?.id],
marketingRoute: "https://bitwarden.com/products/business/",
isActive: this.router.url.includes("/organizations/"),

View File

@@ -1,9 +1,11 @@
import { Directive, ViewChild, ViewContainerRef } from "@angular/core";
import { Observable } from "rxjs";
import { Directive, ViewChild, ViewContainerRef, OnDestroy } from "@angular/core";
import { BehaviorSubject, Observable, Subject, takeUntil } from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault";
@@ -12,27 +14,111 @@ import { AddEditComponent } from "../../../vault/individual-vault/add-edit.compo
import { AddEditComponent as OrgAddEditComponent } from "../../../vault/org-vault/add-edit.component";
@Directive()
export class CipherReportComponent {
export class CipherReportComponent implements OnDestroy {
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
cipherAddEditModalRef: ViewContainerRef;
isAdminConsoleActive = false;
loading = false;
hasLoaded = false;
ciphers: CipherView[] = [];
allCiphers: CipherView[] = [];
organization: Organization;
organizations: Organization[];
organizations$: Observable<Organization[]>;
filterStatus: any = [0];
showFilterToggle: boolean = false;
vaultMsg: string = "vault";
currentFilterStatus: number | string;
protected filterOrgStatus$ = new BehaviorSubject<number | string>(0);
private destroyed$: Subject<void> = new Subject();
constructor(
protected cipherService: CipherService,
private modalService: ModalService,
protected passwordRepromptService: PasswordRepromptService,
protected organizationService: OrganizationService,
protected i18nService: I18nService,
) {
this.organizations$ = this.organizationService.organizations$;
this.organizations$.pipe(takeUntil(this.destroyed$)).subscribe((orgs) => {
this.organizations = orgs;
});
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
getName(filterId: string | number) {
let orgName: any;
if (filterId === 0) {
orgName = this.i18nService.t("all");
} else if (filterId === 1) {
orgName = this.i18nService.t("me");
} else {
this.organizations.filter((org: Organization) => {
if (org.id === filterId) {
orgName = org.name;
return org;
}
});
}
return orgName;
}
getCount(filterId: string | number) {
let orgFilterStatus: any;
let cipherCount;
if (filterId === 0) {
cipherCount = this.allCiphers.length;
} else if (filterId === 1) {
cipherCount = this.allCiphers.filter((c: any) => c.orgFilterStatus === null).length;
} else {
this.organizations.filter((org: Organization) => {
if (org.id === filterId) {
orgFilterStatus = org.id;
return org;
}
});
cipherCount = this.allCiphers.filter(
(c: any) => c.orgFilterStatus === orgFilterStatus,
).length;
}
return cipherCount;
}
async filterOrgToggle(status: any) {
this.currentFilterStatus = status;
await this.setCiphers();
if (status === 0) {
return;
} else if (status === 1) {
this.ciphers = this.ciphers.filter((c: any) => c.orgFilterStatus == null);
} else {
this.ciphers = this.ciphers.filter((c: any) => c.orgFilterStatus === status);
}
}
async load() {
this.loading = true;
await this.setCiphers();
// when a user fixes an item in a report we want to persist the filter they had
// if they fix the last item of that filter we will go back to the "All" filter
if (this.currentFilterStatus) {
if (this.ciphers.length > 2) {
this.filterOrgStatus$.next(this.currentFilterStatus);
await this.filterOrgToggle(this.currentFilterStatus);
} else {
this.filterOrgStatus$.next(0);
await this.filterOrgToggle(0);
}
} else {
await this.setCiphers();
}
this.loading = false;
this.hasLoaded = true;
}
@@ -76,7 +162,7 @@ export class CipherReportComponent {
}
protected async setCiphers() {
this.ciphers = [];
this.allCiphers = [];
}
protected async repromptCipher(c: CipherView) {
@@ -85,4 +171,32 @@ export class CipherReportComponent {
(await this.passwordRepromptService.showPasswordPrompt())
);
}
protected async getAllCiphers(): Promise<CipherView[]> {
return await this.cipherService.getAllDecrypted();
}
protected filterCiphersByOrg(ciphersList: CipherView[]) {
this.allCiphers = [...ciphersList];
this.ciphers = ciphersList.map((ciph: any) => {
ciph.orgFilterStatus = ciph.organizationId;
if (this.filterStatus.indexOf(ciph.organizationId) === -1 && ciph.organizationId != null) {
this.filterStatus.push(ciph.organizationId);
} else if (this.filterStatus.indexOf(1) === -1 && ciph.organizationId == null) {
this.filterStatus.splice(1, 0, 1);
}
return ciph;
});
if (this.filterStatus.length > 2) {
this.showFilterToggle = true;
this.vaultMsg = "vaults";
} else {
// If a user fixes an item and there is only one item left remove the filter toggle and change the vault message to singular
this.showFilterToggle = false;
this.vaultMsg = "vault";
}
}
}

View File

@@ -11,9 +11,32 @@
</app-callout>
<ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'exposedPasswordsFound' | i18n }}" [useAlertRole]="true">
{{ "exposedPasswordsFoundDesc" | i18n: (ciphers.length | number) }}
{{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout>
<bit-toggle-group
*ngIf="showFilterToggle && !isAdminConsoleActive"
[selected]="filterOrgStatus$ | async"
(selectedChange)="filterOrgToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<ng-container *ngFor="let status of filterStatus">
<bit-toggle [value]="status">
{{ getName(status) }}
<span bitBadge variant="info"> {{ getCount(status) }} </span>
</bit-toggle>
</ng-container>
</bit-toggle-group>
<table class="table table-hover table-list table-ciphers">
<thead
class="tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-font-bold tw-text-muted"
*ngIf="!isAdminConsoleActive"
>
<tr>
<th></th>
<th>{{ "name" | i18n }}</th>
<th>{{ "owner" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of ciphers">
<td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -17,9 +18,12 @@ describe("ExposedPasswordsReportComponent", () => {
let component: ExposedPasswordsReportComponent;
let fixture: ComponentFixture<ExposedPasswordsReportComponent>;
let auditService: MockProxy<AuditService>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => {
auditService = mock<AuditService>();
organizationService = mock<OrganizationService>();
organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -35,7 +39,7 @@ describe("ExposedPasswordsReportComponent", () => {
},
{
provide: OrganizationService,
useValue: mock<OrganizationService>(),
useValue: organizationService,
},
{
provide: ModalService,

View File

@@ -3,6 +3,7 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -24,8 +25,9 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
protected organizationService: OrganizationService,
modalService: ModalService,
passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) {
super(modalService, passwordRepromptService, organizationService);
super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -36,7 +38,9 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
const allCiphers = await this.getAllCiphers();
const exposedPasswordCiphers: CipherView[] = [];
const promises: Promise<void>[] = [];
allCiphers.forEach((ciph) => {
this.filterStatus = [0];
allCiphers.forEach((ciph: any) => {
const { type, login, isDeleted, edit, viewPassword, id } = ciph;
if (
type !== CipherType.Login ||
@@ -48,6 +52,7 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
) {
return;
}
const promise = this.auditService.passwordLeaked(login.password).then((exposedCount) => {
if (exposedCount > 0) {
exposedPasswordCiphers.push(ciph);
@@ -57,11 +62,8 @@ export class ExposedPasswordsReportComponent extends CipherReportComponent imple
promises.push(promise);
});
await Promise.all(promises);
this.ciphers = [...exposedPasswordCiphers];
}
protected getAllCiphers(): Promise<CipherView[]> {
return this.cipherService.getAllDecrypted();
this.filterCiphersByOrg(exposedPasswordCiphers);
}
protected canManageCipher(c: CipherView): boolean {

View File

@@ -16,9 +16,32 @@
</app-callout>
<ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'inactive2faFound' | i18n }}">
{{ "inactive2faFoundDesc" | i18n: (ciphers.length | number) }}
{{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout>
<bit-toggle-group
*ngIf="showFilterToggle && !isAdminConsoleActive"
[selected]="filterOrgStatus$ | async"
(selectedChange)="filterOrgToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<ng-container *ngFor="let status of filterStatus">
<bit-toggle [value]="status">
{{ getName(status) }}
<span bitBadge variant="info"> {{ getCount(status) }} </span>
</bit-toggle>
</ng-container>
</bit-toggle-group>
<table class="table table-hover table-list table-ciphers">
<thead
class="tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-font-bold tw-text-muted"
*ngIf="!isAdminConsoleActive"
>
<tr>
<th></th>
<th>{{ "name" | i18n }}</th>
<th>{{ "owner" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of ciphers">
<td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -16,8 +17,11 @@ import { cipherData } from "./reports-ciphers.mock";
describe("InactiveTwoFactorReportComponent", () => {
let component: InactiveTwoFactorReportComponent;
let fixture: ComponentFixture<InactiveTwoFactorReportComponent>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => {
organizationService = mock<OrganizationService>();
organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -29,7 +33,7 @@ describe("InactiveTwoFactorReportComponent", () => {
},
{
provide: OrganizationService,
useValue: mock<OrganizationService>(),
useValue: organizationService,
},
{
provide: ModalService,

View File

@@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -26,8 +27,9 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
modalService: ModalService,
private logService: LogService,
passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) {
super(modalService, passwordRepromptService, organizationService);
super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -45,6 +47,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
const allCiphers = await this.getAllCiphers();
const inactive2faCiphers: CipherView[] = [];
const docs = new Map<string, string>();
this.filterStatus = [0];
allCiphers.forEach((ciph) => {
const { type, login, isDeleted, edit, id, viewPassword } = ciph;
@@ -58,6 +61,7 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
) {
return;
}
for (let i = 0; i < login.uris.length; i++) {
const u = login.uris[i];
if (u.uri != null && u.uri !== "") {
@@ -75,15 +79,12 @@ export class InactiveTwoFactorReportComponent extends CipherReportComponent impl
}
}
});
this.ciphers = [...inactive2faCiphers];
this.filterCiphersByOrg(inactive2faCiphers);
this.cipherDocs = docs;
}
}
protected getAllCiphers(): Promise<CipherView[]> {
return this.cipherService.getAllDecrypted();
}
private async load2fa() {
if (this.services.size > 0) {
return;

View File

@@ -16,9 +16,34 @@
</app-callout>
<ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'reusedPasswordsFound' | i18n }}">
{{ "reusedPasswordsFoundDesc" | i18n: (ciphers.length | number) }}
{{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout>
<bit-toggle-group
*ngIf="showFilterToggle && !isAdminConsoleActive"
[selected]="filterOrgStatus$ | async"
(selectedChange)="filterOrgToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<ng-container *ngFor="let status of filterStatus">
<bit-toggle [value]="status">
{{ getName(status) }}
<span bitBadge variant="info"> {{ getCount(status) }} </span>
</bit-toggle>
</ng-container>
</bit-toggle-group>
<table class="table table-hover table-list table-ciphers">
<thead
class="tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-font-bold tw-text-muted"
*ngIf="!isAdminConsoleActive"
>
<tr>
<th></th>
<th>{{ "name" | i18n }}</th>
<th>{{ "owner" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of ciphers">
<td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -15,8 +16,11 @@ import { ReusedPasswordsReportComponent } from "./reused-passwords-report.compon
describe("ReusedPasswordsReportComponent", () => {
let component: ReusedPasswordsReportComponent;
let fixture: ComponentFixture<ReusedPasswordsReportComponent>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => {
organizationService = mock<OrganizationService>();
organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -28,7 +32,7 @@ describe("ReusedPasswordsReportComponent", () => {
},
{
provide: OrganizationService,
useValue: mock<OrganizationService>(),
useValue: organizationService,
},
{
provide: ModalService,

View File

@@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -22,8 +23,9 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
protected organizationService: OrganizationService,
modalService: ModalService,
passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) {
super(modalService, passwordRepromptService, organizationService);
super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -34,6 +36,8 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
const allCiphers = await this.getAllCiphers();
const ciphersWithPasswords: CipherView[] = [];
this.passwordUseMap = new Map<string, number>();
this.filterStatus = [0];
allCiphers.forEach((ciph) => {
const { type, login, isDeleted, edit, viewPassword } = ciph;
if (
@@ -46,6 +50,7 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
) {
return;
}
ciphersWithPasswords.push(ciph);
if (this.passwordUseMap.has(login.password)) {
this.passwordUseMap.set(login.password, this.passwordUseMap.get(login.password) + 1);
@@ -57,11 +62,8 @@ export class ReusedPasswordsReportComponent extends CipherReportComponent implem
(c) =>
this.passwordUseMap.has(c.login.password) && this.passwordUseMap.get(c.login.password) > 1,
);
this.ciphers = reusedPasswordCiphers;
}
protected getAllCiphers(): Promise<CipherView[]> {
return this.cipherService.getAllDecrypted();
this.filterCiphersByOrg(reusedPasswordCiphers);
}
protected canManageCipher(c: CipherView): boolean {

View File

@@ -16,9 +16,33 @@
</app-callout>
<ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'unsecuredWebsitesFound' | i18n }}">
{{ "unsecuredWebsitesFoundDesc" | i18n: (ciphers.length | number) }}
{{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout>
<bit-toggle-group
*ngIf="showFilterToggle && !isAdminConsoleActive"
[selected]="filterOrgStatus$ | async"
(selectedChange)="filterOrgToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<ng-container *ngFor="let status of filterStatus">
<bit-toggle [value]="status">
{{ getName(status) }}
<span bitBadge variant="info"> {{ getCount(status) }} </span>
</bit-toggle>
</ng-container>
</bit-toggle-group>
<table class="table table-hover table-list table-ciphers">
<thead
class="tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-font-bold tw-text-muted"
*ngIf="!isAdminConsoleActive"
>
<tr>
<th></th>
<th>{{ "name" | i18n }}</th>
<th>{{ "owner" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of ciphers">
<td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { MockProxy, mock } from "jest-mock-extended";
import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -15,8 +16,11 @@ import { UnsecuredWebsitesReportComponent } from "./unsecured-websites-report.co
describe("UnsecuredWebsitesReportComponent", () => {
let component: UnsecuredWebsitesReportComponent;
let fixture: ComponentFixture<UnsecuredWebsitesReportComponent>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => {
organizationService = mock<OrganizationService>();
organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -28,7 +32,7 @@ describe("UnsecuredWebsitesReportComponent", () => {
},
{
provide: OrganizationService,
useValue: mock<OrganizationService>(),
useValue: organizationService,
},
{
provide: ModalService,

View File

@@ -2,9 +2,9 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault";
import { CipherReportComponent } from "./cipher-report.component";
@@ -21,8 +21,9 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl
protected organizationService: OrganizationService,
modalService: ModalService,
passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) {
super(modalService, passwordRepromptService, organizationService);
super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -31,18 +32,15 @@ export class UnsecuredWebsitesReportComponent extends CipherReportComponent impl
async setCiphers() {
const allCiphers = await this.getAllCiphers();
this.filterStatus = [0];
const unsecuredCiphers = allCiphers.filter((c) => {
if (c.type !== CipherType.Login || !c.login.hasUris || c.isDeleted) {
return false;
}
return c.login.uris.some((u) => u.uri != null && u.uri.indexOf("http://") === 0);
});
this.ciphers = unsecuredCiphers.filter(
(c) => (!this.organization && c.edit) || (this.organization && !c.edit),
);
}
protected getAllCiphers(): Promise<CipherView[]> {
return this.cipherService.getAllDecrypted();
return c.login.uris.some((u: any) => u.uri != null && u.uri.indexOf("http://") === 0);
});
this.filterCiphersByOrg(unsecuredCiphers);
}
}

View File

@@ -16,9 +16,32 @@
</app-callout>
<ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'weakPasswordsFound' | i18n }}">
{{ "weakPasswordsFoundDesc" | i18n: (ciphers.length | number) }}
{{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout>
<bit-toggle-group
*ngIf="showFilterToggle && !isAdminConsoleActive"
[selected]="filterOrgStatus$ | async"
(selectedChange)="filterOrgToggle($event)"
[attr.aria-label]="'addAccessFilter' | i18n"
>
<ng-container *ngFor="let status of filterStatus">
<bit-toggle [value]="status">
{{ getName(status) }}
<span bitBadge variant="info"> {{ getCount(status) }} </span>
</bit-toggle>
</ng-container>
</bit-toggle-group>
<table class="table table-hover table-list table-ciphers">
<thead
class="tw-border-0 tw-border-b-2 tw-border-solid tw-border-secondary-300 tw-font-bold tw-text-muted"
*ngIf="!isAdminConsoleActive"
>
<tr>
<th></th>
<th>{{ "name" | i18n }}</th>
<th>{{ "owner" | i18n }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of ciphers">
<td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -17,9 +18,12 @@ describe("WeakPasswordsReportComponent", () => {
let component: WeakPasswordsReportComponent;
let fixture: ComponentFixture<WeakPasswordsReportComponent>;
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => {
passwordStrengthService = mock<PasswordStrengthServiceAbstraction>();
organizationService = mock<OrganizationService>();
organizationService.organizations$ = of([]);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({
@@ -35,7 +39,7 @@ describe("WeakPasswordsReportComponent", () => {
},
{
provide: OrganizationService,
useValue: mock<OrganizationService>(),
useValue: organizationService,
},
{
provide: ModalService,

View File

@@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -29,8 +30,9 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
protected organizationService: OrganizationService,
modalService: ModalService,
passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) {
super(modalService, passwordRepromptService, organizationService);
super(cipherService, modalService, passwordRepromptService, organizationService, i18nService);
}
async ngOnInit() {
@@ -38,7 +40,10 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
}
async setCiphers() {
const allCiphers = await this.getAllCiphers();
const allCiphers: any = await this.getAllCiphers();
this.passwordStrengthCache = new Map<string, number>();
this.weakPasswordCiphers = [];
this.filterStatus = [0];
this.findWeakPasswords(allCiphers);
}
@@ -55,6 +60,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
) {
return;
}
const hasUserName = this.isUserNameNotEmpty(ciph);
const cacheKey = this.getCacheKey(ciph);
if (!this.passwordStrengthCache.has(cacheKey)) {
@@ -87,6 +93,7 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
this.passwordStrengthCache.set(cacheKey, result.score);
}
const score = this.passwordStrengthCache.get(cacheKey);
if (score != null && score <= 2) {
this.passwordStrengthMap.set(id, this.scoreKey(score));
this.weakPasswordCiphers.push(ciph);
@@ -98,11 +105,8 @@ export class WeakPasswordsReportComponent extends CipherReportComponent implemen
this.passwordStrengthCache.get(this.getCacheKey(b))
);
});
this.ciphers = [...this.weakPasswordCiphers];
}
protected getAllCiphers(): Promise<CipherView[]> {
return this.cipherService.getAllDecrypted();
this.filterCiphersByOrg(this.weakPasswordCiphers);
}
protected canManageCipher(c: CipherView): boolean {

View File

@@ -32,7 +32,6 @@ export class VaultItemsComponent {
@Input() showCollections: boolean;
@Input() showGroups: boolean;
@Input() useEvents: boolean;
@Input() cloneableOrganizationCiphers: boolean;
@Input() showPremiumFeatures: boolean;
@Input() showBulkMove: boolean;
@Input() showBulkTrashOptions: boolean;
@@ -160,10 +159,27 @@ export class VaultItemsComponent {
}
protected canClone(vaultItem: VaultItem) {
return (
(vaultItem.cipher.organizationId && this.cloneableOrganizationCiphers) ||
vaultItem.cipher.organizationId == null
);
if (vaultItem.cipher.organizationId == null) {
return true;
}
const org = this.allOrganizations.find((o) => o.id === vaultItem.cipher.organizationId);
// Admins and custom users can always clone in the Org Vault
if (this.viewingOrgVault && (org.isAdmin || org.permissions.editAnyCollection)) {
return true;
}
// Check if the cipher belongs to a collection with canManage permission
const orgCollections = this.allCollections.filter((c) => c.organizationId === org.id);
for (const collection of orgCollections) {
if (vaultItem.cipher.collectionIds.includes(collection.id) && collection.manage) {
return true;
}
}
return false;
}
private refreshItems() {

View File

@@ -51,6 +51,13 @@ export class CollectionAdminView extends CollectionView {
* Whether the user can modify user access to this collection
*/
canEditUserAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.canManageUsers;
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageUsers;
}
/**
* Whether the user can modify group access to this collection
*/
canEditGroupAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean {
return this.canEdit(org, flexibleCollectionsV1Enabled) || org.permissions.manageGroups;
}
}

View File

@@ -47,7 +47,6 @@
[showBulkMove]="showBulkMove"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="false"
[cloneableOrganizationCiphers]="false"
[showAdminActions]="false"
(onEvent)="onVaultItemsEvent($event)"
[flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled$ | async"

View File

@@ -81,22 +81,6 @@ export class AddEditComponent extends BaseAddEditComponent {
);
}
protected allowOwnershipAssignment() {
if (
this.ownershipOptions != null &&
(this.ownershipOptions.length > 1 || !this.allowPersonal)
) {
if (this.organization != null) {
return (
this.cloneMode && this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)
);
} else {
return !this.editMode || this.cloneMode;
}
}
return false;
}
protected loadCollections() {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) {
return super.loadCollections();

View File

@@ -48,7 +48,6 @@
[showBulkMove]="false"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="organization?.useEvents"
[cloneableOrganizationCiphers]="true"
[showAdminActions]="true"
(onEvent)="onVaultItemsEvent($event)"
[showBulkEditCollectionAccess]="organization?.flexibleCollections"

View File

@@ -1809,12 +1809,16 @@
"unsecuredWebsitesFound": {
"message": "Unsecured websites found"
},
"unsecuredWebsitesFoundDesc": {
"message": "We found $COUNT$ items in your vault with unsecured URIs. You should change their URI scheme to https:// if the website allows it.",
"unsecuredWebsitesFoundReportDesc": {
"message": "We found $COUNT$ items in your $VAULT$ with unsecured URIs. You should change their URI scheme to https:// if the website allows it.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -1830,12 +1834,16 @@
"inactive2faFound": {
"message": "Logins without two-step login found"
},
"inactive2faFoundDesc": {
"message": "We found $COUNT$ website(s) in your vault that may not be configured with two-step login (according to 2fa.directory). To further protect these accounts, you should set up two-step login.",
"inactive2faFoundReportDesc": {
"message": "We found $COUNT$ website(s) in your $VAULT$ that may not be configured with two-step login (according to 2fa.directory). To further protect these accounts, you should set up two-step login.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -1854,12 +1862,16 @@
"exposedPasswordsFound": {
"message": "Exposed passwords found"
},
"exposedPasswordsFoundDesc": {
"message": "We found $COUNT$ items in your vault that have passwords that were exposed in known data breaches. You should change them to use a new password.",
"exposedPasswordsFoundReportDesc": {
"message": "We found $COUNT$ items in your $VAULT$ that have passwords that were exposed in known data breaches. You should change them to use a new password.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -1887,12 +1899,16 @@
"weakPasswordsFound": {
"message": "Weak passwords found"
},
"weakPasswordsFoundDesc": {
"message": "We found $COUNT$ items in your vault with passwords that are not strong. You should update them to use stronger passwords.",
"weakPasswordsFoundReportDesc": {
"message": "We found $COUNT$ items in your $VAULT$ with passwords that are not strong. You should update them to use stronger passwords.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -1908,12 +1924,16 @@
"reusedPasswordsFound": {
"message": "Reused passwords found"
},
"reusedPasswordsFoundDesc": {
"message": "We found $COUNT$ passwords that are being reused in your vault. You should change them to a unique value.",
"reusedPasswordsFoundReportDesc": {
"message": "We found $COUNT$ passwords that are being reused in your $VAULT$. You should change them to a unique value.",
"placeholders": {
"count": {
"content": "$1",
"example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
}
}
},
@@ -6460,6 +6480,9 @@
"editGroupCollectionsDesc": {
"message": "Grant access to collections by adding them to this group."
},
"editGroupCollectionsRestrictionsDesc": {
"message": "You can only assign collections you manage."
},
"accessAllCollectionsDesc": {
"message": "Grant access to all current and future collections."
},