[PM-5189] Merging main and fixing conflicts

This commit is contained in:
Cesar Gonzalez 2024-05-02 16:31:29 -05:00
commit 9a8d6d38da
No known key found for this signature in database
GPG Key ID: 3381A5457F8CCECF
108 changed files with 2198 additions and 941 deletions

1
.github/CODEOWNERS vendored
View File

@ -57,6 +57,7 @@ libs/common/src/admin-console @bitwarden/team-admin-console-dev
libs/admin-console @bitwarden/team-admin-console-dev
## Billing team files ##
apps/browser/src/billing @bitwarden/team-billing-dev
apps/web/src/app/billing @bitwarden/team-billing-dev
libs/angular/src/billing @bitwarden/team-billing-dev
libs/common/src/billing @bitwarden/team-billing-dev

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."
},

View File

@ -1,5 +1,5 @@
import { Component } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { ActivatedRoute, Router } from "@angular/router";
import { UserVerificationDialogComponent } from "@bitwarden/auth/angular";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@ -42,6 +42,7 @@ export class AccountComponent {
private dialogService: DialogService,
private configService: ConfigService,
private providerApiService: ProviderApiServiceAbstraction,
private router: Router,
) {}
async ngOnInit() {
@ -93,9 +94,8 @@ export class AccountComponent {
return;
}
this.formPromise = this.providerApiService.deleteProvider(this.providerId);
try {
await this.formPromise;
await this.providerApiService.deleteProvider(this.providerId);
this.platformUtilsService.showToast(
"success",
this.i18nService.t("providerDeleted"),
@ -104,7 +104,8 @@ export class AccountComponent {
} catch (e) {
this.logService.error(e);
}
this.formPromise = null;
await this.router.navigate(["/"]);
}
private async verifyUser(): Promise<boolean> {

View File

@ -58,3 +58,16 @@ export class ServiceAccountPeopleAccessPoliciesView {
userAccessPolicies: UserServiceAccountAccessPolicyView[];
groupAccessPolicies: GroupServiceAccountAccessPolicyView[];
}
export class ServiceAccountProjectPolicyPermissionDetailsView {
accessPolicy: ServiceAccountProjectAccessPolicyView;
hasPermission: boolean;
}
export class ServiceAccountGrantedPoliciesView {
grantedProjectPolicies: ServiceAccountProjectPolicyPermissionDetailsView[];
}
export class ProjectServiceAccountsAccessPoliciesView {
serviceAccountAccessPolicies: ServiceAccountProjectAccessPolicyView[];
}

View File

@ -1,17 +1,27 @@
<div class="tw-w-2/5">
<p class="tw-mt-8">
{{ "projectMachineAccountsDescription" | i18n }}
</p>
<sm-access-selector
[rows]="rows$ | async"
granteeType="serviceAccounts"
[label]="'machineAccounts' | i18n"
[hint]="'projectMachineAccountsSelectHint' | i18n"
[columnTitle]="'machineAccounts' | i18n"
[emptyMessage]="'projectEmptyMachineAccountAccessPolicies' | i18n"
(onCreateAccessPolicies)="handleCreateAccessPolicies($event)"
(onDeleteAccessPolicy)="handleDeleteAccessPolicy($event)"
(onUpdateAccessPolicy)="handleUpdateAccessPolicy($event)"
>
</sm-access-selector>
</div>
<form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
<div class="tw-w-2/5">
<p class="tw-mt-8" *ngIf="!loading">
{{ "projectMachineAccountsDescription" | i18n }}
</p>
<sm-access-policy-selector
[loading]="loading"
formControlName="accessPolicies"
[addButtonMode]="true"
[items]="items"
[label]="'machineAccounts' | i18n"
[hint]="'projectMachineAccountsSelectHint' | i18n"
[columnTitle]="'machineAccounts' | i18n"
[emptyMessage]="'projectEmptyMachineAccountAccessPolicies' | i18n"
>
</sm-access-policy-selector>
<button bitButton buttonType="primary" bitFormButton type="submit" class="tw-mt-7">
{{ "save" | i18n }}
</button>
</div>
</form>
<ng-template #spinner>
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>

View File

@ -1,93 +1,69 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { map, Observable, startWith, Subject, switchMap, takeUntil } from "rxjs";
import { combineLatest, Subject, switchMap, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SelectItemView } from "@bitwarden/components";
import { ProjectServiceAccountsAccessPoliciesView } from "../../models/view/access-policy.view";
import {
ProjectAccessPoliciesView,
ServiceAccountProjectAccessPolicyView,
} from "../../models/view/access-policy.view";
ApItemValueType,
convertToProjectServiceAccountsAccessPoliciesView,
} from "../../shared/access-policies/access-policy-selector/models/ap-item-value.type";
import {
ApItemViewType,
convertPotentialGranteesToApItemViewType,
convertProjectServiceAccountsViewToApItemViews,
} from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
import {
AccessSelectorComponent,
AccessSelectorRowView,
} from "../../shared/access-policies/access-selector.component";
@Component({
selector: "sm-project-service-accounts",
templateUrl: "./project-service-accounts.component.html",
})
export class ProjectServiceAccountsComponent implements OnInit, OnDestroy {
private currentAccessPolicies: ApItemViewType[];
private destroy$ = new Subject<void>();
private organizationId: string;
private projectId: string;
protected rows$: Observable<AccessSelectorRowView[]> =
this.accessPolicyService.projectAccessPolicyChanges$.pipe(
startWith(null),
switchMap(() =>
this.accessPolicyService.getProjectAccessPolicies(this.organizationId, this.projectId),
),
map((policies) =>
policies.serviceAccountAccessPolicies.map((policy) => ({
type: "serviceAccount",
name: policy.serviceAccountName,
id: policy.serviceAccountId,
accessPolicyId: policy.id,
read: policy.read,
write: policy.write,
icon: AccessSelectorComponent.serviceAccountIcon,
static: false,
})),
),
);
private currentAccessPolicies$ = combineLatest([this.route.params]).pipe(
switchMap(([params]) =>
this.accessPolicyService
.getProjectServiceAccountsAccessPolicies(params.organizationId, params.projectId)
.then((policies) => {
return convertProjectServiceAccountsViewToApItemViews(policies);
}),
),
);
protected async handleUpdateAccessPolicy(policy: AccessSelectorRowView) {
try {
return await this.accessPolicyService.updateAccessPolicy(
AccessSelectorComponent.getBaseAccessPolicyView(policy),
);
} catch (e) {
this.validationService.showError(e);
}
}
private potentialGrantees$ = combineLatest([this.route.params]).pipe(
switchMap(([params]) =>
this.accessPolicyService
.getServiceAccountsPotentialGrantees(params.organizationId)
.then((grantees) => {
return convertPotentialGranteesToApItemViewType(grantees);
}),
),
);
protected handleCreateAccessPolicies(selected: SelectItemView[]) {
const projectAccessPoliciesView = new ProjectAccessPoliciesView();
projectAccessPoliciesView.serviceAccountAccessPolicies = selected
.filter(
(selection) => AccessSelectorComponent.getAccessItemType(selection) === "serviceAccount",
)
.map((filtered) => {
const view = new ServiceAccountProjectAccessPolicyView();
view.grantedProjectId = this.projectId;
view.serviceAccountId = filtered.id;
view.read = true;
view.write = false;
return view;
});
protected formGroup = new FormGroup({
accessPolicies: new FormControl([] as ApItemValueType[]),
});
return this.accessPolicyService.createProjectAccessPolicies(
this.organizationId,
this.projectId,
projectAccessPoliciesView,
);
}
protected async handleDeleteAccessPolicy(policy: AccessSelectorRowView) {
try {
await this.accessPolicyService.deleteAccessPolicy(policy.accessPolicyId);
} catch (e) {
this.validationService.showError(e);
}
}
protected loading = true;
protected potentialGrantees: ApItemViewType[];
protected items: ApItemViewType[];
constructor(
private route: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private validationService: ValidationService,
private accessPolicyService: AccessPolicyService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {}
ngOnInit(): void {
@ -95,10 +71,97 @@ export class ProjectServiceAccountsComponent implements OnInit, OnDestroy {
this.organizationId = params.organizationId;
this.projectId = params.projectId;
});
combineLatest([this.potentialGrantees$, this.currentAccessPolicies$])
.pipe(takeUntil(this.destroy$))
.subscribe(([potentialGrantees, currentAccessPolicies]) => {
this.potentialGrantees = potentialGrantees;
this.items = this.getItems(potentialGrantees, currentAccessPolicies);
this.setSelected(currentAccessPolicies);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
submit = async () => {
if (this.isFormInvalid()) {
return;
}
const formValues = this.formGroup.value.accessPolicies;
this.formGroup.disable();
try {
const accessPoliciesView = await this.updateProjectServiceAccountsAccessPolicies(
this.organizationId,
this.projectId,
formValues,
);
const updatedView = convertProjectServiceAccountsViewToApItemViews(accessPoliciesView);
this.items = this.getItems(this.potentialGrantees, updatedView);
this.setSelected(updatedView);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("projectAccessUpdated"),
);
} catch (e) {
this.validationService.showError(e);
this.setSelected(this.currentAccessPolicies);
}
this.formGroup.enable();
};
private setSelected(policiesToSelect: ApItemViewType[]) {
this.loading = true;
this.currentAccessPolicies = policiesToSelect;
if (policiesToSelect != undefined) {
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
// potentialGrantees, otherwise no selected values will be patched below
this.changeDetectorRef.detectChanges();
this.formGroup.patchValue({
accessPolicies: policiesToSelect.map((m) => ({
type: m.type,
id: m.id,
permission: m.permission,
})),
});
}
this.loading = false;
}
private isFormInvalid(): boolean {
this.formGroup.markAllAsTouched();
return this.formGroup.invalid;
}
private async updateProjectServiceAccountsAccessPolicies(
organizationId: string,
projectId: string,
selectedPolicies: ApItemValueType[],
): Promise<ProjectServiceAccountsAccessPoliciesView> {
const view = convertToProjectServiceAccountsAccessPoliciesView(projectId, selectedPolicies);
return await this.accessPolicyService.putProjectServiceAccountsAccessPolicies(
organizationId,
projectId,
view,
);
}
private getItems(potentialGrantees: ApItemViewType[], currentAccessPolicies: ApItemViewType[]) {
// If the user doesn't have access to the service account, they won't be in the potentialGrantees list.
// Add them to the potentialGrantees list if they are selected.
const items = [...potentialGrantees];
for (const policy of currentAccessPolicies) {
const exists = potentialGrantees.some((grantee) => grantee.id === policy.id);
if (!exists) {
items.push(policy);
}
}
return items;
}
}

View File

@ -1,17 +1,27 @@
<div class="tw-mt-4 tw-w-2/5">
<p class="tw-mt-6">
{{ "machineAccountProjectsDescription" | i18n }}
</p>
<sm-access-selector
[rows]="rows$ | async"
granteeType="projects"
[label]="'projects' | i18n"
[hint]="'newSaSelectAccess' | i18n"
[columnTitle]="'projects' | i18n"
[emptyMessage]="'serviceAccountEmptyProjectAccesspolicies' | i18n"
(onCreateAccessPolicies)="handleCreateAccessPolicies($event)"
(onDeleteAccessPolicy)="handleDeleteAccessPolicy($event)"
(onUpdateAccessPolicy)="handleUpdateAccessPolicy($event)"
>
</sm-access-selector>
</div>
<form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
<div class="tw-w-2/5">
<p class="tw-mt-8" *ngIf="!loading">
{{ "machineAccountProjectsDescription" | i18n }}
</p>
<sm-access-policy-selector
[loading]="loading"
formControlName="accessPolicies"
[addButtonMode]="true"
[items]="potentialGrantees"
[label]="'projects' | i18n"
[hint]="'newSaSelectAccess' | i18n"
[columnTitle]="'projects' | i18n"
[emptyMessage]="'serviceAccountEmptyProjectAccesspolicies' | i18n"
>
</sm-access-policy-selector>
<button bitButton buttonType="primary" bitFormButton type="submit" class="tw-mt-7">
{{ "save" | i18n }}
</button>
</div>
</form>
<ng-template #spinner>
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>

View File

@ -1,90 +1,68 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute } from "@angular/router";
import { combineLatestWith, map, Observable, startWith, Subject, switchMap, takeUntil } from "rxjs";
import { combineLatest, Subject, switchMap, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
import { ServiceAccountProjectAccessPolicyView } from "../../models/view/access-policy.view";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
import { ServiceAccountGrantedPoliciesView } from "../../models/view/access-policy.view";
import {
AccessSelectorComponent,
AccessSelectorRowView,
} from "../../shared/access-policies/access-selector.component";
ApItemValueType,
convertToServiceAccountGrantedPoliciesView,
} from "../../shared/access-policies/access-policy-selector/models/ap-item-value.type";
import {
ApItemViewType,
convertPotentialGranteesToApItemViewType,
convertGrantedPoliciesToAccessPolicyItemViews,
} from "../../shared/access-policies/access-policy-selector/models/ap-item-view.type";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
@Component({
selector: "sm-service-account-projects",
templateUrl: "./service-account-projects.component.html",
})
export class ServiceAccountProjectsComponent implements OnInit, OnDestroy {
private currentAccessPolicies: ApItemViewType[];
private destroy$ = new Subject<void>();
private serviceAccountId: string;
private organizationId: string;
private serviceAccountId: string;
protected rows$: Observable<AccessSelectorRowView[]> =
this.accessPolicyService.serviceAccountGrantedPolicyChanges$.pipe(
startWith(null),
combineLatestWith(this.route.params),
switchMap(([_, params]) =>
this.accessPolicyService.getGrantedPolicies(params.serviceAccountId, params.organizationId),
),
map((policies) => {
return policies.map((policy) => {
return {
type: "project",
name: policy.grantedProjectName,
id: policy.grantedProjectId,
accessPolicyId: policy.id,
read: policy.read,
write: policy.write,
icon: AccessSelectorComponent.projectIcon,
static: false,
} as AccessSelectorRowView;
});
}),
);
private currentAccessPolicies$ = combineLatest([this.route.params]).pipe(
switchMap(([params]) =>
this.accessPolicyService
.getServiceAccountGrantedPolicies(params.organizationId, params.serviceAccountId)
.then((policies) => {
return convertGrantedPoliciesToAccessPolicyItemViews(policies);
}),
),
);
protected handleCreateAccessPolicies(selected: SelectItemView[]) {
const serviceAccountProjectAccessPolicyView = selected
.filter((selection) => AccessSelectorComponent.getAccessItemType(selection) === "project")
.map((filtered) => {
const view = new ServiceAccountProjectAccessPolicyView();
view.serviceAccountId = this.serviceAccountId;
view.grantedProjectId = filtered.id;
view.read = true;
view.write = false;
return view;
});
private potentialGrantees$ = combineLatest([this.route.params]).pipe(
switchMap(([params]) =>
this.accessPolicyService
.getProjectsPotentialGrantees(params.organizationId)
.then((grantees) => {
return convertPotentialGranteesToApItemViewType(grantees);
}),
),
);
return this.accessPolicyService.createGrantedPolicies(
this.organizationId,
this.serviceAccountId,
serviceAccountProjectAccessPolicyView,
);
}
protected formGroup = new FormGroup({
accessPolicies: new FormControl([] as ApItemValueType[]),
});
protected async handleUpdateAccessPolicy(policy: AccessSelectorRowView) {
try {
return await this.accessPolicyService.updateAccessPolicy(
AccessSelectorComponent.getBaseAccessPolicyView(policy),
);
} catch (e) {
this.validationService.showError(e);
}
}
protected async handleDeleteAccessPolicy(policy: AccessSelectorRowView) {
try {
await this.accessPolicyService.deleteAccessPolicy(policy.accessPolicyId);
} catch (e) {
this.validationService.showError(e);
}
}
protected loading = true;
protected potentialGrantees: ApItemViewType[];
constructor(
private route: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private validationService: ValidationService,
private accessPolicyService: AccessPolicyService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {}
ngOnInit(): void {
@ -92,10 +70,119 @@ export class ServiceAccountProjectsComponent implements OnInit, OnDestroy {
this.organizationId = params.organizationId;
this.serviceAccountId = params.serviceAccountId;
});
combineLatest([this.potentialGrantees$, this.currentAccessPolicies$])
.pipe(takeUntil(this.destroy$))
.subscribe(([potentialGrantees, currentAccessPolicies]) => {
this.potentialGrantees = this.getPotentialGrantees(
potentialGrantees,
currentAccessPolicies,
);
this.setSelected(currentAccessPolicies);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
submit = async () => {
if (this.isFormInvalid()) {
return;
}
const formValues = this.getFormValues();
this.formGroup.disable();
try {
const grantedViews = await this.updateServiceAccountGrantedPolicies(
this.organizationId,
this.serviceAccountId,
formValues,
);
this.currentAccessPolicies = convertGrantedPoliciesToAccessPolicyItemViews(grantedViews);
this.platformUtilsService.showToast(
"success",
null,
this.i18nService.t("serviceAccountAccessUpdated"),
);
} catch (e) {
this.validationService.showError(e);
this.setSelected(this.currentAccessPolicies);
}
this.formGroup.enable();
};
private setSelected(policiesToSelect: ApItemViewType[]) {
this.loading = true;
this.currentAccessPolicies = policiesToSelect;
if (policiesToSelect != undefined) {
// Must detect changes so that AccessSelector @Inputs() are aware of the latest
// potentialGrantees, otherwise no selected values will be patched below
this.changeDetectorRef.detectChanges();
this.formGroup.patchValue({
accessPolicies: policiesToSelect.map((m) => ({
type: m.type,
id: m.id,
permission: m.permission,
readOnly: m.readOnly,
})),
});
}
this.loading = false;
}
private isFormInvalid(): boolean {
this.formGroup.markAllAsTouched();
return this.formGroup.invalid;
}
private async updateServiceAccountGrantedPolicies(
organizationId: string,
serviceAccountId: string,
selectedPolicies: ApItemValueType[],
): Promise<ServiceAccountGrantedPoliciesView> {
const grantedViews = convertToServiceAccountGrantedPoliciesView(
serviceAccountId,
selectedPolicies,
);
return await this.accessPolicyService.putServiceAccountGrantedPolicies(
organizationId,
serviceAccountId,
grantedViews,
);
}
private getPotentialGrantees(
potentialGrantees: ApItemViewType[],
currentAccessPolicies: ApItemViewType[],
) {
// If the user doesn't have access to the project, they won't be in the potentialGrantees list.
// Add them to the potentialGrantees list so they can be selected as read-only.
for (const policy of currentAccessPolicies) {
const exists = potentialGrantees.some((grantee) => grantee.id === policy.id);
if (!exists) {
potentialGrantees.push(policy);
}
}
return potentialGrantees;
}
private getFormValues(): ApItemValueType[] {
// The read-only disabled form values are not included in the formGroup value.
// Manually add them to the returned result to ensure they are included in the form submission.
let formValues = this.formGroup.value.accessPolicies;
formValues = formValues.concat(
this.currentAccessPolicies
.filter((m) => m.readOnly)
.map((m) => ({
id: m.id,
type: m.type,
permission: m.permission,
})),
);
return formValues;
}
}

View File

@ -29,14 +29,17 @@
bitRow
*ngFor="let item of selectionList.selectedItems; let i = index"
[formGroupName]="i"
[ngClass]="{ 'tw-text-muted': item.readOnly }"
>
<td bitCell class="tw-w-0 tw-pr-0">
<i class="bwi {{ item.icon }} tw-text-muted" aria-hidden="true"></i>
<i class="bwi {{ item.icon }}" aria-hidden="true"></i>
</td>
<td bitCell class="tw-max-w-sm tw-truncate">
{{ item.labelName }}
</td>
<td bitCell class="tw-max-w-sm tw-truncate">{{ item.labelName }}</td>
<td bitCell class="tw-mb-auto tw-inline-block tw-w-auto">
<select
*ngIf="!staticPermission; else static"
*ngIf="!staticPermission && !item.readOnly; else readOnly"
bitInput
formControlName="permission"
(blur)="handleBlur()"
@ -45,12 +48,20 @@
{{ p.labelId | i18n }}
</option>
</select>
<ng-template #readOnly>
<ng-container *ngIf="item.readOnly; else static">
<div class="tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap">
{{ item.permission | i18n }}
</div>
</ng-container>
</ng-template>
<ng-template #static>
<span>{{ staticPermission | i18n }}</span>
</ng-template>
</td>
<td bitCell class="tw-w-0">
<button
*ngIf="!item.readOnly"
type="button"
bitIconButton="bwi-close"
buttonType="main"

View File

@ -35,6 +35,34 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn
private notifyOnTouch: () => void;
private pauseChangeNotification: boolean;
/**
* Updates the enabled/disabled state of provided row form group based on the item's readonly state.
* If a row is enabled, it also updates the enabled/disabled state of the permission control
* based on the item's accessAllItems state and the current value of `permissionMode`.
* @param controlRow - The form group for the row to update
* @param item - The access item that is represented by the row
*/
private updateRowControlDisableState = (
controlRow: FormGroup<ControlsOf<ApItemValueType>>,
item: ApItemViewType,
) => {
// Disable entire row form group if readOnly
if (item.readOnly || this.disabled) {
controlRow.disable();
} else {
controlRow.enable();
}
};
/**
* Updates the enabled/disabled state of ALL row form groups based on each item's readonly state.
*/
private updateAllRowControlDisableStates = () => {
this.selectionList.forEachControlItem((controlRow, item) => {
this.updateRowControlDisableState(controlRow as FormGroup<ControlsOf<ApItemValueType>>, item);
});
};
/**
* The internal selection list that tracks the value of this form control / component.
* It's responsible for keeping items sorted and synced with the rendered form controls
@ -59,6 +87,9 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn
currentUserInGroup: new FormControl(currentUserInGroup),
currentUser: new FormControl(currentUser),
});
this.updateRowControlDisableState(fg, item);
return fg;
}, this._itemComparator.bind(this));
@ -100,7 +131,13 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn
set items(val: ApItemViewType[]) {
if (val != null) {
const selected = this.selectionList.formArray.getRawValue() ?? [];
let selected = this.selectionList.formArray.getRawValue() ?? [];
selected = selected.concat(
val
.filter((m) => m.readOnly)
.map((m) => ({ id: m.id, type: m.type, permission: m.permission })),
);
this.selectionList.populateItems(
val.map((m) => {
m.icon = m.icon ?? ApItemEnumUtil.itemIcon(m.type);
@ -137,6 +174,9 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn
} else {
this.formGroup.enable();
this.multiSelectFormGroup.enable();
// The enable() above automatically enables all the row controls,
// so we need to disable the readonly ones again
this.updateAllRowControlDisableStates();
}
}
@ -149,6 +189,9 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn
// Always clear the internal selection list on a new value
this.selectionList.deselectAll();
// We need to also select any read only items to appear in the table
this.selectionList.selectItems(this.items.filter((m) => m.readOnly).map((m) => m.id));
// If the new value is null, then we're done
if (selectedItems == null) {
this.pauseChangeNotification = false;

View File

@ -347,6 +347,7 @@ function createApItemViewType(options: Partial<ApItemViewType> = {}) {
labelName: options?.labelName ?? "test",
type: options?.type ?? ApItemEnum.User,
permission: options?.permission ?? ApPermissionEnum.CanRead,
readOnly: options?.readOnly ?? false,
};
}

View File

@ -5,6 +5,10 @@ import {
ServiceAccountPeopleAccessPoliciesView,
UserServiceAccountAccessPolicyView,
GroupServiceAccountAccessPolicyView,
ServiceAccountGrantedPoliciesView,
ServiceAccountProjectPolicyPermissionDetailsView,
ServiceAccountProjectAccessPolicyView,
ProjectServiceAccountsAccessPoliciesView,
} from "../../../../models/view/access-policy.view";
import { ApItemEnum } from "./enums/ap-item.enum";
@ -76,3 +80,46 @@ export function convertToServiceAccountPeopleAccessPoliciesView(
});
return view;
}
export function convertToServiceAccountGrantedPoliciesView(
serviceAccountId: string,
selectedPolicyValues: ApItemValueType[],
): ServiceAccountGrantedPoliciesView {
const view = new ServiceAccountGrantedPoliciesView();
view.grantedProjectPolicies = selectedPolicyValues
.filter((x) => x.type == ApItemEnum.Project)
.map((filtered) => {
const detailView = new ServiceAccountProjectPolicyPermissionDetailsView();
const policyView = new ServiceAccountProjectAccessPolicyView();
policyView.serviceAccountId = serviceAccountId;
policyView.grantedProjectId = filtered.id;
policyView.read = ApPermissionEnumUtil.toRead(filtered.permission);
policyView.write = ApPermissionEnumUtil.toWrite(filtered.permission);
detailView.accessPolicy = policyView;
return detailView;
});
return view;
}
export function convertToProjectServiceAccountsAccessPoliciesView(
projectId: string,
selectedPolicyValues: ApItemValueType[],
): ProjectServiceAccountsAccessPoliciesView {
const view = new ProjectServiceAccountsAccessPoliciesView();
view.serviceAccountAccessPolicies = selectedPolicyValues
.filter((x) => x.type == ApItemEnum.ServiceAccount)
.map((filtered) => {
const policyView = new ServiceAccountProjectAccessPolicyView();
policyView.serviceAccountId = filtered.id;
policyView.grantedProjectId = projectId;
policyView.read = ApPermissionEnumUtil.toRead(filtered.permission);
policyView.write = ApPermissionEnumUtil.toWrite(filtered.permission);
return policyView;
});
return view;
}

View File

@ -3,6 +3,8 @@ import { SelectItemView } from "@bitwarden/components";
import {
ProjectPeopleAccessPoliciesView,
ServiceAccountGrantedPoliciesView,
ProjectServiceAccountsAccessPoliciesView,
ServiceAccountPeopleAccessPoliciesView,
} from "../../../../models/view/access-policy.view";
import { PotentialGranteeView } from "../../../../models/view/potential-grantee.view";
@ -13,6 +15,12 @@ import { ApPermissionEnum, ApPermissionEnumUtil } from "./enums/ap-permission.en
export type ApItemViewType = SelectItemView & {
accessPolicyId?: string;
permission?: ApPermissionEnum;
/**
* Flag that this item cannot be modified.
* This will disable the permission editor and will keep
* the item always selected.
*/
readOnly: boolean;
} & (
| {
type: ApItemEnum.User;
@ -47,6 +55,7 @@ export function convertToAccessPolicyItemViews(
permission: ApPermissionEnumUtil.toApPermissionEnum(policy.read, policy.write),
userId: policy.userId,
currentUser: policy.currentUser,
readOnly: false,
});
});
@ -60,12 +69,59 @@ export function convertToAccessPolicyItemViews(
listName: policy.groupName,
permission: ApPermissionEnumUtil.toApPermissionEnum(policy.read, policy.write),
currentUserInGroup: policy.currentUserInGroup,
readOnly: false,
});
});
return accessPolicies;
}
export function convertGrantedPoliciesToAccessPolicyItemViews(
value: ServiceAccountGrantedPoliciesView,
): ApItemViewType[] {
const accessPolicies: ApItemViewType[] = [];
value.grantedProjectPolicies.forEach((detailView) => {
accessPolicies.push({
type: ApItemEnum.Project,
icon: ApItemEnumUtil.itemIcon(ApItemEnum.Project),
id: detailView.accessPolicy.grantedProjectId,
accessPolicyId: detailView.accessPolicy.id,
labelName: detailView.accessPolicy.grantedProjectName,
listName: detailView.accessPolicy.grantedProjectName,
permission: ApPermissionEnumUtil.toApPermissionEnum(
detailView.accessPolicy.read,
detailView.accessPolicy.write,
),
readOnly: !detailView.hasPermission,
});
});
return accessPolicies;
}
export function convertProjectServiceAccountsViewToApItemViews(
value: ProjectServiceAccountsAccessPoliciesView,
): ApItemViewType[] {
const accessPolicies: ApItemViewType[] = [];
value.serviceAccountAccessPolicies.forEach((accessPolicyView) => {
accessPolicies.push({
type: ApItemEnum.ServiceAccount,
icon: ApItemEnumUtil.itemIcon(ApItemEnum.ServiceAccount),
id: accessPolicyView.serviceAccountId,
accessPolicyId: accessPolicyView.id,
labelName: accessPolicyView.serviceAccountName,
listName: accessPolicyView.serviceAccountName,
permission: ApPermissionEnumUtil.toApPermissionEnum(
accessPolicyView.read,
accessPolicyView.write,
),
readOnly: false,
});
});
return accessPolicies;
}
export function convertPotentialGranteesToApItemViewType(
grantees: PotentialGranteeView[],
): ApItemViewType[] {
@ -108,6 +164,7 @@ export function convertPotentialGranteesToApItemViewType(
listName: listName,
currentUserInGroup: granteeView.currentUserInGroup,
currentUser: granteeView.currentUser,
readOnly: false,
};
});
}

