1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 18:33:50 +00:00

Desktop/pm 18769/migrate vault filters (#17919)

Migrated vault filters to new v3 vault's navigation

* Decoupled existing vault filtering from vault component by using routed params with routed-vault-filter-bridge
* Converted vault filters to standalone components
* Removed extending filter Base Components from deprecated /libs/angular library and handled logic directly
* Moved shared 'models' and 'services' directories from web-vault into /libs/vault
This commit is contained in:
Leslie Xiong
2026-01-15 10:17:00 -05:00
committed by jaasen-livefront
parent 7088447046
commit acad754540
142 changed files with 1219 additions and 509 deletions

View File

@@ -1,10 +1,12 @@
import { Observable } from "rxjs";
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
import {
CollectionAdminView,
CollectionAccessSelectionView,
CollectionDetailsResponse,
} from "@bitwarden/common/admin-console/models/collections";
import { UserId } from "@bitwarden/common/types/guid";
import { CollectionAccessSelectionView, CollectionAdminView } from "../models";
export abstract class CollectionAdminService {
abstract collectionAdminViews$(
organizationId: string,

View File

@@ -1,11 +1,14 @@
import { Observable } from "rxjs";
import {
CollectionView,
Collection,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CollectionData, Collection, CollectionView } from "../models";
export abstract class CollectionService {
abstract encryptedCollections$(userId: UserId): Observable<Collection[] | null>;
abstract decryptedCollections$(userId: UserId): Observable<CollectionView[]>;

View File

@@ -1,30 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { View } from "@bitwarden/common/models/view/view";
interface SelectionResponseLike {
id: string;
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
}
export class CollectionAccessSelectionView extends View {
readonly id: string;
readonly readOnly: boolean;
readonly hidePasswords: boolean;
readonly manage: boolean;
constructor(response?: SelectionResponseLike) {
super();
if (!response) {
return;
}
this.id = response.id;
this.readOnly = response.readOnly;
this.hidePasswords = response.hidePasswords;
this.manage = response.manage;
}
}

View File

@@ -1,180 +0,0 @@
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionAccessSelectionView } from "./collection-access-selection.view";
import { CollectionAccessDetailsResponse, CollectionResponse } from "./collection.response";
import { CollectionView } from "./collection.view";
// TODO: this is used to represent the pseudo "Unassigned" collection as well as
// the user's personal vault (as a pseudo organization). This should be separated out into different values.
export const Unassigned = "unassigned";
export type Unassigned = typeof Unassigned;
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];
users: CollectionAccessSelectionView[] = [];
/**
* Flag indicating the collection has no active user or group assigned to it with CanManage permissions
* In this case, the collection can be managed by admins/owners or custom users with appropriate permissions
*/
unmanaged: boolean = false;
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean = false;
/**
* Returns true if the user can edit a collection (including user and group access) from the Admin Console.
*/
override canEdit(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return (
org?.canEditAnyCollection ||
(this.unmanaged && org?.canEditUnmanagedCollections) ||
super.canEdit(org)
);
}
/**
* Returns true if the user can delete a collection from the Admin Console.
*/
override canDelete(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return org?.canDeleteAnyCollection || super.canDelete(org);
}
/**
* Whether the user can modify user access to this collection
*/
canEditUserAccess(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return (
(org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org)
);
}
/**
* Whether the user can modify group access to this collection
*/
canEditGroupAccess(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return (
(org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) ||
this.canEdit(org)
);
}
/**
* Returns true if the user can view collection info and access in a read-only state from the Admin Console
*/
override canViewCollectionInfo(org: Organization | undefined): boolean {
if (this.isUnassignedCollection || this.isDefaultCollection) {
return false;
}
const isAdmin = org?.isAdmin ?? false;
const permissions = org?.permissions.editAnyCollection ?? false;
return this.manage || isAdmin || permissions;
}
/**
* True if this collection represents the pseudo "Unassigned" collection
* This is different from the "unmanaged" flag, which indicates that no users or groups have access to the collection
*/
get isUnassignedCollection() {
return this.id === Unassigned;
}
/**
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
* that were DefaultUserCollections but where the relevant user has been offboarded.
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
* Do not edit or remove this unless you understand why.
*/
override canEditName(org: Organization): boolean {
return (this.canEdit(org) && !this.defaultUserCollectionEmail) || super.canEditName(org);
}
static async fromCollectionAccessDetails(
collection: CollectionAccessDetailsResponse,
encryptService: EncryptService,
orgKey: OrgKey,
): Promise<CollectionAdminView> {
const view = new CollectionAdminView({ ...collection });
try {
view.name = await encryptService.decryptString(new EncString(view.name), orgKey);
} catch (e) {
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
// eslint-disable-next-line no-console
console.error(
"[CollectionAdminView/fromCollectionAccessDetails] Error decrypting collection name",
e,
);
throw e;
}
view.assigned = collection.assigned;
view.readOnly = collection.readOnly;
view.hidePasswords = collection.hidePasswords;
view.manage = collection.manage;
view.unmanaged = collection.unmanaged;
view.type = collection.type;
view.externalId = collection.externalId;
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
view.groups = collection.groups
? collection.groups.map((g) => new CollectionAccessSelectionView(g))
: [];
view.users = collection.users
? collection.users.map((g) => new CollectionAccessSelectionView(g))
: [];
return view;
}
static async fromCollectionResponse(
collection: CollectionResponse,
encryptService: EncryptService,
orgKey: OrgKey,
): Promise<CollectionAdminView> {
let collectionName: string;
try {
collectionName = await encryptService.decryptString(new EncString(collection.name), orgKey);
} catch (e) {
// Note: This should be updated by the owning team with appropriate, domain specific behavior
// eslint-disable-next-line no-console
console.error(
"[CollectionAdminView/fromCollectionResponse] Failed to decrypt the collection name",
e,
);
throw e;
}
const collectionAdminView = new CollectionAdminView({
id: collection.id,
name: collectionName,
organizationId: collection.organizationId,
});
collectionAdminView.externalId = collection.externalId;
return collectionAdminView;
}
}

View File

@@ -1,4 +1,5 @@
import { Collection } from "./collection";
import { Collection } from "@bitwarden/common/admin-console/models/collections";
import { BaseCollectionRequest } from "./collection.request";
export class CollectionWithIdRequest extends BaseCollectionRequest {

View File

@@ -1,37 +0,0 @@
import { Jsonify } from "type-fest";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType, CollectionTypes } from "./collection";
import { CollectionDetailsResponse } from "./collection.response";
export class CollectionData {
id: CollectionId;
organizationId: OrganizationId;
name: string;
defaultUserCollectionEmail: string | undefined;
externalId: string | undefined;
readOnly: boolean = false;
manage: boolean = false;
hidePasswords: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection;
constructor(response: CollectionDetailsResponse) {
this.id = response.id;
this.organizationId = response.organizationId;
this.name = response.name;
this.externalId = response.externalId;
this.readOnly = response.readOnly;
this.manage = response.manage;
this.hidePasswords = response.hidePasswords;
this.type = response.type;
this.defaultUserCollectionEmail = response.defaultUserCollectionEmail;
}
static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null {
if (obj == null) {
return null;
}
return Object.assign(new CollectionData(new CollectionDetailsResponse({})), obj);
}
}

View File

@@ -1,68 +0,0 @@
import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/models/response/selection-read-only.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CollectionType, CollectionTypes } from "./collection";
export class CollectionResponse extends BaseResponse {
id: CollectionId;
organizationId: OrganizationId;
name: string;
defaultUserCollectionEmail: string | undefined;
externalId: string | undefined;
type: CollectionType = CollectionTypes.SharedCollection;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name");
this.externalId = this.getResponseProperty("ExternalId");
this.defaultUserCollectionEmail = this.getResponseProperty("DefaultUserCollectionEmail");
this.type = this.getResponseProperty("Type") ?? CollectionTypes.SharedCollection;
}
}
export class CollectionDetailsResponse extends CollectionResponse {
readOnly: boolean;
manage: boolean;
hidePasswords: boolean;
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean;
constructor(response: any) {
super(response);
this.readOnly = this.getResponseProperty("ReadOnly") || false;
this.manage = this.getResponseProperty("Manage") || false;
this.hidePasswords = this.getResponseProperty("HidePasswords") || false;
// Temporary until the API is updated to return this property in AC-2084
// For now, we can assume that if the object is 'collectionDetails' then the user is assigned
this.assigned = this.getResponseProperty("object") == "collectionDetails";
}
}
export class CollectionAccessDetailsResponse extends CollectionDetailsResponse {
groups: SelectionReadOnlyResponse[] = [];
users: SelectionReadOnlyResponse[] = [];
unmanaged: boolean;
constructor(response: any) {
super(response);
this.assigned = this.getResponseProperty("Assigned") || false;
this.unmanaged = this.getResponseProperty("Unmanaged") || false;
const groups = this.getResponseProperty("Groups");
if (groups != null) {
this.groups = groups.map((g: any) => new SelectionReadOnlyResponse(g));
}
const users = this.getResponseProperty("Users");
if (users != null) {
this.users = users.map((g: any) => new SelectionReadOnlyResponse(g));
}
}
}

