Merge branch 'main' into billing/AC-1970/provider-billing-area

This commit is contained in:
Alex Morask 2024-05-02 07:47:05 -04:00
commit e24acdcc83
No known key found for this signature in database
GPG Key ID: 23E38285B743E3A8
26 changed files with 363 additions and 261 deletions

View File

@ -212,6 +212,8 @@ import { UpdateBadge } from "../platform/listeners/update-badge";
/* eslint-disable no-restricted-imports */
import { ChromeMessageSender } from "../platform/messaging/chrome-message.sender";
/* eslint-enable no-restricted-imports */
import { OffscreenDocumentService } from "../platform/offscreen-document/abstractions/offscreen-document";
import { DefaultOffscreenDocumentService } from "../platform/offscreen-document/offscreen-document.service";
import { BrowserStateService as StateServiceAbstraction } from "../platform/services/abstractions/browser-state.service";
import { BrowserCryptoService } from "../platform/services/browser-crypto.service";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
@ -336,6 +338,7 @@ export default class MainBackground {
userAutoUnlockKeyService: UserAutoUnlockKeyService;
scriptInjectorService: BrowserScriptInjectorService;
kdfConfigService: kdfConfigServiceAbstraction;
offscreenDocumentService: OffscreenDocumentService;
onUpdatedRan: boolean;
onReplacedRan: boolean;
@ -393,11 +396,14 @@ export default class MainBackground {
),
);
this.offscreenDocumentService = new DefaultOffscreenDocumentService();
this.platformUtilsService = new BackgroundPlatformUtilsService(
this.messagingService,
(clipboardValue, clearMs) => this.clearClipboard(clipboardValue, clearMs),
async () => this.biometricUnlock(),
self,
this.offscreenDocumentService,
);
// Creates a session key for mv3 storage of large memory items
@ -737,7 +743,6 @@ export default class MainBackground {
this.cipherService,
this.folderService,
this.collectionService,
this.cryptoService,
this.platformUtilsService,
this.messagingService,
this.searchService,

View File

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

View File

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

View File

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

View File

@ -558,34 +558,6 @@ export class BrowserApi {
chrome.privacy.services.passwordSavingEnabled.set({ value });
}
/**
* Opens the offscreen document with the given reasons and justification.
*
* @param reasons - List of reasons for opening the offscreen document.
* @see https://developer.chrome.com/docs/extensions/reference/api/offscreen#type-Reason
* @param justification - Custom written justification for opening the offscreen document.
*/
static async createOffscreenDocument(reasons: chrome.offscreen.Reason[], justification: string) {
await chrome.offscreen.createDocument({
url: "offscreen-document/index.html",
reasons,
justification,
});
}
/**
* Closes the offscreen document.
*
* @param callback - Optional callback to execute after the offscreen document is closed.
*/
static closeOffscreenDocument(callback?: () => void) {
chrome.offscreen.closeDocument(() => {
if (callback) {
callback();
}
});
}
/**
* Handles registration of static content scripts within manifest v2.
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -100,6 +100,8 @@ import { runInsideAngular } from "../../platform/browser/run-inside-angular.oper
/* eslint-disable no-restricted-imports */
import { ChromeMessageSender } from "../../platform/messaging/chrome-message.sender";
/* eslint-enable no-restricted-imports */
import { OffscreenDocumentService } from "../../platform/offscreen-document/abstractions/offscreen-document";
import { DefaultOffscreenDocumentService } from "../../platform/offscreen-document/offscreen-document.service";
import BrowserPopupUtils from "../../platform/popup/browser-popup-utils";
import { BrowserFileDownloadService } from "../../platform/popup/services/browser-file-download.service";
import { BrowserStateService as StateServiceAbstraction } from "../../platform/services/abstractions/browser-state.service";
@ -287,9 +289,17 @@ const safeProviders: SafeProvider[] = [
useFactory: getBgService<DevicesServiceAbstraction>("devicesService"),
deps: [],
}),
safeProvider({
provide: OffscreenDocumentService,
useClass: DefaultOffscreenDocumentService,
deps: [],
}),
safeProvider({
provide: PlatformUtilsService,
useFactory: (toastService: ToastService) => {
useFactory: (
toastService: ToastService,
offscreenDocumentService: OffscreenDocumentService,
) => {
return new ForegroundPlatformUtilsService(
toastService,
(clipboardValue: string, clearMs: number) => {
@ -306,9 +316,10 @@ const safeProviders: SafeProvider[] = [
return response.result;
},
window,
offscreenDocumentService,
);
},
deps: [ToastService],
deps: [ToastService, OffscreenDocumentService],
}),
safeProvider({
provide: PasswordGenerationServiceAbstraction,

View File

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

View File

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

View File

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

View File

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

View File

@ -6480,6 +6480,9 @@
"editGroupCollectionsDesc": {
"message": "Grant access to collections by adding them to this group."
},
"editGroupCollectionsRestrictionsDesc": {
"message": "You can only assign collections you manage."
},
"accessAllCollectionsDesc": {
"message": "Grant access to all current and future collections."
},

View File

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

View File

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

View File

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

View File

@ -82,10 +82,6 @@ export abstract class StateService<T extends Account = Account> {
* @deprecated For migration purposes only, use getUserKeyMasterKey instead
*/
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>;
/**
* @deprecated For migration purposes only, use getUserKeyAuto instead
*/
getCryptoMasterKeyAuto: (options?: StorageOptions) => Promise<string>;
/**
* @deprecated For migration purposes only, use setUserKeyAuto instead
*/

View File

@ -930,35 +930,6 @@ export class CryptoService implements CryptoServiceAbstraction {
}
}
async migrateAutoKeyIfNeeded(userId?: UserId) {
const oldAutoKey = await this.stateService.getCryptoMasterKeyAuto({ userId: userId });
if (!oldAutoKey) {
return;
}
// Decrypt
const masterKey = new SymmetricCryptoKey(Utils.fromB64ToArray(oldAutoKey)) as MasterKey;
if (await this.isLegacyUser(masterKey, userId)) {
// Legacy users don't have a user key, so no need to migrate.
// Instead, set the master key for additional isLegacyUser checks that will log the user out.
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
await this.masterPasswordService.setMasterKey(masterKey, userId);
return;
}
const encryptedUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({
userId: userId,
});
const userKey = await this.decryptUserKeyWithMasterKey(
masterKey,
new EncString(encryptedUserKey),
userId,
);
// Migrate
await this.stateService.setUserKeyAutoUnlock(userKey.keyB64, { userId: userId });
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId });
// Set encrypted user key in case user immediately locks without syncing
await this.setMasterKeyEncryptedUserKey(encryptedUserKey);
}
async decryptAndMigrateOldPinKey(
masterPasswordOnRestart: boolean,
pin: string,

View File

@ -268,23 +268,6 @@ export class StateService<
);
}
/**
* @deprecated Use UserKeyAuto instead
*/
async getCryptoMasterKeyAuto(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(
this.reconcileOptions(options, { keySuffix: "auto" }),
await this.defaultSecureStorageOptions(),
);
if (options?.userId == null) {
return null;
}
return await this.secureStorageService.get<string>(
`${options.userId}${partialKeys.autoKey}`,
options,
);
}
/**
* @deprecated Use UserKeyAuto instead
*/

View File

@ -9,7 +9,6 @@ import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
@ -28,7 +27,6 @@ describe("VaultTimeoutService", () => {
let cipherService: MockProxy<CipherService>;
let folderService: MockProxy<FolderService>;
let collectionService: MockProxy<CollectionService>;
let cryptoService: MockProxy<CryptoService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let messagingService: MockProxy<MessagingService>;
let searchService: MockProxy<SearchService>;
@ -52,7 +50,6 @@ describe("VaultTimeoutService", () => {
cipherService = mock();
folderService = mock();
collectionService = mock();
cryptoService = mock();
platformUtilsService = mock();
messagingService = mock();
searchService = mock();
@ -76,7 +73,6 @@ describe("VaultTimeoutService", () => {
cipherService,
folderService,
collectionService,
cryptoService,
platformUtilsService,
messagingService,
searchService,

View File

@ -7,9 +7,7 @@ import { AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { InternalMasterPasswordServiceAbstraction } from "../../auth/abstractions/master-password.service.abstraction";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { ClientType } from "../../enums";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service";
@ -28,7 +26,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private cipherService: CipherService,
private folderService: FolderService,
private collectionService: CollectionService,
private cryptoService: CryptoService,
protected platformUtilsService: PlatformUtilsService,
private messagingService: MessagingService,
private searchService: SearchService,
@ -44,8 +41,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
if (this.inited) {
return;
}
// TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3483)
await this.migrateKeyForNeverLockIfNeeded();
this.inited = true;
if (checkOnInterval) {
@ -175,21 +170,4 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
? await this.logOut(userId)
: await this.lock(userId);
}
private async migrateKeyForNeverLockIfNeeded(): Promise<void> {
// Web can't set vault timeout to never
if (this.platformUtilsService.getClientType() == ClientType.Web) {
return;
}
const accounts = await firstValueFrom(this.stateService.accounts$);
for (const userId in accounts) {
if (userId != null) {
await this.cryptoService.migrateAutoKeyIfNeeded(userId);
// Legacy users should be logged out since we're not on the web vault and can't migrate.
if (await this.cryptoService.isLegacyUser(null, userId)) {
await this.logOut(userId);
}
}
}
}
}