1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 16:53:34 +00:00

[PM-5189] Merging main and fixing conflicts

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

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 libs/admin-console @bitwarden/team-admin-console-dev
## Billing team files ## ## Billing team files ##
apps/browser/src/billing @bitwarden/team-billing-dev
apps/web/src/app/billing @bitwarden/team-billing-dev apps/web/src/app/billing @bitwarden/team-billing-dev
libs/angular/src/billing @bitwarden/team-billing-dev libs/angular/src/billing @bitwarden/team-billing-dev
libs/common/src/billing @bitwarden/team-billing-dev libs/common/src/billing @bitwarden/team-billing-dev

View File

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

View File

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

View File

@@ -57,7 +57,6 @@
</button> </button>
</div> </div>
</div> </div>
<app-private-mode-warning></app-private-mode-warning>
<div class="content login-buttons"> <div class="content login-buttons">
<button type="submit" class="btn primary block" [disabled]="form.loading"> <button type="submit" class="btn primary block" [disabled]="form.loading">
<span [hidden]="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 { EventType } from "@bitwarden/common/enums";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.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 { Utils } from "@bitwarden/common/platform/misc/utils";
import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service";
import { import {
@@ -75,13 +76,14 @@ describe("AutofillService", () => {
const logService = mock<LogService>(); const logService = mock<LogService>();
const userVerificationService = mock<UserVerificationService>(); const userVerificationService = mock<UserVerificationService>();
const billingAccountProfileStateService = mock<BillingAccountProfileStateService>(); const billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
const platformUtilsService = mock<PlatformUtilsService>();
let inlineMenuVisibilitySettingMock$!: BehaviorSubject<InlineMenuVisibilitySetting>; let inlineMenuVisibilitySettingMock$!: BehaviorSubject<InlineMenuVisibilitySetting>;
beforeEach(() => { beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
inlineMenuVisibilitySettingMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus); inlineMenuVisibilitySettingMock$ = new BehaviorSubject(AutofillOverlayVisibility.OnFieldFocus);
autofillSettingsService = mock<AutofillSettingsServiceAbstraction>(); autofillSettingsService = mock<AutofillSettingsServiceAbstraction>();
autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilitySettingMock$; autofillSettingsService.inlineMenuVisibility$ = inlineMenuVisibilitySettingMock$;
scriptInjectorService = new BrowserScriptInjectorService();
autofillService = new AutofillService( autofillService = new AutofillService(
cipherService, cipherService,
autofillSettingsService, autofillSettingsService,

View File

@@ -110,17 +110,13 @@ export default class AutofillService implements AutofillServiceInterface {
frameId = 0, frameId = 0,
triggeringOnPageLoad = true, triggeringOnPageLoad = true,
): Promise<void> { ): Promise<void> {
// Autofill settings loaded from state can await the active account state indefinitely if // Autofill user settings loaded from state can await the active account state indefinitely
// not guarded by an active account check (e.g. the user is logged in) // if not guarded by an active account check (e.g. the user is logged in)
const activeAccount = await firstValueFrom(this.accountService.activeAccount$); 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; let autoFillOnPageLoadIsEnabled = false;
const overlayVisibility = await this.getInlineMenuVisibility();
if (activeAccount) {
overlayVisibility = await this.getInlineMenuVisibility();
}
const mainAutofillScript = overlayVisibility const mainAutofillScript = overlayVisibility
? "bootstrap-autofill-overlay.js" ? "bootstrap-autofill-overlay.js"
: "bootstrap-autofill.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 { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; 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 { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.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 { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { SystemService } from "@bitwarden/common/platform/services/system.service"; 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 */ /* eslint-disable no-restricted-imports */
import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender"; import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender";
/* eslint-enable no-restricted-imports */ /* 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 { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service";
import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; import BrowserLocalStorageService from "../platform/services/browser-local-storage.service";
import BrowserMemoryStorageService from "../platform/services/browser-memory-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 { BrowserScriptInjectorService } from "../platform/services/browser-script-injector.service";
import { DefaultBrowserStateService } from "../platform/services/default-browser-state.service"; import { DefaultBrowserStateService } from "../platform/services/default-browser-state.service";
import I18nService from "../platform/services/i18n.service"; import I18nService from "../platform/services/i18n.service";
@@ -337,6 +338,7 @@ export default class MainBackground {
userAutoUnlockKeyService: UserAutoUnlockKeyService; userAutoUnlockKeyService: UserAutoUnlockKeyService;
scriptInjectorService: BrowserScriptInjectorService; scriptInjectorService: BrowserScriptInjectorService;
kdfConfigService: kdfConfigServiceAbstraction; kdfConfigService: kdfConfigServiceAbstraction;
offscreenDocumentService: OffscreenDocumentService;
onUpdatedRan: boolean; onUpdatedRan: boolean;
onReplacedRan: boolean; onReplacedRan: boolean;
@@ -356,10 +358,7 @@ export default class MainBackground {
private isSafari: boolean; private isSafari: boolean;
private nativeMessagingBackground: NativeMessagingBackground; private nativeMessagingBackground: NativeMessagingBackground;
constructor( constructor(public popupOnlyContext: boolean = false) {
public isPrivateMode: boolean = false,
public popupOnlyContext: boolean = false,
) {
// Services // Services
const lockedCallback = async (userId?: string) => { const lockedCallback = async (userId?: string) => {
if (this.notificationsService != null) { if (this.notificationsService != null) {
@@ -397,11 +396,14 @@ export default class MainBackground {
), ),
); );
this.offscreenDocumentService = new DefaultOffscreenDocumentService();
this.platformUtilsService = new BackgroundPlatformUtilsService( this.platformUtilsService = new BackgroundPlatformUtilsService(
this.messagingService, this.messagingService,
(clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs), (clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs),
async () => this.biometricUnlock(), async () => this.biometricUnlock(),
self, self,
this.offscreenDocumentService,
); );
// Creates a session key for mv3 storage of large memory items // 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.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) this.memoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
? new BrowserMemoryStorageService() // mv3 stores to storage.session ? 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.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 ? 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) this.largeObjectMemoryStorageForStateProviders = BrowserApi.isManifestVersion(3)
? mv3MemoryStorageCreator() // mv3 stores to local-backed session storage ? mv3MemoryStorageCreator() // mv3 stores to local-backed session storage
: this.memoryStorageForStateProviders; // mv2 stores to the same location : this.memoryStorageForStateProviders; // mv2 stores to the same location
@@ -469,14 +475,14 @@ export default class MainBackground {
storageServiceProvider, storageServiceProvider,
); );
this.encryptService = this.encryptService = flagEnabled("multithreadDecryption")
flagEnabled("multithreadDecryption") && BrowserApi.isManifestVersion(2) ? new BrowserMultithreadEncryptServiceImplementation(
? new MultithreadEncryptServiceImplementation( this.cryptoFunctionService,
this.cryptoFunctionService, this.logService,
this.logService, true,
true, this.offscreenDocumentService,
) )
: new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true); : new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true);
this.singleUserStateProvider = new DefaultSingleUserStateProvider( this.singleUserStateProvider = new DefaultSingleUserStateProvider(
storageServiceProvider, storageServiceProvider,
@@ -737,7 +743,6 @@ export default class MainBackground {
this.cipherService, this.cipherService,
this.folderService, this.folderService,
this.collectionService, this.collectionService,
this.cryptoService,
this.platformUtilsService, this.platformUtilsService,
this.messagingService, this.messagingService,
this.searchService, this.searchService,
@@ -808,7 +813,10 @@ export default class MainBackground {
); );
this.totpService = new TotpService(this.cryptoFunctionService, this.logService); 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.autofillService = new AutofillService(
this.cipherService, this.cipherService,
this.autofillSettingsService, this.autofillSettingsService,
@@ -1110,27 +1118,9 @@ export default class MainBackground {
await this.idleBackground.init(); await this.idleBackground.init();
this.webRequestBackground?.startListening(); 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) => { return new Promise<void>((resolve) => {
setTimeout(async () => { setTimeout(async () => {
if (!this.isPrivateMode) { await this.refreshBadge();
await this.refreshBadge();
}
await this.fullSync(true); await this.fullSync(true);
setTimeout(() => this.notificationsService.init(), 2500); setTimeout(() => this.notificationsService.init(), 2500);
resolve(); resolve();

View File

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

View File

@@ -51,7 +51,7 @@
"default_popup": "popup/index.html" "default_popup": "popup/index.html"
}, },
"permissions": [ "permissions": [
"<all_urls>", "activeTab",
"tabs", "tabs",
"contextMenus", "contextMenus",
"storage", "storage",
@@ -66,7 +66,7 @@
"webNavigation" "webNavigation"
], ],
"optional_permissions": ["nativeMessaging", "privacy"], "optional_permissions": ["nativeMessaging", "privacy"],
"host_permissions": ["<all_urls>"], "host_permissions": ["https://*/*", "http://*/*"],
"content_security_policy": { "content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"sandbox": "sandbox allow-scripts; script-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 { BrowserScriptInjectorService } from "../../services/browser-script-injector.service";
import { CachedServices, FactoryOptions, factory } from "./factory-options"; import { CachedServices, FactoryOptions, factory } from "./factory-options";
import {
PlatformUtilsServiceInitOptions,
platformUtilsServiceFactory,
} from "./platform-utils-service.factory";
type BrowserScriptInjectorServiceOptions = FactoryOptions; type BrowserScriptInjectorServiceOptions = FactoryOptions;
export type BrowserScriptInjectorServiceInitOptions = BrowserScriptInjectorServiceOptions; export type BrowserScriptInjectorServiceInitOptions = BrowserScriptInjectorServiceOptions &
PlatformUtilsServiceInitOptions &
LogServiceInitOptions;
export function browserScriptInjectorServiceFactory( export function browserScriptInjectorServiceFactory(
cache: { browserScriptInjectorService?: BrowserScriptInjectorService } & CachedServices, cache: { browserScriptInjectorService?: BrowserScriptInjectorService } & CachedServices,
@@ -14,6 +24,10 @@ export function browserScriptInjectorServiceFactory(
cache, cache,
"browserScriptInjectorService", "browserScriptInjectorService",
opts, 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.clipboardWriteCallback,
opts.platformUtilsServiceOptions.biometricCallback, opts.platformUtilsServiceOptions.biometricCallback,
opts.platformUtilsServiceOptions.win, 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", () => { describe("registerContentScriptsMv2", () => {
const details: browser.contentScripts.RegisteredContentScriptOptions = { const details: browser.contentScripts.RegisteredContentScriptOptions = {
matches: ["<all_urls>"], matches: ["<all_urls>"],

View File

@@ -204,10 +204,6 @@ export class BrowserApi {
chrome.tabs.sendMessage<TabMessage, T>(tabId, message, options, responseCallback); 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) { 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 // FIXME: Make sure that is does not cause a memory leak in Safari or use BrowserApi.AddListener
// and test that it doesn't break. // and test that it doesn't break.
@@ -574,34 +570,6 @@ export class BrowserApi {
chrome.privacy.services.passwordSavingEnabled.set({ value }); 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. * Handles registration of static content scripts within manifest v2.
* *

View File

@@ -1,7 +1,8 @@
type OffscreenDocumentExtensionMessage = { export type OffscreenDocumentExtensionMessage = {
[key: string]: any; [key: string]: any;
command: string; command: string;
text?: string; text?: string;
decryptRequest?: string;
}; };
type OffscreenExtensionMessageEventParams = { type OffscreenExtensionMessageEventParams = {
@@ -9,18 +10,21 @@ type OffscreenExtensionMessageEventParams = {
sender: chrome.runtime.MessageSender; sender: chrome.runtime.MessageSender;
}; };
type OffscreenDocumentExtensionMessageHandlers = { export type OffscreenDocumentExtensionMessageHandlers = {
[key: string]: ({ message, sender }: OffscreenExtensionMessageEventParams) => any; [key: string]: ({ message, sender }: OffscreenExtensionMessageEventParams) => any;
offscreenCopyToClipboard: ({ message }: OffscreenExtensionMessageEventParams) => any; offscreenCopyToClipboard: ({ message }: OffscreenExtensionMessageEventParams) => any;
offscreenReadFromClipboard: () => any; offscreenReadFromClipboard: () => any;
offscreenDecryptItems: ({ message }: OffscreenExtensionMessageEventParams) => Promise<string>;
}; };
interface OffscreenDocument { export interface OffscreenDocument {
init(): void; init(): void;
} }
export { export abstract class OffscreenDocumentService {
OffscreenDocumentExtensionMessage, abstract withDocument<T>(
OffscreenDocumentExtensionMessageHandlers, reasons: chrome.offscreen.Reason[],
OffscreenDocument, 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 { flushPromises, sendMockExtensionMessage } from "../../autofill/spec/testing-utils";
import { BrowserApi } from "../browser/browser-api"; import { BrowserApi } from "../browser/browser-api";
import BrowserClipboardService from "../services/browser-clipboard.service"; 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", () => { describe("OffscreenDocument", () => {
const browserApiMessageListenerSpy = jest.spyOn(BrowserApi, "messageListener"); const browserApiMessageListenerSpy = jest.spyOn(BrowserApi, "messageListener");
const browserClipboardServiceCopySpy = jest.spyOn(BrowserClipboardService, "copy"); const browserClipboardServiceCopySpy = jest.spyOn(BrowserClipboardService, "copy");
@@ -60,5 +78,37 @@ describe("OffscreenDocument", () => {
expect(browserClipboardServiceReadSpy).toHaveBeenCalledWith(window); 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 { 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 { BrowserApi } from "../browser/browser-api";
import BrowserClipboardService from "../services/browser-clipboard.service"; import BrowserClipboardService from "../services/browser-clipboard.service";
import { import {
OffscreenDocument as OffscreenDocumentInterface,
OffscreenDocumentExtensionMessage, OffscreenDocumentExtensionMessage,
OffscreenDocumentExtensionMessageHandlers, OffscreenDocumentExtensionMessageHandlers,
OffscreenDocument as OffscreenDocumentInterface,
} from "./abstractions/offscreen-document"; } from "./abstractions/offscreen-document";
class OffscreenDocument implements OffscreenDocumentInterface { class OffscreenDocument implements OffscreenDocumentInterface {
private consoleLogService: ConsoleLogService = new ConsoleLogService(false); private readonly consoleLogService: ConsoleLogService;
private encryptService: MultithreadEncryptServiceImplementation;
private readonly extensionMessageHandlers: OffscreenDocumentExtensionMessageHandlers = { private readonly extensionMessageHandlers: OffscreenDocumentExtensionMessageHandlers = {
offscreenCopyToClipboard: ({ message }) => this.handleOffscreenCopyToClipboard(message), offscreenCopyToClipboard: ({ message }) => this.handleOffscreenCopyToClipboard(message),
offscreenReadFromClipboard: () => this.handleOffscreenReadFromClipboard(), 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. * Initializes the offscreen document extension.
*/ */
@@ -39,6 +53,23 @@ class OffscreenDocument implements OffscreenDocumentInterface {
return await BrowserClipboardService.read(self); 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. * 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", () => { describe("openPopout", () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({ jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({

View File

@@ -89,13 +89,6 @@ class BrowserPopupUtils {
return !BrowserApi.getBackgroundPage(); 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. * 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 { BrowserApi } from "../browser/browser-api";
import { import {
@@ -20,9 +25,11 @@ describe("ScriptInjectorService", () => {
let scriptInjectorService: BrowserScriptInjectorService; let scriptInjectorService: BrowserScriptInjectorService;
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation(); jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
jest.spyOn(BrowserApi, "isManifestVersion"); jest.spyOn(BrowserApi, "isManifestVersion");
const platformUtilsService = mock<PlatformUtilsService>();
const logService = mock<LogService>();
beforeEach(() => { beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService(); scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
}); });
describe("inject", () => { 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 { BrowserApi } from "../browser/browser-api";
import { import {
@@ -7,6 +10,13 @@ import {
} from "./abstractions/script-injector.service"; } from "./abstractions/script-injector.service";
export class BrowserScriptInjectorService extends ScriptInjectorService { 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 * Facilitates the injection of a script into a tab context. Will adjust
* behavior between manifest v2 and v3 based on the passed configuration. * 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); const injectionDetails = this.buildInjectionDetails(injectDetails, file);
if (BrowserApi.isManifestVersion(3)) { if (BrowserApi.isManifestVersion(3)) {
await BrowserApi.executeScriptInTab(tabId, injectionDetails, { try {
world: mv3Details?.world ?? "ISOLATED", 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; return;
} }

View File

@@ -62,15 +62,6 @@ export class DefaultBrowserStateService
await super.addAccount(account); 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 // Overriding the base class to prevent deleting the cache on save. We register a storage listener
// to delete the cache in the constructor above. // to delete the cache in the constructor above.
protected override async saveAccountToDisk( protected override async saveAccountToDisk(

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { ToastService } from "@bitwarden/components"; import { ToastService } from "@bitwarden/components";
import { OffscreenDocumentService } from "../../offscreen-document/abstractions/offscreen-document";
import { BrowserPlatformUtilsService } from "./browser-platform-utils.service"; import { BrowserPlatformUtilsService } from "./browser-platform-utils.service";
export class ForegroundPlatformUtilsService extends BrowserPlatformUtilsService { export class ForegroundPlatformUtilsService extends BrowserPlatformUtilsService {
@@ -8,8 +10,9 @@ export class ForegroundPlatformUtilsService extends BrowserPlatformUtilsService
clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void, clipboardWriteCallback: (clipboardValue: string, clearMs: number) => void,
biometricCallback: () => Promise<boolean>, biometricCallback: () => Promise<boolean>,
win: Window & typeof globalThis, win: Window & typeof globalThis,
offscreenDocumentService: OffscreenDocumentService,
) { ) {
super(clipboardWriteCallback, biometricCallback, win); super(clipboardWriteCallback, biometricCallback, win, offscreenDocumentService);
} }
override showToast( 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 { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.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 BrowserPopupUtils from "../platform/popup/browser-popup-utils";
import { GeneratorComponent } from "../tools/popup/generator/generator.component"; import { GeneratorComponent } from "../tools/popup/generator/generator.component";
import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.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 { FoldersComponent } from "./settings/folders.component";
import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component";
import { OptionsComponent } from "./settings/options.component"; import { OptionsComponent } from "./settings/options.component";
import { PremiumComponent } from "./settings/premium.component";
import { SettingsComponent } from "./settings/settings.component"; import { SettingsComponent } from "./settings/settings.component";
import { SyncComponent } from "./settings/sync.component"; import { SyncComponent } from "./settings/sync.component";
import { TabsComponent } from "./tabs.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 { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.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 { HeaderComponent } from "../platform/popup/header.component";
import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component"; import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.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 { AppRoutingModule } from "./app-routing.module";
import { AppComponent } from "./app.component"; import { AppComponent } from "./app.component";
import { PopOutComponent } from "./components/pop-out.component"; import { PopOutComponent } from "./components/pop-out.component";
import { PrivateModeWarningComponent } from "./components/private-mode-warning.component";
import { UserVerificationComponent } from "./components/user-verification.component"; import { UserVerificationComponent } from "./components/user-verification.component";
import { ServicesModule } from "./services/services.module"; import { ServicesModule } from "./services/services.module";
import { ExcludedDomainsComponent } from "./settings/excluded-domains.component"; import { ExcludedDomainsComponent } from "./settings/excluded-domains.component";
import { FoldersComponent } from "./settings/folders.component"; import { FoldersComponent } from "./settings/folders.component";
import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component"; import { HelpAndFeedbackComponent } from "./settings/help-and-feedback.component";
import { OptionsComponent } from "./settings/options.component"; import { OptionsComponent } from "./settings/options.component";
import { PremiumComponent } from "./settings/premium.component";
import { SettingsComponent } from "./settings/settings.component"; import { SettingsComponent } from "./settings/settings.component";
import { SyncComponent } from "./settings/sync.component"; import { SyncComponent } from "./settings/sync.component";
import { VaultTimeoutInputComponent } from "./settings/vault-timeout-input.component"; import { VaultTimeoutInputComponent } from "./settings/vault-timeout-input.component";
@@ -150,7 +149,6 @@ import "../platform/popup/locales";
PasswordHistoryComponent, PasswordHistoryComponent,
PopOutComponent, PopOutComponent,
PremiumComponent, PremiumComponent,
PrivateModeWarningComponent,
RegisterComponent, RegisterComponent,
SendAddEditComponent, SendAddEditComponent,
SendGroupingsComponent, 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-sm,
body.body-xs { body.body-xs {
app-home { 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 { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.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 { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.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 { 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 { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; 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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.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 { 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@@ -61,6 +63,7 @@ import {
AbstractStorageService, AbstractStorageService,
ObservableStorageService, ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service"; } 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 { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection // 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 */ /* eslint-disable no-restricted-imports */
import { ChromeMessageSender } from "../../platform/messaging/chrome-message.sender"; import { ChromeMessageSender } from "../../platform/messaging/chrome-message.sender";
/* eslint-enable no-restricted-imports */ /* 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 BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service"; import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service";
import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service"; import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.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 { BrowserEnvironmentService } from "../../platform/services/browser-environment.service";
import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service"; import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service";
import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.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 { DebounceNavigationService } from "./debounce-navigation.service";
import { InitService } from "./init.service"; import { InitService } from "./init.service";
import { PopupCloseWarningService } from "./popup-close-warning.service"; import { PopupCloseWarningService } from "./popup-close-warning.service";
import { PopupSearchService } from "./popup-search.service";
const OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE = new SafeInjectionToken< const OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE = new SafeInjectionToken<
AbstractStorageService & ObservableStorageService AbstractStorageService & ObservableStorageService
>("OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE"); >("OBSERVABLE_LARGE_OBJECT_MEMORY_STORAGE");
const needsBackgroundInit = BrowserPopupUtils.backgroundInitializationRequired(); const needsBackgroundInit = BrowserPopupUtils.backgroundInitializationRequired();
const isPrivateMode = BrowserPopupUtils.inPrivateMode();
const mainBackground: MainBackground = needsBackgroundInit const mainBackground: MainBackground = needsBackgroundInit
? createLocalBgService() ? createLocalBgService()
: BrowserApi.getBackgroundPage().bitwardenMain; : BrowserApi.getBackgroundPage().bitwardenMain;
function createLocalBgService() { 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. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
localBgService.bootstrap(); localBgService.bootstrap();
@@ -176,26 +180,11 @@ const safeProviders: SafeProvider[] = [
useFactory: getBgService<SsoLoginServiceAbstraction>("ssoLoginService"), useFactory: getBgService<SsoLoginServiceAbstraction>("ssoLoginService"),
deps: [], deps: [],
}), }),
safeProvider({
provide: SearchServiceAbstraction,
useClass: PopupSearchService,
deps: [LogService, I18nServiceAbstraction, StateProvider],
}),
safeProvider({
provide: CipherService,
useFactory: getBgService<CipherService>("cipherService"),
deps: [],
}),
safeProvider({ safeProvider({
provide: CryptoFunctionService, provide: CryptoFunctionService,
useFactory: () => new WebCryptoFunctionService(window), useFactory: () => new WebCryptoFunctionService(window),
deps: [], deps: [],
}), }),
safeProvider({
provide: CollectionService,
useFactory: getBgService<CollectionService>("collectionService"),
deps: [],
}),
safeProvider({ safeProvider({
provide: LogService, provide: LogService,
useFactory: (platformUtilsService: PlatformUtilsService) => useFactory: (platformUtilsService: PlatformUtilsService) =>
@@ -220,12 +209,48 @@ const safeProviders: SafeProvider[] = [
}), }),
safeProvider({ safeProvider({
provide: CryptoService, provide: CryptoService,
useFactory: (encryptService: EncryptService) => { useFactory: (
const cryptoService = getBgService<CryptoService>("cryptoService")(); 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); new ContainerService(cryptoService, encryptService).attachToGlobal(self);
return cryptoService; return cryptoService;
}, },
deps: [EncryptService], deps: [
InternalMasterPasswordServiceAbstraction,
KeyGenerationService,
CryptoFunctionService,
EncryptService,
PlatformUtilsService,
LogService,
StateServiceAbstraction,
AccountServiceAbstraction,
StateProvider,
BiometricStateService,
KdfConfigService,
],
}), }),
safeProvider({ safeProvider({
provide: TotpServiceAbstraction, provide: TotpServiceAbstraction,
@@ -247,9 +272,17 @@ const safeProviders: SafeProvider[] = [
useFactory: getBgService<DevicesServiceAbstraction>("devicesService"), useFactory: getBgService<DevicesServiceAbstraction>("devicesService"),
deps: [], deps: [],
}), }),
safeProvider({
provide: OffscreenDocumentService,
useClass: DefaultOffscreenDocumentService,
deps: [],
}),
safeProvider({ safeProvider({
provide: PlatformUtilsService, provide: PlatformUtilsService,
useFactory: (toastService: ToastService) => { useFactory: (
toastService: ToastService,
offscreenDocumentService: OffscreenDocumentService,
) => {
return new ForegroundPlatformUtilsService( return new ForegroundPlatformUtilsService(
toastService, toastService,
(clipboardValue: string, clearMs: number) => { (clipboardValue: string, clearMs: number) => {
@@ -266,9 +299,10 @@ const safeProviders: SafeProvider[] = [
return response.result; return response.result;
}, },
window, window,
offscreenDocumentService,
); );
}, },
deps: [ToastService], deps: [ToastService, OffscreenDocumentService],
}), }),
safeProvider({ safeProvider({
provide: PasswordGenerationServiceAbstraction, provide: PasswordGenerationServiceAbstraction,
@@ -312,7 +346,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: ScriptInjectorService, provide: ScriptInjectorService,
useClass: BrowserScriptInjectorService, useClass: BrowserScriptInjectorService,
deps: [], deps: [PlatformUtilsService, LogService],
}), }),
safeProvider({ safeProvider({
provide: KeyConnectorService, 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 { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.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 { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { Importer, ImportResult, ImportServiceAbstraction } from "@bitwarden/importer/core"; import { Importer, ImportResult, ImportServiceAbstraction } from "@bitwarden/importer/core";
@@ -38,10 +40,12 @@ describe("FilelessImporterBackground ", () => {
const notificationBackground = mock<NotificationBackground>(); const notificationBackground = mock<NotificationBackground>();
const importService = mock<ImportServiceAbstraction>(); const importService = mock<ImportServiceAbstraction>();
const syncService = mock<SyncService>(); const syncService = mock<SyncService>();
const platformUtilsService = mock<PlatformUtilsService>();
const logService = mock<LogService>();
let scriptInjectorService: BrowserScriptInjectorService; let scriptInjectorService: BrowserScriptInjectorService;
beforeEach(() => { beforeEach(() => {
scriptInjectorService = new BrowserScriptInjectorService(); scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService);
filelessImporterBackground = new FilelessImporterBackground( filelessImporterBackground = new FilelessImporterBackground(
configService, configService,
authService, authService,

View File

@@ -70,13 +70,13 @@ export class Fido2Background implements Fido2BackgroundInterface {
*/ */
async injectFido2ContentScriptsInAllTabs() { async injectFido2ContentScriptsInAllTabs() {
const tabs = await BrowserApi.tabsQuery({}); const tabs = await BrowserApi.tabsQuery({});
for (let index = 0; index < tabs.length; index++) { for (let index = 0; index < tabs.length; index++) {
const tab = tabs[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", () => { describe("Fido2 Content Script", () => {
beforeAll(() => {
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(
() => mockGlobalThisDocument,
);
});
afterEach(() => {
jest.resetModules();
});
afterAll(() => {
jest.clearAllMocks();
});
let messenger: Messenger; let messenger: Messenger;
const messengerForDOMCommunicationSpy = jest const messengerForDOMCommunicationSpy = jest
.spyOn(Messenger, "forDOMCommunication") .spyOn(Messenger, "forDOMCommunication")
.mockImplementation((window) => { .mockImplementation((context) => {
const windowOrigin = window.location.origin; const windowOrigin = context.location.origin;
messenger = new Messenger({ messenger = new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]), postMessage: (message, port) => context.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => window.addEventListener("message", listener), addEventListener: (listener) => context.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener), removeEventListener: (listener) => context.removeEventListener("message", listener),
}); });
messenger.destroy = jest.fn(); messenger.destroy = jest.fn();
return messenger; return messenger;
@@ -33,16 +59,6 @@ describe("Fido2 Content Script", () => {
const portSpy: MockProxy<chrome.runtime.Port> = createPortSpyMock(Fido2PortName.InjectedScript); const portSpy: MockProxy<chrome.runtime.Port> = createPortSpyMock(Fido2PortName.InjectedScript);
chrome.runtime.connect = jest.fn(() => portSpy); 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", () => { it("destroys the messenger when the port is disconnected", () => {
require("./content-script"); require("./content-script");
@@ -151,11 +167,31 @@ describe("Fido2 Content Script", () => {
await expect(result).rejects.toEqual(errorMessage); await expect(result).rejects.toEqual(errorMessage);
}); });
it("skips initializing the content script if the document content type is not 'text/html'", () => { it("skips initializing if the document content type is not 'text/html'", () => {
Object.defineProperty(document, "contentType", { jest.clearAllMocks();
value: "application/json",
writable: true, (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"); require("./content-script");

View File

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

View File

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

View File

@@ -10,17 +10,29 @@ import { WebauthnUtils } from "../webauthn-utils";
import { MessageType } from "./messaging/message"; import { MessageType } from "./messaging/message";
import { Messenger } from "./messaging/messenger"; 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; let messenger: Messenger;
jest.mock("./messaging/messenger", () => { jest.mock("./messaging/messenger", () => {
return { return {
Messenger: class extends jest.requireActual("./messaging/messenger").Messenger { Messenger: class extends jest.requireActual("./messaging/messenger").Messenger {
static forDOMCommunication: any = jest.fn((window) => { static forDOMCommunication: any = jest.fn((context) => {
const windowOrigin = window.location.origin; const windowOrigin = context.location.origin;
messenger = new Messenger({ messenger = new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]), postMessage: (message, port) => context.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => window.addEventListener("message", listener), addEventListener: (listener) => context.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener), removeEventListener: (listener) => context.removeEventListener("message", listener),
}); });
messenger.destroy = jest.fn(); messenger.destroy = jest.fn();
return messenger; return messenger;
@@ -31,6 +43,10 @@ jest.mock("./messaging/messenger", () => {
jest.mock("../webauthn-utils"); jest.mock("../webauthn-utils");
describe("Fido2 page script with native WebAuthn support", () => { describe("Fido2 page script with native WebAuthn support", () => {
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(
() => mockGlobalThisDocument,
);
const mockCredentialCreationOptions = createCredentialCreationOptionsMock(); const mockCredentialCreationOptions = createCredentialCreationOptionsMock();
const mockCreateCredentialsResult = createCreateCredentialResultMock(); const mockCreateCredentialsResult = createCreateCredentialResultMock();
const mockCredentialRequestOptions = createCredentialRequestOptionsMock(); const mockCredentialRequestOptions = createCredentialRequestOptionsMock();
@@ -39,9 +55,12 @@ describe("Fido2 page script with native WebAuthn support", () => {
require("./page-script"); require("./page-script");
afterEach(() => {
jest.resetModules();
});
afterAll(() => { afterAll(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.resetModules();
}); });
describe("creating WebAuthn credentials", () => { describe("creating WebAuthn credentials", () => {
@@ -118,4 +137,42 @@ describe("Fido2 page script with native WebAuthn support", () => {
expect(messenger.destroy).toHaveBeenCalled(); 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 { MessageType } from "./messaging/message";
import { Messenger } from "./messaging/messenger"; 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; let messenger: Messenger;
jest.mock("./messaging/messenger", () => { jest.mock("./messaging/messenger", () => {
return { return {
Messenger: class extends jest.requireActual("./messaging/messenger").Messenger { Messenger: class extends jest.requireActual("./messaging/messenger").Messenger {
static forDOMCommunication: any = jest.fn((window) => { static forDOMCommunication: any = jest.fn((context) => {
const windowOrigin = window.location.origin; const windowOrigin = context.location.origin;
messenger = new Messenger({ messenger = new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]), postMessage: (message, port) => context.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => window.addEventListener("message", listener), addEventListener: (listener) => context.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener), removeEventListener: (listener) => context.removeEventListener("message", listener),
}); });
messenger.destroy = jest.fn(); messenger.destroy = jest.fn();
return messenger; return messenger;
@@ -30,15 +42,22 @@ jest.mock("./messaging/messenger", () => {
jest.mock("../webauthn-utils"); jest.mock("../webauthn-utils");
describe("Fido2 page script without native WebAuthn support", () => { describe("Fido2 page script without native WebAuthn support", () => {
(jest.spyOn(globalThis, "document", "get") as jest.Mock).mockImplementation(
() => mockGlobalThisDocument,
);
const mockCredentialCreationOptions = createCredentialCreationOptionsMock(); const mockCredentialCreationOptions = createCredentialCreationOptionsMock();
const mockCreateCredentialsResult = createCreateCredentialResultMock(); const mockCreateCredentialsResult = createCreateCredentialResultMock();
const mockCredentialRequestOptions = createCredentialRequestOptionsMock(); const mockCredentialRequestOptions = createCredentialRequestOptionsMock();
const mockCredentialAssertResult = createAssertCredentialResultMock(); const mockCredentialAssertResult = createAssertCredentialResultMock();
require("./page-script"); require("./page-script");
afterEach(() => {
jest.resetModules();
});
afterAll(() => { afterAll(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.resetModules();
}); });
describe("creating WebAuthn credentials", () => { describe("creating WebAuthn credentials", () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,13 @@ import {
of, of,
shareReplay, shareReplay,
Subject, Subject,
switchMap,
takeUntil, takeUntil,
} from "rxjs"; } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { UserId } from "@bitwarden/common/types/guid"; 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 { 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 { InternalGroupService as GroupService, GroupView } from "../core";
import { import {
AccessItemType, AccessItemType,
@@ -95,9 +93,15 @@ export const openGroupAddEditDialog = (
templateUrl: "group-add-edit.component.html", templateUrl: "group-add-edit.component.html",
}) })
export class GroupAddEditComponent implements OnInit, OnDestroy { export class GroupAddEditComponent implements OnInit, OnDestroy {
protected flexibleCollectionsEnabled$ = this.organizationService private organization$ = this.organizationService
.get$(this.organizationId) .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 PermissionMode = PermissionMode;
protected ResultType = GroupAddEditDialogResultType; protected ResultType = GroupAddEditDialogResultType;
@@ -131,27 +135,9 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
private get orgCollections$() { private orgCollections$ = from(this.collectionAdminService.getAll(this.organizationId)).pipe(
return from(this.apiService.getCollections(this.organizationId)).pipe( shareReplay({ refCount: true, bufferSize: 1 }),
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 get orgMembers$(): Observable<Array<AccessItemView & { userId: UserId }>> { private get orgMembers$(): Observable<Array<AccessItemView & { userId: UserId }>> {
return from(this.organizationUserService.getAllUsers(this.organizationId)).pipe( return from(this.organizationUserService.getAllUsers(this.organizationId)).pipe(
@@ -197,23 +183,24 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
shareReplay({ refCount: true, bufferSize: 1 }), shareReplay({ refCount: true, bufferSize: 1 }),
); );
restrictGroupAccess$ = combineLatest([ allowAdminAccessToAllCollectionItems$ = combineLatest([
this.organizationService.get$(this.organizationId), this.organization$,
this.configService.getFeatureFlag$(FeatureFlag.FlexibleCollectionsV1), this.flexibleCollectionsV1Enabled$,
this.groupDetails$,
]).pipe( ]).pipe(
map( map(([organization, flexibleCollectionsV1Enabled]) => {
([organization, flexibleCollectionsV1Enabled, group]) => if (!flexibleCollectionsV1Enabled || !organization.flexibleCollections) {
// Feature flag conditionals return true;
flexibleCollectionsV1Enabled && }
organization.flexibleCollections &&
// Business logic conditionals return organization.allowAdminAccessToAllCollectionItems;
!organization.allowAdminAccessToAllCollectionItems && }),
group !== undefined,
),
shareReplay({ refCount: true, bufferSize: 1 }),
); );
restrictGroupAccess$ = combineLatest([
this.allowAdminAccessToAllCollectionItems$,
this.groupDetails$,
]).pipe(map(([allowAdminAccess, groupDetails]) => !allowAdminAccess && groupDetails != null));
constructor( constructor(
@Inject(DIALOG_DATA) private params: GroupAddEditDialogParams, @Inject(DIALOG_DATA) private params: GroupAddEditDialogParams,
private dialogRef: DialogRef<GroupAddEditDialogResultType>, private dialogRef: DialogRef<GroupAddEditDialogResultType>,
@@ -221,7 +208,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private organizationUserService: OrganizationUserService, private organizationUserService: OrganizationUserService,
private groupService: GroupService, private groupService: GroupService,
private i18nService: I18nService, private i18nService: I18nService,
private collectionService: CollectionService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private logService: LogService, private logService: LogService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@@ -230,6 +216,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
private organizationService: OrganizationService, private organizationService: OrganizationService,
private configService: ConfigService, private configService: ConfigService,
private accountService: AccountService, private accountService: AccountService,
private collectionAdminService: CollectionAdminService,
) { ) {
this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info; this.tabIndex = params.initialTab ?? GroupAddEditTabType.Info;
} }
@@ -244,48 +231,61 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.groupDetails$, this.groupDetails$,
this.restrictGroupAccess$, this.restrictGroupAccess$,
this.accountService.activeAccount$, this.accountService.activeAccount$,
this.organization$,
this.flexibleCollectionsV1Enabled$,
]) ])
.pipe(takeUntil(this.destroy$)) .pipe(takeUntil(this.destroy$))
.subscribe(([collections, members, group, restrictGroupAccess, activeAccount]) => { .subscribe(
this.collections = collections; ([
this.members = members; collections,
this.group = group; members,
group,
if (this.group != undefined) { restrictGroupAccess,
// Must detect changes so that AccessSelector @Inputs() are aware of the latest activeAccount,
// collections/members set above, otherwise no selected values will be patched below organization,
this.changeDetectorRef.detectChanges(); flexibleCollectionsV1Enabled,
]) => {
this.groupForm.patchValue({ this.members = members;
name: this.group.name, this.group = group;
externalId: this.group.externalId, this.collections = mapToAccessItemViews(
accessAll: this.group.accessAll, collections,
members: this.group.members.map((m) => ({ organization,
id: m, flexibleCollectionsV1Enabled,
type: AccessItemType.Member, group,
})),
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,
); );
if (!isAlreadyInGroup) { if (this.group != undefined) {
this.members = this.members.filter((m) => m.id !== organizationUserId); // 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() { ngOnDestroy() {
@@ -355,3 +355,46 @@ export class GroupAddEditComponent implements OnInit, OnDestroy {
this.dialogRef.close(GroupAddEditDialogResultType.Deleted); 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 { ModalService } from "@bitwarden/angular/services/modal.service";
import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { AuditService } from "@bitwarden/common/abstractions/audit.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -27,11 +28,20 @@ export class ExposedPasswordsReportComponent extends BaseExposedPasswordsReportC
organizationService: OrganizationService, organizationService: OrganizationService,
private route: ActivatedRoute, private route: ActivatedRoute,
passwordRepromptService: PasswordRepromptService, passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) { ) {
super(cipherService, auditService, organizationService, modalService, passwordRepromptService); super(
cipherService,
auditService,
organizationService,
modalService,
passwordRepromptService,
i18nService,
);
} }
async ngOnInit() { async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => { this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId); 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 { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -24,11 +25,20 @@ export class InactiveTwoFactorReportComponent extends BaseInactiveTwoFactorRepor
logService: LogService, logService: LogService,
passwordRepromptService: PasswordRepromptService, passwordRepromptService: PasswordRepromptService,
organizationService: OrganizationService, organizationService: OrganizationService,
i18nService: I18nService,
) { ) {
super(cipherService, organizationService, modalService, logService, passwordRepromptService); super(
cipherService,
organizationService,
modalService,
logService,
passwordRepromptService,
i18nService,
);
} }
async ngOnInit() { async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => { this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId); 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 { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@@ -25,11 +26,13 @@ export class ReusedPasswordsReportComponent extends BaseReusedPasswordsReportCom
private route: ActivatedRoute, private route: ActivatedRoute,
organizationService: OrganizationService, organizationService: OrganizationService,
passwordRepromptService: PasswordRepromptService, passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) { ) {
super(cipherService, organizationService, modalService, passwordRepromptService); super(cipherService, organizationService, modalService, passwordRepromptService, i18nService);
} }
async ngOnInit() { async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => { this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId); 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 { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault"; import { PasswordRepromptService } from "@bitwarden/vault";
@@ -22,11 +23,13 @@ export class UnsecuredWebsitesReportComponent extends BaseUnsecuredWebsitesRepor
private route: ActivatedRoute, private route: ActivatedRoute,
organizationService: OrganizationService, organizationService: OrganizationService,
passwordRepromptService: PasswordRepromptService, passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) { ) {
super(cipherService, organizationService, modalService, passwordRepromptService); super(cipherService, organizationService, modalService, passwordRepromptService, i18nService);
} }
async ngOnInit() { async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => { this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId); 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 { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
@@ -27,6 +28,7 @@ export class WeakPasswordsReportComponent extends BaseWeakPasswordsReportCompone
private route: ActivatedRoute, private route: ActivatedRoute,
organizationService: OrganizationService, organizationService: OrganizationService,
passwordRepromptService: PasswordRepromptService, passwordRepromptService: PasswordRepromptService,
i18nService: I18nService,
) { ) {
super( super(
cipherService, cipherService,
@@ -34,10 +36,12 @@ export class WeakPasswordsReportComponent extends BaseWeakPasswordsReportCompone
organizationService, organizationService,
modalService, modalService,
passwordRepromptService, passwordRepromptService,
i18nService,
); );
} }
async ngOnInit() { async ngOnInit() {
this.isAdminConsoleActive = true;
// eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
this.route.parent.parent.params.subscribe(async (params) => { this.route.parent.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId); this.organization = await this.organizationService.get(params.organizationId);

View File

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

View File

@@ -1,12 +1,13 @@
import { Component, ViewChild } from "@angular/core"; import { Component, ViewChild } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, ParamMap, Router } from "@angular/router";
import { combineLatest, concatMap } from "rxjs"; import { combineLatest, concatMap, map } from "rxjs";
import { import {
canAccessOrgAdmin, canAccessOrgAdmin,
OrganizationService, OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; 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"; import { MenuComponent } from "@bitwarden/components";
type ProductSwitcherItem = { type ProductSwitcherItem = {
@@ -48,6 +49,13 @@ export class ProductSwitcherContentComponent {
this.organizationService.organizations$, this.organizationService.organizations$,
this.route.paramMap, this.route.paramMap,
]).pipe( ]).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]) => { concatMap(async ([orgs, paramMap]) => {
const routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId")); 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. // 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: { ac: {
name: "Admin Console", name: "Admin Console",
icon: "bwi-business", icon: "bwi-user-monitor",
appRoute: ["/organizations", acOrg?.id], appRoute: ["/organizations", acOrg?.id],
marketingRoute: "https://bitwarden.com/products/business/", marketingRoute: "https://bitwarden.com/products/business/",
isActive: this.router.url.includes("/organizations/"), isActive: this.router.url.includes("/organizations/"),

View File

@@ -1,9 +1,11 @@
import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; import { Directive, ViewChild, ViewContainerRef, OnDestroy } from "@angular/core";
import { Observable } from "rxjs"; import { BehaviorSubject, Observable, Subject, takeUntil } from "rxjs";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; 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 { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { PasswordRepromptService } from "@bitwarden/vault"; 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"; import { AddEditComponent as OrgAddEditComponent } from "../../../vault/org-vault/add-edit.component";
@Directive() @Directive()
export class CipherReportComponent { export class CipherReportComponent implements OnDestroy {
@ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true }) @ViewChild("cipherAddEdit", { read: ViewContainerRef, static: true })
cipherAddEditModalRef: ViewContainerRef; cipherAddEditModalRef: ViewContainerRef;
isAdminConsoleActive = false;
loading = false; loading = false;
hasLoaded = false; hasLoaded = false;
ciphers: CipherView[] = []; ciphers: CipherView[] = [];
allCiphers: CipherView[] = [];
organization: Organization; organization: Organization;
organizations: Organization[];
organizations$: Observable<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( constructor(
protected cipherService: CipherService,
private modalService: ModalService, private modalService: ModalService,
protected passwordRepromptService: PasswordRepromptService, protected passwordRepromptService: PasswordRepromptService,
protected organizationService: OrganizationService, protected organizationService: OrganizationService,
protected i18nService: I18nService,
) { ) {
this.organizations$ = this.organizationService.organizations$; 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() { async load() {
this.loading = true; 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.loading = false;
this.hasLoaded = true; this.hasLoaded = true;
} }
@@ -76,7 +162,7 @@ export class CipherReportComponent {
} }
protected async setCiphers() { protected async setCiphers() {
this.ciphers = []; this.allCiphers = [];
} }
protected async repromptCipher(c: CipherView) { protected async repromptCipher(c: CipherView) {
@@ -85,4 +171,32 @@ export class CipherReportComponent {
(await this.passwordRepromptService.showPasswordPrompt()) (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> </app-callout>
<ng-container *ngIf="ciphers.length"> <ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'exposedPasswordsFound' | i18n }}" [useAlertRole]="true"> <app-callout type="danger" title="{{ 'exposedPasswordsFound' | i18n }}" [useAlertRole]="true">
{{ "exposedPasswordsFoundDesc" | i18n: (ciphers.length | number) }} {{ "exposedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout> </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"> <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> <tbody>
<tr *ngFor="let c of ciphers"> <tr *ngFor="let c of ciphers">
<td class="table-list-icon"> <td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -17,9 +18,12 @@ describe("ExposedPasswordsReportComponent", () => {
let component: ExposedPasswordsReportComponent; let component: ExposedPasswordsReportComponent;
let fixture: ComponentFixture<ExposedPasswordsReportComponent>; let fixture: ComponentFixture<ExposedPasswordsReportComponent>;
let auditService: MockProxy<AuditService>; let auditService: MockProxy<AuditService>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => { beforeEach(() => {
auditService = mock<AuditService>(); 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. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -35,7 +39,7 @@ describe("ExposedPasswordsReportComponent", () => {
}, },
{ {
provide: OrganizationService, provide: OrganizationService,
useValue: mock<OrganizationService>(), useValue: organizationService,
}, },
{ {
provide: ModalService, provide: ModalService,

View File

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

View File

@@ -16,9 +16,32 @@
</app-callout> </app-callout>
<ng-container *ngIf="ciphers.length"> <ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'inactive2faFound' | i18n }}"> <app-callout type="danger" title="{{ 'inactive2faFound' | i18n }}">
{{ "inactive2faFoundDesc" | i18n: (ciphers.length | number) }} {{ "inactive2faFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout> </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"> <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> <tbody>
<tr *ngFor="let c of ciphers"> <tr *ngFor="let c of ciphers">
<td class="table-list-icon"> <td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing"; 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 { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -16,8 +17,11 @@ import { cipherData } from "./reports-ciphers.mock";
describe("InactiveTwoFactorReportComponent", () => { describe("InactiveTwoFactorReportComponent", () => {
let component: InactiveTwoFactorReportComponent; let component: InactiveTwoFactorReportComponent;
let fixture: ComponentFixture<InactiveTwoFactorReportComponent>; let fixture: ComponentFixture<InactiveTwoFactorReportComponent>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => { 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. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -29,7 +33,7 @@ describe("InactiveTwoFactorReportComponent", () => {
}, },
{ {
provide: OrganizationService, provide: OrganizationService,
useValue: mock<OrganizationService>(), useValue: organizationService,
}, },
{ {
provide: ModalService, provide: ModalService,

View File

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

View File

@@ -16,9 +16,34 @@
</app-callout> </app-callout>
<ng-container *ngIf="ciphers.length"> <ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'reusedPasswordsFound' | i18n }}"> <app-callout type="danger" title="{{ 'reusedPasswordsFound' | i18n }}">
{{ "reusedPasswordsFoundDesc" | i18n: (ciphers.length | number) }} {{ "reusedPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout> </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"> <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> <tbody>
<tr *ngFor="let c of ciphers"> <tr *ngFor="let c of ciphers">
<td class="table-list-icon"> <td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing"; 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 { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -15,8 +16,11 @@ import { ReusedPasswordsReportComponent } from "./reused-passwords-report.compon
describe("ReusedPasswordsReportComponent", () => { describe("ReusedPasswordsReportComponent", () => {
let component: ReusedPasswordsReportComponent; let component: ReusedPasswordsReportComponent;
let fixture: ComponentFixture<ReusedPasswordsReportComponent>; let fixture: ComponentFixture<ReusedPasswordsReportComponent>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => { 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. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -28,7 +32,7 @@ describe("ReusedPasswordsReportComponent", () => {
}, },
{ {
provide: OrganizationService, provide: OrganizationService,
useValue: mock<OrganizationService>(), useValue: organizationService,
}, },
{ {
provide: ModalService, provide: ModalService,

View File

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

View File

@@ -16,9 +16,33 @@
</app-callout> </app-callout>
<ng-container *ngIf="ciphers.length"> <ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'unsecuredWebsitesFound' | i18n }}"> <app-callout type="danger" title="{{ 'unsecuredWebsitesFound' | i18n }}">
{{ "unsecuredWebsitesFoundDesc" | i18n: (ciphers.length | number) }} {{ "unsecuredWebsitesFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout> </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"> <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> <tbody>
<tr *ngFor="let c of ciphers"> <tr *ngFor="let c of ciphers">
<td class="table-list-icon"> <td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing"; 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 { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -15,8 +16,11 @@ import { UnsecuredWebsitesReportComponent } from "./unsecured-websites-report.co
describe("UnsecuredWebsitesReportComponent", () => { describe("UnsecuredWebsitesReportComponent", () => {
let component: UnsecuredWebsitesReportComponent; let component: UnsecuredWebsitesReportComponent;
let fixture: ComponentFixture<UnsecuredWebsitesReportComponent>; let fixture: ComponentFixture<UnsecuredWebsitesReportComponent>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => { 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. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -28,7 +32,7 @@ describe("UnsecuredWebsitesReportComponent", () => {
}, },
{ {
provide: OrganizationService, provide: OrganizationService,
useValue: mock<OrganizationService>(), useValue: organizationService,
}, },
{ {
provide: ModalService, provide: ModalService,

View File

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

View File

@@ -16,9 +16,32 @@
</app-callout> </app-callout>
<ng-container *ngIf="ciphers.length"> <ng-container *ngIf="ciphers.length">
<app-callout type="danger" title="{{ 'weakPasswordsFound' | i18n }}"> <app-callout type="danger" title="{{ 'weakPasswordsFound' | i18n }}">
{{ "weakPasswordsFoundDesc" | i18n: (ciphers.length | number) }} {{ "weakPasswordsFoundReportDesc" | i18n: (ciphers.length | number) : vaultMsg }}
</app-callout> </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"> <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> <tbody>
<tr *ngFor="let c of ciphers"> <tr *ngFor="let c of ciphers">
<td class="table-list-icon"> <td class="table-list-icon">

View File

@@ -1,6 +1,7 @@
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -17,9 +18,12 @@ describe("WeakPasswordsReportComponent", () => {
let component: WeakPasswordsReportComponent; let component: WeakPasswordsReportComponent;
let fixture: ComponentFixture<WeakPasswordsReportComponent>; let fixture: ComponentFixture<WeakPasswordsReportComponent>;
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>; let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
let organizationService: MockProxy<OrganizationService>;
beforeEach(() => { beforeEach(() => {
passwordStrengthService = mock<PasswordStrengthServiceAbstraction>(); 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. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -35,7 +39,7 @@ describe("WeakPasswordsReportComponent", () => {
}, },
{ {
provide: OrganizationService, provide: OrganizationService,
useValue: mock<OrganizationService>(), useValue: organizationService,
}, },
{ {
provide: ModalService, provide: ModalService,

View File

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

View File

@@ -32,7 +32,6 @@ export class VaultItemsComponent {
@Input() showCollections: boolean; @Input() showCollections: boolean;
@Input() showGroups: boolean; @Input() showGroups: boolean;
@Input() useEvents: boolean; @Input() useEvents: boolean;
@Input() cloneableOrganizationCiphers: boolean;
@Input() showPremiumFeatures: boolean; @Input() showPremiumFeatures: boolean;
@Input() showBulkMove: boolean; @Input() showBulkMove: boolean;
@Input() showBulkTrashOptions: boolean; @Input() showBulkTrashOptions: boolean;
@@ -160,10 +159,27 @@ export class VaultItemsComponent {
} }
protected canClone(vaultItem: VaultItem) { protected canClone(vaultItem: VaultItem) {
return ( if (vaultItem.cipher.organizationId == null) {
(vaultItem.cipher.organizationId && this.cloneableOrganizationCiphers) || return true;
vaultItem.cipher.organizationId == null }
);
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() { private refreshItems() {

View File

@@ -51,6 +51,13 @@ export class CollectionAdminView extends CollectionView {
* Whether the user can modify user access to this collection * Whether the user can modify user access to this collection
*/ */
canEditUserAccess(org: Organization, flexibleCollectionsV1Enabled: boolean): boolean { 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" [showBulkMove]="showBulkMove"
[showBulkTrashOptions]="filter.type === 'trash'" [showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="false" [useEvents]="false"
[cloneableOrganizationCiphers]="false"
[showAdminActions]="false" [showAdminActions]="false"
(onEvent)="onVaultItemsEvent($event)" (onEvent)="onVaultItemsEvent($event)"
[flexibleCollectionsV1Enabled]="flexibleCollectionsV1Enabled$ | async" [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() { protected loadCollections() {
if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { if (!this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) {
return super.loadCollections(); return super.loadCollections();

View File

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

View File

@@ -1809,12 +1809,16 @@
"unsecuredWebsitesFound": { "unsecuredWebsitesFound": {
"message": "Unsecured websites found" "message": "Unsecured websites found"
}, },
"unsecuredWebsitesFoundDesc": { "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.", "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": { "placeholders": {
"count": { "count": {
"content": "$1", "content": "$1",
"example": "8" "example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
} }
} }
}, },
@@ -1830,12 +1834,16 @@
"inactive2faFound": { "inactive2faFound": {
"message": "Logins without two-step login found" "message": "Logins without two-step login found"
}, },
"inactive2faFoundDesc": { "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.", "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": { "placeholders": {
"count": { "count": {
"content": "$1", "content": "$1",
"example": "8" "example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
} }
} }
}, },
@@ -1854,12 +1862,16 @@
"exposedPasswordsFound": { "exposedPasswordsFound": {
"message": "Exposed passwords found" "message": "Exposed passwords found"
}, },
"exposedPasswordsFoundDesc": { "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.", "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": { "placeholders": {
"count": { "count": {
"content": "$1", "content": "$1",
"example": "8" "example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
} }
} }
}, },
@@ -1887,12 +1899,16 @@
"weakPasswordsFound": { "weakPasswordsFound": {
"message": "Weak passwords found" "message": "Weak passwords found"
}, },
"weakPasswordsFoundDesc": { "weakPasswordsFoundReportDesc": {
"message": "We found $COUNT$ items in your vault with passwords that are not strong. You should update them to use stronger passwords.", "message": "We found $COUNT$ items in your $VAULT$ with passwords that are not strong. You should update them to use stronger passwords.",
"placeholders": { "placeholders": {
"count": { "count": {
"content": "$1", "content": "$1",
"example": "8" "example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
} }
} }
}, },
@@ -1908,12 +1924,16 @@
"reusedPasswordsFound": { "reusedPasswordsFound": {
"message": "Reused passwords found" "message": "Reused passwords found"
}, },
"reusedPasswordsFoundDesc": { "reusedPasswordsFoundReportDesc": {
"message": "We found $COUNT$ passwords that are being reused in your vault. You should change them to a unique value.", "message": "We found $COUNT$ passwords that are being reused in your $VAULT$. You should change them to a unique value.",
"placeholders": { "placeholders": {
"count": { "count": {
"content": "$1", "content": "$1",
"example": "8" "example": "8"
},
"vault": {
"content": "$2",
"example": "this will be 'vault' or 'vaults'"
} }
} }
}, },
@@ -6460,6 +6480,9 @@
"editGroupCollectionsDesc": { "editGroupCollectionsDesc": {
"message": "Grant access to collections by adding them to this group." "message": "Grant access to collections by adding them to this group."
}, },
"editGroupCollectionsRestrictionsDesc": {
"message": "You can only assign collections you manage."
},
"accessAllCollectionsDesc": { "accessAllCollectionsDesc": {
"message": "Grant access to all current and future collections." "message": "Grant access to all current and future collections."
}, },

View File

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

View File

@@ -58,3 +58,16 @@ export class ServiceAccountPeopleAccessPoliciesView {
userAccessPolicies: UserServiceAccountAccessPolicyView[]; userAccessPolicies: UserServiceAccountAccessPolicyView[];
groupAccessPolicies: GroupServiceAccountAccessPolicyView[]; 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"> <form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
<p class="tw-mt-8"> <div class="tw-w-2/5">
{{ "projectMachineAccountsDescription" | i18n }} <p class="tw-mt-8" *ngIf="!loading">
</p> {{ "projectMachineAccountsDescription" | i18n }}
<sm-access-selector </p>
[rows]="rows$ | async" <sm-access-policy-selector
granteeType="serviceAccounts" [loading]="loading"
[label]="'machineAccounts' | i18n" formControlName="accessPolicies"
[hint]="'projectMachineAccountsSelectHint' | i18n" [addButtonMode]="true"
[columnTitle]="'machineAccounts' | i18n" [items]="items"
[emptyMessage]="'projectEmptyMachineAccountAccessPolicies' | i18n" [label]="'machineAccounts' | i18n"
(onCreateAccessPolicies)="handleCreateAccessPolicies($event)" [hint]="'projectMachineAccountsSelectHint' | i18n"
(onDeleteAccessPolicy)="handleDeleteAccessPolicy($event)" [columnTitle]="'machineAccounts' | i18n"
(onUpdateAccessPolicy)="handleUpdateAccessPolicy($event)" [emptyMessage]="'projectEmptyMachineAccountAccessPolicies' | i18n"
> >
</sm-access-selector> </sm-access-policy-selector>
</div> <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 { 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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { SelectItemView } from "@bitwarden/components";
import { ProjectServiceAccountsAccessPoliciesView } from "../../models/view/access-policy.view";
import { import {
ProjectAccessPoliciesView, ApItemValueType,
ServiceAccountProjectAccessPolicyView, convertToProjectServiceAccountsAccessPoliciesView,
} from "../../models/view/access-policy.view"; } 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 { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
import {
AccessSelectorComponent,
AccessSelectorRowView,
} from "../../shared/access-policies/access-selector.component";
@Component({ @Component({
selector: "sm-project-service-accounts", selector: "sm-project-service-accounts",
templateUrl: "./project-service-accounts.component.html", templateUrl: "./project-service-accounts.component.html",
}) })
export class ProjectServiceAccountsComponent implements OnInit, OnDestroy { export class ProjectServiceAccountsComponent implements OnInit, OnDestroy {
private currentAccessPolicies: ApItemViewType[];
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
private organizationId: string; private organizationId: string;
private projectId: string; private projectId: string;
protected rows$: Observable<AccessSelectorRowView[]> = private currentAccessPolicies$ = combineLatest([this.route.params]).pipe(
this.accessPolicyService.projectAccessPolicyChanges$.pipe( switchMap(([params]) =>
startWith(null), this.accessPolicyService
switchMap(() => .getProjectServiceAccountsAccessPolicies(params.organizationId, params.projectId)
this.accessPolicyService.getProjectAccessPolicies(this.organizationId, this.projectId), .then((policies) => {
), return convertProjectServiceAccountsViewToApItemViews(policies);
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,
})),
),
);
protected async handleUpdateAccessPolicy(policy: AccessSelectorRowView) { private potentialGrantees$ = combineLatest([this.route.params]).pipe(
try { switchMap(([params]) =>
return await this.accessPolicyService.updateAccessPolicy( this.accessPolicyService
AccessSelectorComponent.getBaseAccessPolicyView(policy), .getServiceAccountsPotentialGrantees(params.organizationId)
); .then((grantees) => {
} catch (e) { return convertPotentialGranteesToApItemViewType(grantees);
this.validationService.showError(e); }),
} ),
} );
protected handleCreateAccessPolicies(selected: SelectItemView[]) { protected formGroup = new FormGroup({
const projectAccessPoliciesView = new ProjectAccessPoliciesView(); accessPolicies: new FormControl([] as ApItemValueType[]),
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;
});
return this.accessPolicyService.createProjectAccessPolicies( protected loading = true;
this.organizationId, protected potentialGrantees: ApItemViewType[];
this.projectId, protected items: ApItemViewType[];
projectAccessPoliciesView,
);
}
protected async handleDeleteAccessPolicy(policy: AccessSelectorRowView) {
try {
await this.accessPolicyService.deleteAccessPolicy(policy.accessPolicyId);
} catch (e) {
this.validationService.showError(e);
}
}
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private validationService: ValidationService, private validationService: ValidationService,
private accessPolicyService: AccessPolicyService, private accessPolicyService: AccessPolicyService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -95,10 +71,97 @@ export class ProjectServiceAccountsComponent implements OnInit, OnDestroy {
this.organizationId = params.organizationId; this.organizationId = params.organizationId;
this.projectId = params.projectId; 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 { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); 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"> <form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
<p class="tw-mt-6"> <div class="tw-w-2/5">
{{ "machineAccountProjectsDescription" | i18n }} <p class="tw-mt-8" *ngIf="!loading">
</p> {{ "machineAccountProjectsDescription" | i18n }}
<sm-access-selector </p>
[rows]="rows$ | async" <sm-access-policy-selector
granteeType="projects" [loading]="loading"
[label]="'projects' | i18n" formControlName="accessPolicies"
[hint]="'newSaSelectAccess' | i18n" [addButtonMode]="true"
[columnTitle]="'projects' | i18n" [items]="potentialGrantees"
[emptyMessage]="'serviceAccountEmptyProjectAccesspolicies' | i18n" [label]="'projects' | i18n"
(onCreateAccessPolicies)="handleCreateAccessPolicies($event)" [hint]="'newSaSelectAccess' | i18n"
(onDeleteAccessPolicy)="handleDeleteAccessPolicy($event)" [columnTitle]="'projects' | i18n"
(onUpdateAccessPolicy)="handleUpdateAccessPolicy($event)" [emptyMessage]="'serviceAccountEmptyProjectAccesspolicies' | i18n"
> >
</sm-access-selector> </sm-access-policy-selector>
</div> <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 { 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 { 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 { ServiceAccountGrantedPoliciesView } from "../../models/view/access-policy.view";
import { AccessPolicyService } from "../../shared/access-policies/access-policy.service";
import { import {
AccessSelectorComponent, ApItemValueType,
AccessSelectorRowView, convertToServiceAccountGrantedPoliciesView,
} from "../../shared/access-policies/access-selector.component"; } 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({ @Component({
selector: "sm-service-account-projects", selector: "sm-service-account-projects",
templateUrl: "./service-account-projects.component.html", templateUrl: "./service-account-projects.component.html",
}) })
export class ServiceAccountProjectsComponent implements OnInit, OnDestroy { export class ServiceAccountProjectsComponent implements OnInit, OnDestroy {
private currentAccessPolicies: ApItemViewType[];
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
private serviceAccountId: string;
private organizationId: string; private organizationId: string;
private serviceAccountId: string;
protected rows$: Observable<AccessSelectorRowView[]> = private currentAccessPolicies$ = combineLatest([this.route.params]).pipe(
this.accessPolicyService.serviceAccountGrantedPolicyChanges$.pipe( switchMap(([params]) =>
startWith(null), this.accessPolicyService
combineLatestWith(this.route.params), .getServiceAccountGrantedPolicies(params.organizationId, params.serviceAccountId)
switchMap(([_, params]) => .then((policies) => {
this.accessPolicyService.getGrantedPolicies(params.serviceAccountId, params.organizationId), return convertGrantedPoliciesToAccessPolicyItemViews(policies);
), }),
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;
});
}),
);
protected handleCreateAccessPolicies(selected: SelectItemView[]) { private potentialGrantees$ = combineLatest([this.route.params]).pipe(
const serviceAccountProjectAccessPolicyView = selected switchMap(([params]) =>
.filter((selection) => AccessSelectorComponent.getAccessItemType(selection) === "project") this.accessPolicyService
.map((filtered) => { .getProjectsPotentialGrantees(params.organizationId)
const view = new ServiceAccountProjectAccessPolicyView(); .then((grantees) => {
view.serviceAccountId = this.serviceAccountId; return convertPotentialGranteesToApItemViewType(grantees);
view.grantedProjectId = filtered.id; }),
view.read = true; ),
view.write = false; );
return view;
});
return this.accessPolicyService.createGrantedPolicies( protected formGroup = new FormGroup({
this.organizationId, accessPolicies: new FormControl([] as ApItemValueType[]),
this.serviceAccountId, });
serviceAccountProjectAccessPolicyView,
);
}
protected async handleUpdateAccessPolicy(policy: AccessSelectorRowView) { protected loading = true;
try { protected potentialGrantees: ApItemViewType[];
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);
}
}
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private validationService: ValidationService, private validationService: ValidationService,
private accessPolicyService: AccessPolicyService, private accessPolicyService: AccessPolicyService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -92,10 +70,119 @@ export class ServiceAccountProjectsComponent implements OnInit, OnDestroy {
this.organizationId = params.organizationId; this.organizationId = params.organizationId;
this.serviceAccountId = params.serviceAccountId; 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 { ngOnDestroy(): void {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); 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 bitRow
*ngFor="let item of selectionList.selectedItems; let i = index" *ngFor="let item of selectionList.selectedItems; let i = index"
[formGroupName]="i" [formGroupName]="i"
[ngClass]="{ 'tw-text-muted': item.readOnly }"
> >
<td bitCell class="tw-w-0 tw-pr-0"> <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>
<td bitCell class="tw-max-w-sm tw-truncate">{{ item.labelName }}</td>
<td bitCell class="tw-mb-auto tw-inline-block tw-w-auto"> <td bitCell class="tw-mb-auto tw-inline-block tw-w-auto">
<select <select
*ngIf="!staticPermission; else static" *ngIf="!staticPermission && !item.readOnly; else readOnly"
bitInput bitInput
formControlName="permission" formControlName="permission"
(blur)="handleBlur()" (blur)="handleBlur()"
@@ -45,12 +48,20 @@
{{ p.labelId | i18n }} {{ p.labelId | i18n }}
</option> </option>
</select> </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> <ng-template #static>
<span>{{ staticPermission | i18n }}</span> <span>{{ staticPermission | i18n }}</span>
</ng-template> </ng-template>
</td> </td>
<td bitCell class="tw-w-0"> <td bitCell class="tw-w-0">
<button <button
*ngIf="!item.readOnly"
type="button" type="button"
bitIconButton="bwi-close" bitIconButton="bwi-close"
buttonType="main" buttonType="main"

View File

@@ -35,6 +35,34 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn
private notifyOnTouch: () => void; private notifyOnTouch: () => void;
private pauseChangeNotification: boolean; 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. * 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 * 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), currentUserInGroup: new FormControl(currentUserInGroup),
currentUser: new FormControl(currentUser), currentUser: new FormControl(currentUser),
}); });
this.updateRowControlDisableState(fg, item);
return fg; return fg;
}, this._itemComparator.bind(this)); }, this._itemComparator.bind(this));
@@ -100,7 +131,13 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn
set items(val: ApItemViewType[]) { set items(val: ApItemViewType[]) {
if (val != null) { 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( this.selectionList.populateItems(
val.map((m) => { val.map((m) => {
m.icon = m.icon ?? ApItemEnumUtil.itemIcon(m.type); m.icon = m.icon ?? ApItemEnumUtil.itemIcon(m.type);
@@ -137,6 +174,9 @@ export class AccessPolicySelectorComponent implements ControlValueAccessor, OnIn
} else { } else {
this.formGroup.enable(); this.formGroup.enable();
this.multiSelectFormGroup.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 // Always clear the internal selection list on a new value
this.selectionList.deselectAll(); 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 the new value is null, then we're done
if (selectedItems == null) { if (selectedItems == null) {
this.pauseChangeNotification = false; this.pauseChangeNotification = false;

View File

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

View File

@@ -5,6 +5,10 @@ import {
ServiceAccountPeopleAccessPoliciesView, ServiceAccountPeopleAccessPoliciesView,
UserServiceAccountAccessPolicyView, UserServiceAccountAccessPolicyView,
GroupServiceAccountAccessPolicyView, GroupServiceAccountAccessPolicyView,
ServiceAccountGrantedPoliciesView,
ServiceAccountProjectPolicyPermissionDetailsView,
ServiceAccountProjectAccessPolicyView,
ProjectServiceAccountsAccessPoliciesView,
} from "../../../../models/view/access-policy.view"; } from "../../../../models/view/access-policy.view";
import { ApItemEnum } from "./enums/ap-item.enum"; import { ApItemEnum } from "./enums/ap-item.enum";
@@ -76,3 +80,46 @@ export function convertToServiceAccountPeopleAccessPoliciesView(
}); });
return view; 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 { import {
ProjectPeopleAccessPoliciesView, ProjectPeopleAccessPoliciesView,
ServiceAccountGrantedPoliciesView,
ProjectServiceAccountsAccessPoliciesView,
ServiceAccountPeopleAccessPoliciesView, ServiceAccountPeopleAccessPoliciesView,
} from "../../../../models/view/access-policy.view"; } from "../../../../models/view/access-policy.view";
import { PotentialGranteeView } from "../../../../models/view/potential-grantee.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 & { export type ApItemViewType = SelectItemView & {
accessPolicyId?: string; accessPolicyId?: string;
permission?: ApPermissionEnum; 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; type: ApItemEnum.User;
@@ -47,6 +55,7 @@ export function convertToAccessPolicyItemViews(
permission: ApPermissionEnumUtil.toApPermissionEnum(policy.read, policy.write), permission: ApPermissionEnumUtil.toApPermissionEnum(policy.read, policy.write),
userId: policy.userId, userId: policy.userId,
currentUser: policy.currentUser, currentUser: policy.currentUser,
readOnly: false,
}); });
}); });
@@ -60,12 +69,59 @@ export function convertToAccessPolicyItemViews(
listName: policy.groupName, listName: policy.groupName,
permission: ApPermissionEnumUtil.toApPermissionEnum(policy.read, policy.write), permission: ApPermissionEnumUtil.toApPermissionEnum(policy.read, policy.write),
currentUserInGroup: policy.currentUserInGroup, currentUserInGroup: policy.currentUserInGroup,
readOnly: false,
}); });
}); });
return accessPolicies; 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( export function convertPotentialGranteesToApItemViewType(
grantees: PotentialGranteeView[], grantees: PotentialGranteeView[],
): ApItemViewType[] { ): ApItemViewType[] {
@@ -108,6 +164,7 @@ export function convertPotentialGranteesToApItemViewType(
listName: listName, listName: listName,
currentUserInGroup: granteeView.currentUserInGroup, currentUserInGroup: granteeView.currentUserInGroup,
currentUser: granteeView.currentUser, currentUser: granteeView.currentUser,
readOnly: false,
}; };
}); });
} }

View File

@@ -18,15 +18,19 @@ import {
UserProjectAccessPolicyView, UserProjectAccessPolicyView,
UserServiceAccountAccessPolicyView, UserServiceAccountAccessPolicyView,
ServiceAccountPeopleAccessPoliciesView, ServiceAccountPeopleAccessPoliciesView,
ServiceAccountGrantedPoliciesView,
ProjectServiceAccountsAccessPoliciesView,
ServiceAccountProjectPolicyPermissionDetailsView,
} from "../../models/view/access-policy.view"; } from "../../models/view/access-policy.view";
import { PotentialGranteeView } from "../../models/view/potential-grantee.view"; import { PotentialGranteeView } from "../../models/view/potential-grantee.view";
import { AccessPoliciesCreateRequest } from "../../shared/access-policies/models/requests/access-policies-create.request"; 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 { PeopleAccessPoliciesRequest } from "../../shared/access-policies/models/requests/people-access-policies.request";
import { ProjectAccessPoliciesResponse } from "../../shared/access-policies/models/responses/project-access-policies.response"; 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 { AccessPolicyUpdateRequest } from "./models/requests/access-policy-update.request";
import { AccessPolicyRequest } from "./models/requests/access-policy.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 { import {
GroupServiceAccountAccessPolicyResponse, GroupServiceAccountAccessPolicyResponse,
UserServiceAccountAccessPolicyResponse, UserServiceAccountAccessPolicyResponse,
@@ -36,28 +40,22 @@ import {
} from "./models/responses/access-policy.response"; } from "./models/responses/access-policy.response";
import { PotentialGranteeResponse } from "./models/responses/potential-grantee.response"; import { PotentialGranteeResponse } from "./models/responses/potential-grantee.response";
import { ProjectPeopleAccessPoliciesResponse } from "./models/responses/project-people-access-policies.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 { ServiceAccountPeopleAccessPoliciesResponse } from "./models/responses/service-account-people-access-policies.response";
import { ServiceAccountProjectPolicyPermissionDetailsResponse } from "./models/responses/service-account-project-policy-permission-details.response";
@Injectable({ @Injectable({
providedIn: "root", providedIn: "root",
}) })
export class AccessPolicyService { export class AccessPolicyService {
private _projectAccessPolicyChanges$ = new Subject<ProjectAccessPoliciesView>(); private _projectAccessPolicyChanges$ = new Subject<ProjectAccessPoliciesView>();
private _serviceAccountGrantedPolicyChanges$ = new Subject<
ServiceAccountProjectAccessPolicyView[]
>();
/** /**
* Emits when a project access policy is created or deleted. * Emits when a project access policy is created or deleted.
*/ */
readonly projectAccessPolicyChanges$ = this._projectAccessPolicyChanges$.asObservable(); readonly projectAccessPolicyChanges$ = this._projectAccessPolicyChanges$.asObservable();
/**
* Emits when a service account granted policy is created or deleted.
*/
readonly serviceAccountGrantedPolicyChanges$ =
this._serviceAccountGrantedPolicyChanges$.asObservable();
constructor( constructor(
private cryptoService: CryptoService, private cryptoService: CryptoService,
protected apiService: ApiService, protected apiService: ApiService,
@@ -68,44 +66,6 @@ export class AccessPolicyService {
this._projectAccessPolicyChanges$.next(null); 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( async getProjectAccessPolicies(
organizationId: string, organizationId: string,
projectId: string, projectId: string,
@@ -184,6 +144,74 @@ export class AccessPolicyService {
return this.createServiceAccountPeopleAccessPoliciesView(results); 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( async createProjectAccessPolicies(
organizationId: string, organizationId: string,
projectId: string, projectId: string,
@@ -206,7 +234,6 @@ export class AccessPolicyService {
async deleteAccessPolicy(accessPolicyId: string): Promise<void> { async deleteAccessPolicy(accessPolicyId: string): Promise<void> {
await this.apiService.send("DELETE", "/access-policies/" + accessPolicyId, null, true, false); await this.apiService.send("DELETE", "/access-policies/" + accessPolicyId, null, true, false);
this._projectAccessPolicyChanges$.next(null); this._projectAccessPolicyChanges$.next(null);
this._serviceAccountGrantedPolicyChanges$.next(null);
} }
async updateAccessPolicy(baseAccessPolicyView: BaseAccessPolicyView): Promise<void> { 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( private async createProjectAccessPoliciesView(
organizationId: string, organizationId: string,
projectAccessPoliciesResponse: ProjectAccessPoliciesResponse, projectAccessPoliciesResponse: ProjectAccessPoliciesResponse,
@@ -394,146 +585,18 @@ export class AccessPolicyService {
}; };
} }
async getPeoplePotentialGrantees(organizationId: string) { private async createProjectServiceAccountsAccessPoliciesView(
const r = await this.apiService.send( response: ProjectServiceAccountsAccessPoliciesResponse,
"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, organizationId: string,
results: PotentialGranteeResponse[], ): Promise<ProjectServiceAccountsAccessPoliciesView> {
): Promise<PotentialGranteeView[]> {
const orgKey = await this.getOrganizationKey(organizationId); 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") { const view = new ProjectServiceAccountsAccessPoliciesView();
view.name = r.name view.serviceAccountAccessPolicies = await Promise.all(
? await this.encryptService.decryptToUtf8(new EncString(r.name), orgKey) response.serviceAccountAccessPolicies.map(async (ap) => {
: null; return await this.createServiceAccountProjectAccessPolicyView(orgKey, ap);
} 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;
}), }),
); );
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, CipherServiceAbstraction,
FolderServiceAbstraction, FolderServiceAbstraction,
CollectionServiceAbstraction, CollectionServiceAbstraction,
CryptoServiceAbstraction,
PlatformUtilsServiceAbstraction, PlatformUtilsServiceAbstraction,
MessagingServiceAbstraction, MessagingServiceAbstraction,
SearchServiceAbstraction, 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 // We don't want to copy passkeys when we clone a cipher
if (this.cloneMode && this.cipher?.login?.hasFido2Credentials) { if (this.cloneMode && this.cipher?.login?.hasFido2Credentials) {

View File

@@ -296,10 +296,6 @@ export abstract class CryptoService {
kdfConfig: KdfConfig, kdfConfig: KdfConfig,
oldPinKey: EncString, oldPinKey: EncString,
): Promise<UserKey>; ): 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 * @param keyMaterial The key material to derive the send key from
* @returns A new send key * @returns A new send key

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