View File

@@ -1,15 +1,17 @@
import { MockProxy, mock } from "jest-mock-extended";
import {
CollectionDetailsResponse,
Collection,
CollectionTypes,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { makeSymmetricCryptoKey } from "@bitwarden/common/spec";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { Collection, CollectionTypes } from "./collection";
import { CollectionData } from "./collection.data";
import { CollectionDetailsResponse } from "./collection.response";
describe("Collection", () => {
let data: CollectionData;
let encService: MockProxy<EncryptService>;

View File

@@ -1,84 +0,0 @@
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data";
import { CollectionView } from "./collection.view";
export const CollectionTypes = {
SharedCollection: 0,
DefaultUserCollection: 1,
} as const;
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
export class Collection extends Domain {
id: CollectionId;
organizationId: OrganizationId;
name: EncString;
externalId: string | undefined;
readOnly: boolean = false;
hidePasswords: boolean = false;
manage: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection;
defaultUserCollectionEmail: string | undefined;
constructor(c: { id: CollectionId; name: EncString; organizationId: OrganizationId }) {
super();
this.id = c.id;
this.name = c.name;
this.organizationId = c.organizationId;
}
static fromCollectionData(obj: CollectionData): Collection {
if (obj == null || obj.name == null || obj.organizationId == null) {
throw new Error("CollectionData must contain name and organizationId.");
}
const collection = new Collection({
...obj,
name: new EncString(obj.name),
});
collection.externalId = obj.externalId;
collection.readOnly = obj.readOnly;
collection.hidePasswords = obj.hidePasswords;
collection.manage = obj.manage;
collection.type = obj.type;
collection.defaultUserCollectionEmail = obj.defaultUserCollectionEmail;
return collection;
}
static async fromCollectionView(
view: CollectionView,
encryptService: EncryptService,
orgKey: OrgKey,
): Promise<Collection> {
const collection = new Collection({
name: await encryptService.encryptString(view.name, orgKey),
id: view.id,
organizationId: view.organizationId,
});
collection.externalId = view.externalId;
collection.readOnly = view.readOnly;
collection.hidePasswords = view.hidePasswords;
collection.manage = view.manage;
collection.type = view.type;
return collection;
}
decrypt(orgKey: OrgKey, encryptService: EncryptService): Promise<CollectionView> {
return CollectionView.fromCollection(this, encryptService, orgKey);
}
// @TODO: This would be better off in Collection.Utils. Move this there when
// refactoring to a shared lib.
static isCollectionId(id: any): id is CollectionId {
return typeof id === "string" && id != null;
}
}

View File

@@ -1,170 +0,0 @@
import { Jsonify } from "type-fest";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { View } from "@bitwarden/common/models/view/view";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { Collection, CollectionType, CollectionTypes } from "./collection";
import { CollectionAccessDetailsResponse } from "./collection.response";
export const NestingDelimiter = "/";
export class CollectionView implements View, ITreeNodeObject {
id: CollectionId;
organizationId: OrganizationId;
externalId: string | undefined;
// readOnly applies to the items within a collection
readOnly: boolean = false;
hidePasswords: boolean = false;
manage: boolean = false;
assigned: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection;
defaultUserCollectionEmail: string | undefined;
private _name: string;
constructor(c: { id: CollectionId; organizationId: OrganizationId; name: string }) {
this.id = c.id;
this.organizationId = c.organizationId;
this._name = c.name;
}
set name(name: string) {
this._name = name;
}
get name(): string {
return this.defaultUserCollectionEmail ?? this._name;
}
canEditItems(org: Organization): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
);
}
return org?.canEditAllCiphers || this.manage || (this.assigned && !this.readOnly);
}
/**
* Returns true if the user can edit a collection (including user and group access) from the individual vault.
* Does not include admin permissions - see {@link CollectionAdminView.canEdit}.
*/
canEdit(org: Organization | undefined): boolean {
if (this.isDefaultCollection) {
return false;
}
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
);
}
return this.manage;
}
/**
* Returns true if the user can delete a collection from the individual vault.
* Does not include admin permissions - see {@link CollectionAdminView.canDelete}.
*/
canDelete(org: Organization | undefined): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
);
}
const canDeleteManagedCollections = !org?.limitCollectionDeletion || org.isAdmin;
// Only use individual permissions, not admin permissions
return canDeleteManagedCollections && this.manage && !this.isDefaultCollection;
}
/**
* Returns true if the user can view collection info and access in a read-only state from the individual vault
*/
canViewCollectionInfo(org: Organization | undefined): boolean {
return false;
}
/**
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
* that were DefaultUserCollections but where the relevant user has been offboarded.
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
* Do not edit or remove this unless you understand why.
*/
canEditName(org: Organization): boolean {
return this.canEdit(org) && !this.defaultUserCollectionEmail;
}
get isDefaultCollection() {
return this.type == CollectionTypes.DefaultUserCollection;
}
// FIXME: we should not use a CollectionView object for the vault filter header because it is not a real
// CollectionView and this violates ts-strict rules.
static vaultFilterHead(): CollectionView {
return new CollectionView({
id: "" as CollectionId,
organizationId: "" as OrganizationId,
name: "",
});
}
static async fromCollection(
collection: Collection,
encryptService: EncryptService,
key: OrgKey,
): Promise<CollectionView> {
const view = new CollectionView({ ...collection, name: "" });
view.name = await encryptService.decryptString(collection.name, key);
view.assigned = true;
view.externalId = collection.externalId;
view.readOnly = collection.readOnly;
view.hidePasswords = collection.hidePasswords;
view.manage = collection.manage;
view.type = collection.type;
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
return view;
}
static async fromCollectionAccessDetails(
collection: CollectionAccessDetailsResponse,
encryptService: EncryptService,
orgKey: OrgKey,
): Promise<CollectionView> {
const view = new CollectionView({ ...collection });
try {
view.name = await encryptService.decryptString(new EncString(collection.name), orgKey);
} catch (e) {
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
// eslint-disable-next-line no-console
console.error("[CollectionView] Error decrypting collection name", e);
throw e;
}
view.externalId = collection.externalId;
view.type = collection.type;
view.assigned = collection.assigned;
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
return view;
}
static fromJSON(obj: Jsonify<CollectionView>) {
return Object.assign(new CollectionView({ ...obj }), obj);
}
encrypt(orgKey: OrgKey, encryptService: EncryptService): Promise<Collection> {
return Collection.fromCollectionView(this, encryptService, orgKey);
}
}