View File

@ -18,15 +18,19 @@ import {
UserProjectAccessPolicyView,
UserServiceAccountAccessPolicyView,
ServiceAccountPeopleAccessPoliciesView,
ServiceAccountGrantedPoliciesView,
ProjectServiceAccountsAccessPoliciesView,
ServiceAccountProjectPolicyPermissionDetailsView,
} from "../../models/view/access-policy.view";
import { PotentialGranteeView } from "../../models/view/potential-grantee.view";
import { AccessPoliciesCreateRequest } from "../../shared/access-policies/models/requests/access-policies-create.request";
import { PeopleAccessPoliciesRequest } from "../../shared/access-policies/models/requests/people-access-policies.request";
import { ProjectAccessPoliciesResponse } from "../../shared/access-policies/models/responses/project-access-policies.response";
import { ServiceAccountGrantedPoliciesRequest } from "../access-policies/models/requests/service-account-granted-policies.request";
import { AccessPolicyUpdateRequest } from "./models/requests/access-policy-update.request";
import { AccessPolicyRequest } from "./models/requests/access-policy.request";
import { GrantedPolicyRequest } from "./models/requests/granted-policy.request";
import { ProjectServiceAccountsAccessPoliciesRequest } from "./models/requests/project-service-accounts-access-policies.request";
import {
GroupServiceAccountAccessPolicyResponse,
UserServiceAccountAccessPolicyResponse,
@ -36,28 +40,22 @@ import {
} from "./models/responses/access-policy.response";
import { PotentialGranteeResponse } from "./models/responses/potential-grantee.response";
import { ProjectPeopleAccessPoliciesResponse } from "./models/responses/project-people-access-policies.response";
import { ProjectServiceAccountsAccessPoliciesResponse } from "./models/responses/project-service-accounts-access-policies.response";
import { ServiceAccountGrantedPoliciesPermissionDetailsResponse } from "./models/responses/service-account-granted-policies-permission-details.response";
import { ServiceAccountPeopleAccessPoliciesResponse } from "./models/responses/service-account-people-access-policies.response";
import { ServiceAccountProjectPolicyPermissionDetailsResponse } from "./models/responses/service-account-project-policy-permission-details.response";
@Injectable({
providedIn: "root",
})
export class AccessPolicyService {
private _projectAccessPolicyChanges$ = new Subject<ProjectAccessPoliciesView>();
private _serviceAccountGrantedPolicyChanges$ = new Subject<
ServiceAccountProjectAccessPolicyView[]
>();
/**
* Emits when a project access policy is created or deleted.
*/
readonly projectAccessPolicyChanges$ = this._projectAccessPolicyChanges$.asObservable();
/**
* Emits when a service account granted policy is created or deleted.
*/
readonly serviceAccountGrantedPolicyChanges$ =
this._serviceAccountGrantedPolicyChanges$.asObservable();
constructor(
private cryptoService: CryptoService,
protected apiService: ApiService,
@ -68,44 +66,6 @@ export class AccessPolicyService {
this._projectAccessPolicyChanges$.next(null);
}
async getGrantedPolicies(
serviceAccountId: string,
organizationId: string,
): Promise<ServiceAccountProjectAccessPolicyView[]> {
const r = await this.apiService.send(
"GET",
"/service-accounts/" + serviceAccountId + "/granted-policies",
null,
true,
true,
);
const results = new ListResponse(r, ServiceAccountProjectAccessPolicyResponse);
return await this.createServiceAccountProjectAccessPolicyViews(results.data, organizationId);
}
async createGrantedPolicies(
organizationId: string,
serviceAccountId: string,
policies: ServiceAccountProjectAccessPolicyView[],
): Promise<ServiceAccountProjectAccessPolicyView[]> {
const request = this.getGrantedPoliciesCreateRequest(policies);
const r = await this.apiService.send(
"POST",
"/service-accounts/" + serviceAccountId + "/granted-policies",
request,
true,
true,
);
const results = new ListResponse(r, ServiceAccountProjectAccessPolicyResponse);
const views = await this.createServiceAccountProjectAccessPolicyViews(
results.data,
organizationId,
);
this._serviceAccountGrantedPolicyChanges$.next(views);
return views;
}
async getProjectAccessPolicies(
organizationId: string,
projectId: string,
@ -184,6 +144,74 @@ export class AccessPolicyService {
return this.createServiceAccountPeopleAccessPoliciesView(results);
}
async getServiceAccountGrantedPolicies(
organizationId: string,
serviceAccountId: string,
): Promise<ServiceAccountGrantedPoliciesView> {
const r = await this.apiService.send(
"GET",
"/service-accounts/" + serviceAccountId + "/granted-policies",
null,
true,
true,
);
const result = new ServiceAccountGrantedPoliciesPermissionDetailsResponse(r);
return await this.createServiceAccountGrantedPoliciesView(result, organizationId);
}
async putServiceAccountGrantedPolicies(
organizationId: string,
serviceAccountId: string,
policies: ServiceAccountGrantedPoliciesView,
): Promise<ServiceAccountGrantedPoliciesView> {
const request = this.getServiceAccountGrantedPoliciesRequest(policies);
const r = await this.apiService.send(
"PUT",
"/service-accounts/" + serviceAccountId + "/granted-policies",
request,
true,
true,
);
const result = new ServiceAccountGrantedPoliciesPermissionDetailsResponse(r);
return await this.createServiceAccountGrantedPoliciesView(result, organizationId);
}
async getProjectServiceAccountsAccessPolicies(
organizationId: string,
projectId: string,
): Promise<ProjectServiceAccountsAccessPoliciesView> {
const r = await this.apiService.send(
"GET",
"/projects/" + projectId + "/access-policies/service-accounts",
null,
true,
true,
);
const result = new ProjectServiceAccountsAccessPoliciesResponse(r);
return await this.createProjectServiceAccountsAccessPoliciesView(result, organizationId);
}
async putProjectServiceAccountsAccessPolicies(
organizationId: string,
projectId: string,
policies: ProjectServiceAccountsAccessPoliciesView,
): Promise<ProjectServiceAccountsAccessPoliciesView> {
const request = this.getProjectServiceAccountsAccessPoliciesRequest(policies);
const r = await this.apiService.send(
"PUT",
"/projects/" + projectId + "/access-policies/service-accounts",
request,
true,
true,
);
const result = new ProjectServiceAccountsAccessPoliciesResponse(r);
return await this.createProjectServiceAccountsAccessPoliciesView(result, organizationId);
}
async createProjectAccessPolicies(
organizationId: string,
projectId: string,
@ -206,7 +234,6 @@ export class AccessPolicyService {
async deleteAccessPolicy(accessPolicyId: string): Promise<void> {
await this.apiService.send("DELETE", "/access-policies/" + accessPolicyId, null, true, false);
this._projectAccessPolicyChanges$.next(null);
this._serviceAccountGrantedPolicyChanges$.next(null);
}
async updateAccessPolicy(baseAccessPolicyView: BaseAccessPolicyView): Promise<void> {
@ -222,6 +249,170 @@ export class AccessPolicyService {
);
}
async getPeoplePotentialGrantees(organizationId: string) {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/access-policies/people/potential-grantees",
null,
true,
true,
);
const results = new ListResponse(r, PotentialGranteeResponse);
return await this.createPotentialGranteeViews(organizationId, results.data);
}
async getServiceAccountsPotentialGrantees(organizationId: string) {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/access-policies/service-accounts/potential-grantees",
null,
true,
true,
);
const results = new ListResponse(r, PotentialGranteeResponse);
return await this.createPotentialGranteeViews(organizationId, results.data);
}
async getProjectsPotentialGrantees(organizationId: string) {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/access-policies/projects/potential-grantees",
null,
true,
true,
);
const results = new ListResponse(r, PotentialGranteeResponse);
return await this.createPotentialGranteeViews(organizationId, results.data);
}
protected async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> {
return await this.cryptoService.getOrgKey(organizationId);
}
protected getAccessPolicyRequest(
granteeId: string,
view:
| UserProjectAccessPolicyView
| UserServiceAccountAccessPolicyView
| GroupProjectAccessPolicyView
| GroupServiceAccountAccessPolicyView
| ServiceAccountProjectAccessPolicyView,
) {
const request = new AccessPolicyRequest();
request.granteeId = granteeId;
request.read = view.read;
request.write = view.write;
return request;
}
protected createBaseAccessPolicyView(
response:
| UserProjectAccessPolicyResponse
| UserServiceAccountAccessPolicyResponse
| GroupProjectAccessPolicyResponse
| GroupServiceAccountAccessPolicyResponse
| ServiceAccountProjectAccessPolicyResponse,
) {
return {
id: response.id,
read: response.read,
write: response.write,
creationDate: response.creationDate,
revisionDate: response.revisionDate,
};
}
private async createPotentialGranteeViews(
organizationId: string,
results: PotentialGranteeResponse[],
): Promise<PotentialGranteeView[]> {
const orgKey = await this.getOrganizationKey(organizationId);
return await Promise.all(
results.map(async (r) => {
const view = new PotentialGranteeView();
view.id = r.id;
view.type = r.type;
view.email = r.email;
view.currentUser = r.currentUser;
view.currentUserInGroup = r.currentUserInGroup;
if (r.type === "serviceAccount" || r.type === "project") {
view.name = r.name
? await this.encryptService.decryptToUtf8(new EncString(r.name), orgKey)
: null;
} else {
view.name = r.name;
}
return view;
}),
);
}
private getServiceAccountGrantedPoliciesRequest(
policies: ServiceAccountGrantedPoliciesView,
): ServiceAccountGrantedPoliciesRequest {
const request = new ServiceAccountGrantedPoliciesRequest();
request.projectGrantedPolicyRequests = policies.grantedProjectPolicies.map((detailView) => ({
grantedId: detailView.accessPolicy.grantedProjectId,
read: detailView.accessPolicy.read,
write: detailView.accessPolicy.write,
}));
return request;
}
private getProjectServiceAccountsAccessPoliciesRequest(
policies: ProjectServiceAccountsAccessPoliciesView,
): ProjectServiceAccountsAccessPoliciesRequest {
const request = new ProjectServiceAccountsAccessPoliciesRequest();
request.serviceAccountAccessPolicyRequests = policies.serviceAccountAccessPolicies.map((ap) => {
return this.getAccessPolicyRequest(ap.serviceAccountId, ap);
});
return request;
}
private async createServiceAccountGrantedPoliciesView(
response: ServiceAccountGrantedPoliciesPermissionDetailsResponse,
organizationId: string,
): Promise<ServiceAccountGrantedPoliciesView> {
const orgKey = await this.getOrganizationKey(organizationId);
const view = new ServiceAccountGrantedPoliciesView();
view.grantedProjectPolicies =
await this.createServiceAccountProjectPolicyPermissionDetailsViews(
orgKey,
response.grantedProjectPolicies,
);
return view;
}
private async createServiceAccountProjectPolicyPermissionDetailsViews(
orgKey: SymmetricCryptoKey,
responses: ServiceAccountProjectPolicyPermissionDetailsResponse[],
): Promise<ServiceAccountProjectPolicyPermissionDetailsView[]> {
return await Promise.all(
responses.map(async (response) => {
return await this.createServiceAccountProjectPolicyPermissionDetailsView(orgKey, response);
}),
);
}
private async createServiceAccountProjectPolicyPermissionDetailsView(
orgKey: SymmetricCryptoKey,
response: ServiceAccountProjectPolicyPermissionDetailsResponse,
): Promise<ServiceAccountProjectPolicyPermissionDetailsView> {
const view = new ServiceAccountProjectPolicyPermissionDetailsView();
view.hasPermission = response.hasPermission;
view.accessPolicy = await this.createServiceAccountProjectAccessPolicyView(
orgKey,
response.accessPolicy,
);
return view;
}
private async createProjectAccessPoliciesView(
organizationId: string,
projectAccessPoliciesResponse: ProjectAccessPoliciesResponse,
@ -394,146 +585,18 @@ export class AccessPolicyService {
};
}
async getPeoplePotentialGrantees(organizationId: string) {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/access-policies/people/potential-grantees",
null,
true,
true,
);
const results = new ListResponse(r, PotentialGranteeResponse);
return await this.createPotentialGranteeViews(organizationId, results.data);
}
async getServiceAccountsPotentialGrantees(organizationId: string) {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/access-policies/service-accounts/potential-grantees",
null,
true,
true,
);
const results = new ListResponse(r, PotentialGranteeResponse);
return await this.createPotentialGranteeViews(organizationId, results.data);
}
async getProjectsPotentialGrantees(organizationId: string) {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/access-policies/projects/potential-grantees",
null,
true,
true,
);
const results = new ListResponse(r, PotentialGranteeResponse);
return await this.createPotentialGranteeViews(organizationId, results.data);
}
protected async getOrganizationKey(organizationId: string): Promise<SymmetricCryptoKey> {
return await this.cryptoService.getOrgKey(organizationId);
}
protected getAccessPolicyRequest(
granteeId: string,
view:
| UserProjectAccessPolicyView
| UserServiceAccountAccessPolicyView
| GroupProjectAccessPolicyView
| GroupServiceAccountAccessPolicyView
| ServiceAccountProjectAccessPolicyView,
) {
const request = new AccessPolicyRequest();
request.granteeId = granteeId;
request.read = view.read;
request.write = view.write;
return request;
}
protected createBaseAccessPolicyView(
response:
| UserProjectAccessPolicyResponse
| UserServiceAccountAccessPolicyResponse
| GroupProjectAccessPolicyResponse
| GroupServiceAccountAccessPolicyResponse
| ServiceAccountProjectAccessPolicyResponse,
) {
return {
id: response.id,
read: response.read,
write: response.write,
creationDate: response.creationDate,
revisionDate: response.revisionDate,
};
}
private async createPotentialGranteeViews(
private async createProjectServiceAccountsAccessPoliciesView(
response: ProjectServiceAccountsAccessPoliciesResponse,
organizationId: string,
results: PotentialGranteeResponse[],
): Promise<PotentialGranteeView[]> {
): Promise<ProjectServiceAccountsAccessPoliciesView> {
const orgKey = await this.getOrganizationKey(organizationId);
return await Promise.all(
results.map(async (r) => {
const view = new PotentialGranteeView();
view.id = r.id;
view.type = r.type;
view.email = r.email;
view.currentUser = r.currentUser;
view.currentUserInGroup = r.currentUserInGroup;
if (r.type === "serviceAccount" || r.type === "project") {
view.name = r.name
? await this.encryptService.decryptToUtf8(new EncString(r.name), orgKey)
: null;
} else {
view.name = r.name;
}
return view;
}),
);
}
private getGrantedPoliciesCreateRequest(
policies: ServiceAccountProjectAccessPolicyView[],
): GrantedPolicyRequest[] {
return policies.map((ap) => {
const request = new GrantedPolicyRequest();
request.grantedId = ap.grantedProjectId;
request.read = ap.read;
request.write = ap.write;
return request;
});
}
private async createServiceAccountProjectAccessPolicyViews(
responses: ServiceAccountProjectAccessPolicyResponse[],
organizationId: string,
): Promise<ServiceAccountProjectAccessPolicyView[]> {
const orgKey = await this.getOrganizationKey(organizationId);
return await Promise.all(
responses.map(async (response: ServiceAccountProjectAccessPolicyResponse) => {
const view = new ServiceAccountProjectAccessPolicyView();
view.id = response.id;
view.read = response.read;
view.write = response.write;
view.creationDate = response.creationDate;
view.revisionDate = response.revisionDate;
view.serviceAccountId = response.serviceAccountId;
view.grantedProjectId = response.grantedProjectId;
view.serviceAccountName = response.serviceAccountName
? await this.encryptService.decryptToUtf8(
new EncString(response.serviceAccountName),
orgKey,
)
: null;
view.grantedProjectName = response.grantedProjectName
? await this.encryptService.decryptToUtf8(
new EncString(response.grantedProjectName),
orgKey,
)
: null;
return view;
const view = new ProjectServiceAccountsAccessPoliciesView();
view.serviceAccountAccessPolicies = await Promise.all(
response.serviceAccountAccessPolicies.map(async (ap) => {
return await this.createServiceAccountProjectAccessPolicyView(orgKey, ap);
}),
);
return view;
}
}

View File

@ -0,0 +1,5 @@
import { AccessPolicyRequest } from "./access-policy.request";
export class ProjectServiceAccountsAccessPoliciesRequest {
serviceAccountAccessPolicyRequests?: AccessPolicyRequest[];
}

View File

@ -0,0 +1,5 @@
import { GrantedPolicyRequest } from "./granted-policy.request";
export class ServiceAccountGrantedPoliciesRequest {
projectGrantedPolicyRequests?: GrantedPolicyRequest[];
}

View File

@ -0,0 +1,15 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { ServiceAccountProjectAccessPolicyResponse } from "./access-policy.response";
export class ProjectServiceAccountsAccessPoliciesResponse extends BaseResponse {
serviceAccountAccessPolicies: ServiceAccountProjectAccessPolicyResponse[];
constructor(response: any) {
super(response);
const serviceAccountAccessPolicies = this.getResponseProperty("ServiceAccountAccessPolicies");
this.serviceAccountAccessPolicies = serviceAccountAccessPolicies.map(
(k: any) => new ServiceAccountProjectAccessPolicyResponse(k),
);
}
}

View File

@ -0,0 +1,15 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { ServiceAccountProjectPolicyPermissionDetailsResponse } from "./service-account-project-policy-permission-details.response";
export class ServiceAccountGrantedPoliciesPermissionDetailsResponse extends BaseResponse {
grantedProjectPolicies: ServiceAccountProjectPolicyPermissionDetailsResponse[];
constructor(response: any) {
super(response);
const grantedProjectPolicies = this.getResponseProperty("GrantedProjectPolicies");
this.grantedProjectPolicies = grantedProjectPolicies.map(
(k: any) => new ServiceAccountProjectPolicyPermissionDetailsResponse(k),
);
}
}

View File

@ -0,0 +1,14 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { ServiceAccountProjectAccessPolicyResponse } from "./access-policy.response";
export class ServiceAccountProjectPolicyPermissionDetailsResponse extends BaseResponse {
accessPolicy: ServiceAccountProjectAccessPolicyResponse;
hasPermission: boolean;
constructor(response: any) {
super(response);
this.accessPolicy = this.getResponseProperty("AccessPolicy");
this.hasPermission = this.getResponseProperty("HasPermission");
}
}

View File

@ -656,7 +656,6 @@ const safeProviders: SafeProvider[] = [
CipherServiceAbstraction,
FolderServiceAbstraction,
CollectionServiceAbstraction,
CryptoServiceAbstraction,
PlatformUtilsServiceAbstraction,
MessagingServiceAbstraction,
SearchServiceAbstraction,

View File

@ -289,6 +289,16 @@ export class AddEditComponent implements OnInit, OnDestroy {
});
}
}
// Only Admins can clone a cipher to different owner
if (this.cloneMode && this.cipher.organizationId != null) {
const cipherOrg = (await firstValueFrom(this.organizationService.memberOrganizations$)).find(
(o) => o.id === this.cipher.organizationId,
);
if (cipherOrg != null && !cipherOrg.isAdmin && !cipherOrg.permissions.editAnyCollection) {
this.ownershipOptions = [{ name: cipherOrg.name, value: cipherOrg.id }];
}
}
// We don't want to copy passkeys when we clone a cipher
if (this.cloneMode && this.cipher?.login?.hasFido2Credentials) {

View File

@ -296,10 +296,6 @@ export abstract class CryptoService {
kdfConfig: KdfConfig,
oldPinKey: EncString,
): Promise<UserKey>;
/**
* Replaces old master auto keys with new user auto keys
*/
abstract migrateAutoKeyIfNeeded(userId?: string): Promise<void>;
/**
* @param keyMaterial The key material to derive the send key from
* @returns A new send key

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