View File

@@ -1,9 +1,3 @@
export * from "./bulk-collection-access.request";
export * from "./collection-access-selection.view";
export * from "./collection-admin.view";
export * from "./collection";
export * from "./collection.data";
export * from "./collection.view";
export * from "./collection.request";
export * from "./collection.response";
export * from "./collection-with-id.request";

View File

@@ -1,5 +1,6 @@
import { Jsonify } from "type-fest";
import { CollectionView, CollectionData } from "@bitwarden/common/admin-console/models/collections";
import {
COLLECTION_DISK,
COLLECTION_MEMORY,
@@ -7,8 +8,6 @@ import {
} from "@bitwarden/common/platform/state";
import { CollectionId } from "@bitwarden/common/types/guid";
import { CollectionData, CollectionView } from "../models";
export const ENCRYPTED_COLLECTION_DATA_KEY = UserKeyDefinition.record<CollectionData, CollectionId>(
COLLECTION_DISK,
"collections",

View File

@@ -5,6 +5,14 @@ import {
getOrganizationById,
OrganizationService,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import {
CollectionAccessSelectionView,
CollectionAdminView,
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -13,13 +21,7 @@ import { KeyService } from "@bitwarden/key-management";
import { CollectionAdminService, CollectionService } from "../abstractions";
import {
CollectionData,
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
BulkCollectionAccessRequest,
CollectionAccessSelectionView,
CollectionAdminView,
BaseCollectionRequest,
UpdateCollectionRequest,
CreateCollectionRequest,

View File

@@ -1,6 +1,11 @@
import { mock, MockProxy } from "jest-mock-extended";
import { combineLatest, first, firstValueFrom, of, ReplaySubject, takeWhile } from "rxjs";
import {
CollectionView,
CollectionTypes,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -18,8 +23,6 @@ import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { CollectionData, CollectionTypes, CollectionView } from "../models";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
import { DefaultCollectionService } from "./default-collection.service";

View File

@@ -12,6 +12,11 @@ import {
switchMap,
} from "rxjs";
import {
CollectionView,
Collection,
CollectionData,
} from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -23,7 +28,6 @@ import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { KeyService } from "@bitwarden/key-management";
import { CollectionService } from "../abstractions/collection.service";
import { Collection, CollectionData, CollectionView } from "../models";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";