mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 08:33:29 +00:00
Merge branch 'main' of github.com:bitwarden/clients into desktop/send-search-on-push
# Conflicts: # apps/desktop/src/app/tools/send-v2/send-v2.component.ts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<!-- Button space (always reserved) -->
|
||||
<div class="tw-my-5 tw-h-12">
|
||||
<button
|
||||
cdkFocusInitial
|
||||
bitButton
|
||||
[buttonType]="cardDetails.button.type"
|
||||
[block]="true"
|
||||
|
||||
@@ -27,13 +27,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
|
||||
import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view";
|
||||
import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text.view";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@@ -78,7 +78,7 @@ export class SendComponent implements OnInit, OnDestroy {
|
||||
protected ngZone: NgZone,
|
||||
protected searchService: SearchService,
|
||||
protected policyService: PolicyService,
|
||||
private logService: LogService,
|
||||
protected logService: LogService,
|
||||
protected sendApiService: SendApiService,
|
||||
protected dialogService: DialogService,
|
||||
protected toastService: ToastService,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// @ts-strict-ignore
|
||||
import { Directive, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
|
||||
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
import { firstValueFrom, Observable } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
|
||||
@@ -3,14 +3,14 @@ import { firstValueFrom, from, map, mergeMap, Observable, switchMap, take } from
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionTypes,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionAccessDetailsResponse,
|
||||
CollectionDetailsResponse,
|
||||
CollectionResponse,
|
||||
CreateCollectionRequest,
|
||||
UpdateCollectionRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
import { OrganizationConnectionType } from "../admin-console/enums";
|
||||
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
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";
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import {
|
||||
CollectionDetailsResponse,
|
||||
CollectionType,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
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;
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
CollectionType,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
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;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CollectionView } 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 Domain from "@bitwarden/common/platform/models/domain/domain-base";
|
||||
@@ -5,7 +6,6 @@ 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,
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./collection-access-selection.view";
|
||||
export * from "./collection-admin.view";
|
||||
export * from "./collection.view";
|
||||
export * from "./collection.response";
|
||||
export * from "./collection";
|
||||
export * from "./collection.data";
|
||||
@@ -1,8 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionResponse } from "@bitwarden/admin-console/common";
|
||||
import { CollectionResponse } from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { CipherResponse } from "../../../vault/models/response/cipher.response";
|
||||
|
||||
120
libs/common/src/admin-console/utils/collection-utils.spec.ts
Normal file
120
libs/common/src/admin-console/utils/collection-utils.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { getNestedCollectionTree, getFlatCollectionTree } from "./collection-utils";
|
||||
|
||||
describe("CollectionUtils Service", () => {
|
||||
describe("getNestedCollectionTree", () => {
|
||||
it("should return collections properly sorted if provided out of order", () => {
|
||||
// Arrange
|
||||
const collections: CollectionView[] = [];
|
||||
|
||||
const parentCollection = new CollectionView({
|
||||
name: "Parent",
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
id: newGuid() as CollectionId,
|
||||
});
|
||||
|
||||
const childCollection = new CollectionView({
|
||||
name: "Parent/Child",
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
id: newGuid() as CollectionId,
|
||||
});
|
||||
|
||||
collections.push(childCollection);
|
||||
collections.push(parentCollection);
|
||||
|
||||
// Act
|
||||
const result = getNestedCollectionTree(collections);
|
||||
|
||||
// Assert
|
||||
expect(result[0].node.name).toBe("Parent");
|
||||
expect(result[0].children[0].node.name).toBe("Child");
|
||||
});
|
||||
|
||||
it("should return an empty array if no collections are provided", () => {
|
||||
// Arrange
|
||||
const collections: CollectionView[] = [];
|
||||
|
||||
// Act
|
||||
const result = getNestedCollectionTree(collections);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFlatCollectionTree", () => {
|
||||
it("should flatten a tree node with no children", () => {
|
||||
// Arrange
|
||||
const collection = new CollectionView({
|
||||
name: "Test Collection",
|
||||
id: "test-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const treeNodes: TreeNode<CollectionView>[] = [
|
||||
new TreeNode<CollectionView>(collection, {} as TreeNode<CollectionView>),
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = getFlatCollectionTree(treeNodes);
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toBe(collection);
|
||||
});
|
||||
|
||||
it("should flatten a tree node with children", () => {
|
||||
// Arrange
|
||||
const parentCollection = new CollectionView({
|
||||
name: "Parent",
|
||||
id: "parent-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const child1Collection = new CollectionView({
|
||||
name: "Child 1",
|
||||
id: "child1-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const child2Collection = new CollectionView({
|
||||
name: "Child 2",
|
||||
id: "child2-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const grandchildCollection = new CollectionView({
|
||||
name: "Grandchild",
|
||||
id: "grandchild-id" as CollectionId,
|
||||
organizationId: "orgId" as OrganizationId,
|
||||
});
|
||||
|
||||
const parentNode = new TreeNode<CollectionView>(
|
||||
parentCollection,
|
||||
{} as TreeNode<CollectionView>,
|
||||
);
|
||||
const child1Node = new TreeNode<CollectionView>(child1Collection, parentNode);
|
||||
const child2Node = new TreeNode<CollectionView>(child2Collection, parentNode);
|
||||
const grandchildNode = new TreeNode<CollectionView>(grandchildCollection, child1Node);
|
||||
|
||||
parentNode.children = [child1Node, child2Node];
|
||||
child1Node.children = [grandchildNode];
|
||||
|
||||
const treeNodes: TreeNode<CollectionView>[] = [parentNode];
|
||||
|
||||
// Act
|
||||
const result = getFlatCollectionTree(treeNodes);
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(4);
|
||||
expect(result[0]).toBe(parentCollection);
|
||||
expect(result).toContain(child1Collection);
|
||||
expect(result).toContain(child2Collection);
|
||||
expect(result).toContain(grandchildCollection);
|
||||
});
|
||||
});
|
||||
});
|
||||
87
libs/common/src/admin-console/utils/collection-utils.ts
Normal file
87
libs/common/src/admin-console/utils/collection-utils.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
CollectionView,
|
||||
NestingDelimiter,
|
||||
CollectionAdminView,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
|
||||
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
|
||||
|
||||
export function getNestedCollectionTree(
|
||||
collections: CollectionAdminView[],
|
||||
): TreeNode<CollectionAdminView>[];
|
||||
export function getNestedCollectionTree(collections: CollectionView[]): TreeNode<CollectionView>[];
|
||||
export function getNestedCollectionTree(
|
||||
collections: (CollectionView | CollectionAdminView)[],
|
||||
): TreeNode<CollectionView | CollectionAdminView>[] {
|
||||
if (!collections) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
|
||||
// modifies the names of collections.
|
||||
// These changes risk affecting collections store in StateService.
|
||||
const clonedCollections: CollectionView[] | CollectionAdminView[] = collections
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(cloneCollection);
|
||||
|
||||
const all: TreeNode<CollectionView | CollectionAdminView>[] = [];
|
||||
const groupedByOrg = new Map<OrganizationId, (CollectionView | CollectionAdminView)[]>();
|
||||
clonedCollections.map((c) => {
|
||||
const key = c.organizationId;
|
||||
(groupedByOrg.get(key) ?? groupedByOrg.set(key, []).get(key)!).push(c);
|
||||
});
|
||||
for (const group of groupedByOrg.values()) {
|
||||
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
|
||||
for (const c of group) {
|
||||
const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
|
||||
ServiceUtils.nestedTraverse(nodes, 0, parts, c, undefined, NestingDelimiter);
|
||||
}
|
||||
all.push(...nodes);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
export function cloneCollection(collection: CollectionView): CollectionView;
|
||||
export function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
|
||||
export function cloneCollection(
|
||||
collection: CollectionView | CollectionAdminView,
|
||||
): CollectionView | CollectionAdminView {
|
||||
let cloned;
|
||||
|
||||
if (collection instanceof CollectionAdminView) {
|
||||
cloned = Object.assign(
|
||||
new CollectionAdminView({ ...collection, name: collection.name }),
|
||||
collection,
|
||||
);
|
||||
} else {
|
||||
cloned = Object.assign(
|
||||
new CollectionView({ ...collection, name: collection.name }),
|
||||
collection,
|
||||
);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function getFlatCollectionTree(
|
||||
nodes: TreeNode<CollectionAdminView>[],
|
||||
): CollectionAdminView[];
|
||||
export function getFlatCollectionTree(nodes: TreeNode<CollectionView>[]): CollectionView[];
|
||||
export function getFlatCollectionTree(
|
||||
nodes: TreeNode<CollectionView | CollectionAdminView>[],
|
||||
): (CollectionView | CollectionAdminView)[] {
|
||||
if (!nodes || nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return nodes.flatMap((node) => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return [node.node];
|
||||
}
|
||||
|
||||
const children = getFlatCollectionTree(node.children);
|
||||
return [node.node, ...children];
|
||||
});
|
||||
}
|
||||
1
libs/common/src/admin-console/utils/index.ts
Normal file
1
libs/common/src/admin-console/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./collection-utils";
|
||||
@@ -0,0 +1,102 @@
|
||||
import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response";
|
||||
import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart } from "@bitwarden/pricing";
|
||||
import {
|
||||
BitwardenSubscription,
|
||||
Storage,
|
||||
SubscriptionStatus,
|
||||
SubscriptionStatuses,
|
||||
} from "@bitwarden/subscription";
|
||||
|
||||
export class BitwardenSubscriptionResponse extends BaseResponse {
|
||||
status: SubscriptionStatus;
|
||||
cart: Cart;
|
||||
storage: Storage;
|
||||
cancelAt?: Date;
|
||||
canceled?: Date;
|
||||
nextCharge?: Date;
|
||||
suspension?: Date;
|
||||
gracePeriod?: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const status = this.getResponseProperty("Status");
|
||||
if (
|
||||
status !== SubscriptionStatuses.Incomplete &&
|
||||
status !== SubscriptionStatuses.IncompleteExpired &&
|
||||
status !== SubscriptionStatuses.Trialing &&
|
||||
status !== SubscriptionStatuses.Active &&
|
||||
status !== SubscriptionStatuses.PastDue &&
|
||||
status !== SubscriptionStatuses.Canceled &&
|
||||
status !== SubscriptionStatuses.Unpaid
|
||||
) {
|
||||
throw new Error(`Failed to parse invalid subscription status: ${status}`);
|
||||
}
|
||||
this.status = status;
|
||||
|
||||
this.cart = new CartResponse(this.getResponseProperty("Cart"));
|
||||
this.storage = new StorageResponse(this.getResponseProperty("Storage"));
|
||||
|
||||
const suspension = this.getResponseProperty("Suspension");
|
||||
if (suspension) {
|
||||
this.suspension = new Date(suspension);
|
||||
}
|
||||
|
||||
const gracePeriod = this.getResponseProperty("GracePeriod");
|
||||
if (gracePeriod) {
|
||||
this.gracePeriod = gracePeriod;
|
||||
}
|
||||
|
||||
const nextCharge = this.getResponseProperty("NextCharge");
|
||||
if (nextCharge) {
|
||||
this.nextCharge = new Date(nextCharge);
|
||||
}
|
||||
|
||||
const cancelAt = this.getResponseProperty("CancelAt");
|
||||
if (cancelAt) {
|
||||
this.cancelAt = new Date(cancelAt);
|
||||
}
|
||||
|
||||
const canceled = this.getResponseProperty("Canceled");
|
||||
if (canceled) {
|
||||
this.canceled = new Date(canceled);
|
||||
}
|
||||
}
|
||||
|
||||
toDomain = (): BitwardenSubscription => {
|
||||
switch (this.status) {
|
||||
case SubscriptionStatuses.Incomplete:
|
||||
case SubscriptionStatuses.IncompleteExpired:
|
||||
case SubscriptionStatuses.PastDue:
|
||||
case SubscriptionStatuses.Unpaid: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
suspension: this.suspension!,
|
||||
gracePeriod: this.gracePeriod!,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Trialing:
|
||||
case SubscriptionStatuses.Active: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
nextCharge: this.nextCharge!,
|
||||
cancelAt: this.cancelAt,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Canceled: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
canceled: this.canceled!,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart, CartItem, Discount } from "@bitwarden/pricing";
|
||||
|
||||
import { DiscountResponse } from "./discount.response";
|
||||
|
||||
export class CartItemResponse extends BaseResponse implements CartItem {
|
||||
translationKey: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
discount?: Discount;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.translationKey = this.getResponseProperty("TranslationKey");
|
||||
this.quantity = this.getResponseProperty("Quantity");
|
||||
this.cost = this.getResponseProperty("Cost");
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = discount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalStorage = this.getResponseProperty("AdditionalStorage");
|
||||
if (additionalStorage) {
|
||||
this.additionalStorage = new CartItemResponse(additionalStorage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SecretsManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts");
|
||||
if (additionalServiceAccounts) {
|
||||
this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CartResponse extends BaseResponse implements Cart {
|
||||
passwordManager: {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
};
|
||||
secretsManager?: {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
};
|
||||
cadence: SubscriptionCadence;
|
||||
discount?: Discount;
|
||||
estimatedTax: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.passwordManager = new PasswordManagerCartItemResponse(
|
||||
this.getResponseProperty("PasswordManager"),
|
||||
);
|
||||
|
||||
const secretsManager = this.getResponseProperty("SecretsManager");
|
||||
if (secretsManager) {
|
||||
this.secretsManager = new SecretsManagerCartItemResponse(secretsManager);
|
||||
}
|
||||
|
||||
const cadence = this.getResponseProperty("Cadence");
|
||||
if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) {
|
||||
throw new Error(`Failed to parse invalid cadence: ${cadence}`);
|
||||
}
|
||||
this.cadence = cadence;
|
||||
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = new DiscountResponse(discount);
|
||||
}
|
||||
|
||||
this.estimatedTax = this.getResponseProperty("EstimatedTax");
|
||||
}
|
||||
}
|
||||
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing";
|
||||
|
||||
export class DiscountResponse extends BaseResponse implements Discount {
|
||||
type: DiscountType;
|
||||
value: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const type = this.getResponseProperty("Type");
|
||||
if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) {
|
||||
throw new Error(`Failed to parse invalid discount type: ${type}`);
|
||||
}
|
||||
this.type = type;
|
||||
this.value = this.getResponseProperty("Value");
|
||||
}
|
||||
}
|
||||
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Storage } from "@bitwarden/subscription";
|
||||
|
||||
export class StorageResponse extends BaseResponse implements Storage {
|
||||
available: number;
|
||||
used: number;
|
||||
readableUsed: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.available = this.getResponseProperty("Available");
|
||||
this.used = this.getResponseProperty("Used");
|
||||
this.readableUsed = this.getResponseProperty("ReadableUsed");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -54,6 +55,8 @@ describe("PhishingDetectionSettingsService", () => {
|
||||
usePhishingBlocker: true,
|
||||
});
|
||||
|
||||
const mockLogService = mock<LogService>();
|
||||
|
||||
const mockUserId = "mock-user-id" as UserId;
|
||||
const account = mock<Account>({ id: mockUserId });
|
||||
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
|
||||
@@ -85,6 +88,7 @@ describe("PhishingDetectionSettingsService", () => {
|
||||
mockAccountService,
|
||||
mockBillingService,
|
||||
mockConfigService,
|
||||
mockLogService,
|
||||
mockOrganizationService,
|
||||
mockPlatformService,
|
||||
stateProvider,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { combineLatest, Observable, of, switchMap } from "rxjs";
|
||||
import { catchError, distinctUntilChanged, map, shareReplay } from "rxjs/operators";
|
||||
import { catchError, distinctUntilChanged, map, shareReplay, tap } from "rxjs/operators";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
@@ -9,6 +9,7 @@ import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
@@ -32,27 +33,47 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
|
||||
private accountService: AccountService,
|
||||
private billingService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
private logService: LogService,
|
||||
private organizationService: OrganizationService,
|
||||
private platformService: PlatformUtilsService,
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.logService.debug(`[PhishingDetectionSettingsService] Initializing service...`);
|
||||
this.available$ = this.buildAvailablePipeline$().pipe(
|
||||
distinctUntilChanged(),
|
||||
tap((available) =>
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Phishing detection available: ${available}`,
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
this.enabled$ = this.buildEnabledPipeline$().pipe(
|
||||
distinctUntilChanged(),
|
||||
tap((enabled) =>
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Phishing detection enabled: ${{ enabled }}`,
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
this.on$ = combineLatest([this.available$, this.enabled$]).pipe(
|
||||
map(([available, enabled]) => available && enabled),
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
tap((on) =>
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Phishing detection is on: ${{ on }}`,
|
||||
),
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
}
|
||||
|
||||
async setEnabled(userId: UserId, enabled: boolean): Promise<void> {
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Setting phishing detection enabled: ${{ enabled, userId }}`,
|
||||
);
|
||||
await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled);
|
||||
}
|
||||
|
||||
@@ -64,6 +85,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
|
||||
private buildAvailablePipeline$(): Observable<boolean> {
|
||||
// Phishing detection is unavailable on Safari due to platform limitations.
|
||||
if (this.platformService.isSafari()) {
|
||||
this.logService.warning(
|
||||
`[PhishingDetectionSettingsService] Phishing detection is unavailable on Safari due to platform limitations`,
|
||||
);
|
||||
return of(false);
|
||||
}
|
||||
|
||||
@@ -97,6 +121,9 @@ export class PhishingDetectionSettingsService implements PhishingDetectionSettin
|
||||
if (!account) {
|
||||
return of(false);
|
||||
}
|
||||
this.logService.debug(
|
||||
`[PhishingDetectionSettingsService] Refreshing phishing detection enabled state`,
|
||||
);
|
||||
return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id);
|
||||
}),
|
||||
map((enabled) => enabled ?? true),
|
||||
|
||||
@@ -31,6 +31,8 @@ export enum FeatureFlag {
|
||||
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
|
||||
PM26462_Milestone_3 = "pm-26462-milestone-3",
|
||||
PM23341_Milestone_2 = "pm-23341-milestone-2",
|
||||
PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page",
|
||||
PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -45,7 +47,6 @@ export enum FeatureFlag {
|
||||
EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration",
|
||||
|
||||
/* Tools */
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
|
||||
ChromiumImporterWithABE = "pm-25855-chromium-importer-abe",
|
||||
SendUIRefresh = "pm-28175-send-ui-refresh",
|
||||
@@ -54,7 +55,6 @@ export enum FeatureFlag {
|
||||
/* DIRT */
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
PhishingDetection = "phishing-detection",
|
||||
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
|
||||
|
||||
/* Vault */
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
@@ -107,7 +107,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
|
||||
|
||||
/* Tools */
|
||||
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
|
||||
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
|
||||
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
|
||||
[FeatureFlag.SendUIRefresh]: FALSE,
|
||||
@@ -116,7 +115,6 @@ export const DefaultFeatureFlagValue = {
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
[FeatureFlag.PhishingDetection]: FALSE,
|
||||
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
@@ -139,6 +137,8 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
[FeatureFlag.PM23341_Milestone_2]: FALSE,
|
||||
[FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE,
|
||||
[FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
|
||||
@@ -113,6 +113,23 @@ export abstract class MasterPasswordServiceAbstraction {
|
||||
* @throws If the user ID is missing.
|
||||
*/
|
||||
abstract userHasMasterPassword(userId: UserId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Derives a master key from the provided password and master password unlock data,
|
||||
* then sets it to state for the specified user. This is a temporary backwards compatibility function
|
||||
* to support existing code that relies on direct master key access.
|
||||
* Note: This will be removed in https://bitwarden.atlassian.net/browse/PM-30676
|
||||
*
|
||||
* @param password The master password.
|
||||
* @param masterPasswordUnlockData The master password unlock data containing the KDF settings and salt.
|
||||
* @param userId The user ID.
|
||||
* @throws If the password, master password unlock data, or user ID is missing.
|
||||
*/
|
||||
abstract setLegacyMasterKeyFromUnlockData(
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction {
|
||||
|
||||
@@ -127,4 +127,12 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
|
||||
masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null> {
|
||||
return this.mock.masterPasswordUnlockData$(userId);
|
||||
}
|
||||
|
||||
setLegacyMasterKeyFromUnlockData(
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
return this.mock.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
@@ -415,6 +416,125 @@ describe("MasterPasswordService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("setLegacyMasterKeyFromUnlockData", () => {
|
||||
const password = "test-password";
|
||||
|
||||
it("derives master key from password and sets it in state", async () => {
|
||||
const masterKey = makeSymmetricCryptoKey(32, 5) as MasterKey;
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
|
||||
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
|
||||
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfPBKDF2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
|
||||
|
||||
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
|
||||
password,
|
||||
masterPasswordUnlockData.salt,
|
||||
masterPasswordUnlockData.kdf,
|
||||
);
|
||||
|
||||
const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$);
|
||||
expect(state).toEqual(masterKey);
|
||||
});
|
||||
|
||||
it("works with argon2 kdf config", async () => {
|
||||
const masterKey = makeSymmetricCryptoKey(32, 6) as MasterKey;
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
|
||||
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
|
||||
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfArgon2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
|
||||
|
||||
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
|
||||
password,
|
||||
masterPasswordUnlockData.salt,
|
||||
masterPasswordUnlockData.kdf,
|
||||
);
|
||||
|
||||
const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$);
|
||||
expect(state).toEqual(masterKey);
|
||||
});
|
||||
|
||||
it("computes and sets master key hash in state", async () => {
|
||||
const masterKey = makeSymmetricCryptoKey(32, 7) as MasterKey;
|
||||
const expectedHashBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const expectedHashB64 = "AQIDBAUGBwg=";
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
|
||||
cryptoFunctionService.pbkdf2.mockResolvedValue(expectedHashBytes);
|
||||
jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(expectedHashB64);
|
||||
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfPBKDF2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
|
||||
|
||||
expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith(
|
||||
masterKey.inner().encryptionKey,
|
||||
password,
|
||||
"sha256",
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
|
||||
const hashState = await firstValueFrom(sut.masterKeyHash$(userId));
|
||||
expect(hashState).toEqual(expectedHashB64);
|
||||
});
|
||||
|
||||
it("throws if password is null", async () => {
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfPBKDF2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.setLegacyMasterKeyFromUnlockData(
|
||||
null as unknown as string,
|
||||
masterPasswordUnlockData,
|
||||
userId,
|
||||
),
|
||||
).rejects.toThrow("password is null or undefined.");
|
||||
});
|
||||
|
||||
it("throws if masterPasswordUnlockData is null", async () => {
|
||||
await expect(
|
||||
sut.setLegacyMasterKeyFromUnlockData(
|
||||
password,
|
||||
null as unknown as MasterPasswordUnlockData,
|
||||
userId,
|
||||
),
|
||||
).rejects.toThrow("masterPasswordUnlockData is null or undefined.");
|
||||
});
|
||||
|
||||
it("throws if userId is null", async () => {
|
||||
const masterPasswordUnlockData = new MasterPasswordUnlockData(
|
||||
salt,
|
||||
kdfPBKDF2,
|
||||
makeEncString().toSdk() as MasterKeyWrappedUserKey,
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.setLegacyMasterKeyFromUnlockData(
|
||||
password,
|
||||
masterPasswordUnlockData,
|
||||
null as unknown as UserId,
|
||||
),
|
||||
).rejects.toThrow("userId is null or undefined.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MASTER_PASSWORD_UNLOCK_KEY", () => {
|
||||
it("has the correct configuration", () => {
|
||||
expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { firstValueFrom, map, Observable } from "rxjs";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
@@ -342,4 +343,51 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
|
||||
return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$;
|
||||
}
|
||||
|
||||
async setLegacyMasterKeyFromUnlockData(
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
assertNonNullish(password, "password");
|
||||
assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData");
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
const masterKey = (await this.keyGenerationService.deriveKeyFromPassword(
|
||||
password,
|
||||
masterPasswordUnlockData.salt,
|
||||
masterPasswordUnlockData.kdf,
|
||||
)) as MasterKey;
|
||||
const localKeyHash = await this.hashMasterKey(
|
||||
password,
|
||||
masterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
|
||||
await this.setMasterKey(masterKey, userId);
|
||||
await this.setMasterKeyHash(localKeyHash, userId);
|
||||
}
|
||||
|
||||
// Copied from KeyService to avoid circular dependency. This will be dropped together with `setLegacyMatserKeyFromUnlockData`.
|
||||
private async hashMasterKey(
|
||||
password: string,
|
||||
key: MasterKey,
|
||||
hashPurpose: HashPurpose,
|
||||
): Promise<string> {
|
||||
if (password == null) {
|
||||
throw new Error("password is required.");
|
||||
}
|
||||
if (key == null) {
|
||||
throw new Error("key is required.");
|
||||
}
|
||||
|
||||
const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1;
|
||||
const hash = await this.cryptoFunctionService.pbkdf2(
|
||||
key.inner().encryptionKey,
|
||||
password,
|
||||
"sha256",
|
||||
iterations,
|
||||
);
|
||||
return Utils.fromBufferToB64(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionView,
|
||||
Collection as CollectionDomain,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { CollectionId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { CollectionExport } from "./collection.export";
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Collection as CollectionDomain, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionView,
|
||||
Collection as CollectionDomain,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
import { EncString } from "../../key-management/crypto/models/enc-string";
|
||||
import { CollectionId, emptyGuid, OrganizationId } from "../../types/guid";
|
||||
|
||||
@@ -136,11 +136,11 @@ export interface CreateCredentialResult {
|
||||
*/
|
||||
export interface AssertCredentialParams {
|
||||
allowedCredentialIds: string[];
|
||||
rpId: string;
|
||||
rpId?: string;
|
||||
origin: string;
|
||||
challenge: string;
|
||||
userVerification?: UserVerification;
|
||||
timeout: number;
|
||||
timeout?: number;
|
||||
sameOriginWithAncestors: boolean;
|
||||
mediation?: "silent" | "optional" | "required" | "conditional";
|
||||
fallbackSupported: boolean;
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
Fido2ClientService as Fido2ClientServiceAbstraction,
|
||||
PublicKeyCredentialParam,
|
||||
UserRequestedFallbackAbortReason,
|
||||
UserVerification,
|
||||
} from "../../abstractions/fido2/fido2-client.service.abstraction";
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { Utils } from "../../misc/utils";
|
||||
@@ -195,7 +194,7 @@ export class Fido2ClientService<
|
||||
}
|
||||
const timeoutSubscription = this.setAbortTimeout(
|
||||
abortController,
|
||||
params.authenticatorSelection?.userVerification,
|
||||
makeCredentialParams.requireUserVerification,
|
||||
params.timeout,
|
||||
);
|
||||
|
||||
@@ -318,7 +317,7 @@ export class Fido2ClientService<
|
||||
|
||||
const timeoutSubscription = this.setAbortTimeout(
|
||||
abortController,
|
||||
params.userVerification,
|
||||
getAssertionParams.requireUserVerification,
|
||||
params.timeout,
|
||||
);
|
||||
|
||||
@@ -441,13 +440,13 @@ export class Fido2ClientService<
|
||||
|
||||
private setAbortTimeout = (
|
||||
abortController: AbortController,
|
||||
userVerification?: UserVerification,
|
||||
requireUserVerification: boolean,
|
||||
timeout?: number,
|
||||
): Subscription => {
|
||||
let clampedTimeout: number;
|
||||
|
||||
const { WITH_VERIFICATION, NO_VERIFICATION } = this.TIMEOUTS;
|
||||
if (userVerification === "required") {
|
||||
if (requireUserVerification) {
|
||||
timeout = timeout ?? WITH_VERIFICATION.DEFAULT;
|
||||
clampedTimeout = Math.max(WITH_VERIFICATION.MIN, Math.min(timeout, WITH_VERIFICATION.MAX));
|
||||
} else {
|
||||
|
||||
@@ -4,11 +4,11 @@ import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionData,
|
||||
CollectionDetailsResponse,
|
||||
CollectionService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
CollectionData,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
|
||||
import { CollectionDetailsResponse } from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
import { PolicyResponse } from "../../admin-console/models/response/policy.response";
|
||||
import { UserDecryptionResponse } from "../../key-management/models/response/user-decryption.response";
|
||||
|
||||
@@ -4,16 +4,15 @@ import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import {
|
||||
CollectionAccessDetailsResponse,
|
||||
CollectionDetailsResponse,
|
||||
CollectionResponse,
|
||||
CreateCollectionRequest,
|
||||
UpdateCollectionRequest,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
import { ApiService as ApiServiceAbstraction } from "../abstractions/api.service";
|
||||
import { OrganizationConnectionType } from "../admin-console/enums";
|
||||
@@ -330,6 +329,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new PaymentResponse(r);
|
||||
}
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
postReinstatePremium(): Promise<any> {
|
||||
return this.send("POST", "/accounts/reinstate-premium", null, true, false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import * as zxcvbn from "zxcvbn";
|
||||
import zxcvbn from "zxcvbn";
|
||||
|
||||
import { PasswordStrengthServiceAbstraction } from "./password-strength.service.abstraction";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendResponse } from "../response/send.response";
|
||||
|
||||
import { SendFileData } from "./send-file.data";
|
||||
@@ -10,6 +11,7 @@ export class SendData {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: SendType;
|
||||
authType: AuthType;
|
||||
name: string;
|
||||
notes: string;
|
||||
file: SendFileData;
|
||||
@@ -33,6 +35,7 @@ export class SendData {
|
||||
this.id = response.id;
|
||||
this.accessId = response.accessId;
|
||||
this.type = response.type;
|
||||
this.authType = response.authType;
|
||||
this.name = response.name;
|
||||
this.notes = response.notes;
|
||||
this.key = response.key;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { mockContainerService, mockEnc } from "../../../../../spec";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendAccessResponse } from "../response/send-access.response";
|
||||
|
||||
import { SendAccess } from "./send-access";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import Domain from "../../../../platform/models/domain/domain-base";
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendAccessResponse } from "../response/send-access.response";
|
||||
import { SendAccessView } from "../view/send-access.view";
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "../../../../platform/services/container.service";
|
||||
import { UserKey } from "../../../../types/key";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendData } from "../data/send.data";
|
||||
|
||||
import { Send } from "./send";
|
||||
@@ -25,6 +26,7 @@ describe("Send", () => {
|
||||
id: "id",
|
||||
accessId: "accessId",
|
||||
type: SendType.Text,
|
||||
authType: AuthType.None,
|
||||
name: "encName",
|
||||
notes: "encNotes",
|
||||
text: {
|
||||
@@ -55,6 +57,7 @@ describe("Send", () => {
|
||||
id: null,
|
||||
accessId: null,
|
||||
type: undefined,
|
||||
authType: undefined,
|
||||
name: null,
|
||||
notes: null,
|
||||
text: undefined,
|
||||
@@ -78,6 +81,7 @@ describe("Send", () => {
|
||||
id: "id",
|
||||
accessId: "accessId",
|
||||
type: SendType.Text,
|
||||
authType: AuthType.None,
|
||||
name: { encryptedString: "encName", encryptionType: 0 },
|
||||
notes: { encryptedString: "encNotes", encryptionType: 0 },
|
||||
text: {
|
||||
@@ -107,6 +111,7 @@ describe("Send", () => {
|
||||
send.id = "id";
|
||||
send.accessId = "accessId";
|
||||
send.type = SendType.Text;
|
||||
send.authType = AuthType.None;
|
||||
send.name = mockEnc("name");
|
||||
send.notes = mockEnc("notes");
|
||||
send.text = text;
|
||||
@@ -145,6 +150,7 @@ describe("Send", () => {
|
||||
name: "name",
|
||||
notes: "notes",
|
||||
type: 0,
|
||||
authType: 2,
|
||||
key: expect.anything(),
|
||||
cryptoKey: "cryptoKey",
|
||||
file: expect.anything(),
|
||||
|
||||
@@ -8,7 +8,8 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import Domain from "../../../../platform/models/domain/domain-base";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendData } from "../data/send.data";
|
||||
import { SendView } from "../view/send.view";
|
||||
|
||||
@@ -19,6 +20,7 @@ export class Send extends Domain {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: SendType;
|
||||
authType: AuthType;
|
||||
name: EncString;
|
||||
notes: EncString;
|
||||
file: SendFile;
|
||||
@@ -54,6 +56,7 @@ export class Send extends Domain {
|
||||
);
|
||||
|
||||
this.type = obj.type;
|
||||
this.authType = obj.authType;
|
||||
this.maxAccessCount = obj.maxAccessCount;
|
||||
this.accessCount = obj.accessCount;
|
||||
this.password = obj.password;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
import { Send } from "../domain/send";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
|
||||
@@ -9,6 +10,7 @@ export class SendResponse extends BaseResponse {
|
||||
id: string;
|
||||
accessId: string;
|
||||
type: SendType;
|
||||
authType: AuthType;
|
||||
name: string;
|
||||
notes: string;
|
||||
file: SendFileApi;
|
||||
@@ -29,6 +31,7 @@ export class SendResponse extends BaseResponse {
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.accessId = this.getResponseProperty("AccessId");
|
||||
this.type = this.getResponseProperty("Type");
|
||||
this.authType = this.getResponseProperty("AuthType");
|
||||
this.name = this.getResponseProperty("Name");
|
||||
this.notes = this.getResponseProperty("Notes");
|
||||
this.key = this.getResponseProperty("Key");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { View } from "../../../../models/view/view";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendAccess } from "../domain/send-access";
|
||||
|
||||
import { SendFileView } from "./send-file.view";
|
||||
|
||||
@@ -4,7 +4,8 @@ import { View } from "../../../../models/view/view";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { DeepJsonify } from "../../../../types/deep-jsonify";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { AuthType } from "../../types/auth-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { Send } from "../domain/send";
|
||||
|
||||
import { SendFileView } from "./send-file.view";
|
||||
@@ -18,6 +19,7 @@ export class SendView implements View {
|
||||
key: Uint8Array;
|
||||
cryptoKey: SymmetricCryptoKey;
|
||||
type: SendType = null;
|
||||
authType: AuthType = null;
|
||||
text = new SendTextView();
|
||||
file = new SendFileView();
|
||||
maxAccessCount?: number = null;
|
||||
@@ -38,6 +40,7 @@ export class SendView implements View {
|
||||
this.id = s.id;
|
||||
this.accessId = s.accessId;
|
||||
this.type = s.type;
|
||||
this.authType = s.authType;
|
||||
this.maxAccessCount = s.maxAccessCount;
|
||||
this.accessCount = s.accessCount;
|
||||
this.revisionDate = s.revisionDate;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
FileUploadService,
|
||||
} from "../../../platform/abstractions/file-upload/file-upload.service";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { Send } from "../models/domain/send";
|
||||
import { SendAccessRequest } from "../models/request/send-access.request";
|
||||
@@ -16,6 +15,7 @@ import { SendFileDownloadDataResponse } from "../models/response/send-file-downl
|
||||
import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response";
|
||||
import { SendResponse } from "../models/response/send.response";
|
||||
import { SendAccessView } from "../models/view/send-access.view";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction";
|
||||
import { InternalSendService } from "./send.service.abstraction";
|
||||
|
||||
@@ -24,13 +24,13 @@ import { ContainerService } from "../../../platform/services/container.service";
|
||||
import { SelfHostedEnvironment } from "../../../platform/services/default-environment.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendFileApi } from "../models/api/send-file.api";
|
||||
import { SendTextApi } from "../models/api/send-text.api";
|
||||
import { SendFileData } from "../models/data/send-file.data";
|
||||
import { SendTextData } from "../models/data/send-text.data";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions";
|
||||
import { SendStateProvider } from "./send-state.provider";
|
||||
|
||||
@@ -16,7 +16,6 @@ import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { Send } from "../models/domain/send";
|
||||
import { SendFile } from "../models/domain/send-file";
|
||||
@@ -24,6 +23,7 @@ import { SendText } from "../models/domain/send-text";
|
||||
import { SendWithIdRequest } from "../models/request/send-with-id.request";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
import { SEND_KDF_ITERATIONS } from "../send-kdf";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
import { SendStateProvider } from "./send-state.provider.abstraction";
|
||||
import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendTextApi } from "../../models/api/send-text.api";
|
||||
import { SendTextData } from "../../models/data/send-text.data";
|
||||
import { SendData } from "../../models/data/send.data";
|
||||
import { Send } from "../../models/domain/send";
|
||||
import { SendView } from "../../models/view/send.view";
|
||||
import { SendType } from "../../types/send-type";
|
||||
|
||||
export function testSendViewData(id: string, name: string) {
|
||||
const data = new SendView({} as any);
|
||||
|
||||
12
libs/common/src/tools/send/types/auth-type.ts
Normal file
12
libs/common/src/tools/send/types/auth-type.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** An type of auth necessary to access a Send */
|
||||
export const AuthType = Object.freeze({
|
||||
/** Send requires email OTP verification */
|
||||
Email: 0,
|
||||
/** Send requires a password */
|
||||
Password: 1,
|
||||
/** Send requires no auth */
|
||||
None: 2,
|
||||
} as const);
|
||||
|
||||
/** An type of auth necessary to access a Send */
|
||||
export type AuthType = (typeof AuthType)[keyof typeof AuthType];
|
||||
7
libs/common/src/tools/send/types/send-filter-type.ts
Normal file
7
libs/common/src/tools/send/types/send-filter-type.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const SendFilterType = Object.freeze({
|
||||
All: "all",
|
||||
Text: "text",
|
||||
File: "file",
|
||||
} as const);
|
||||
|
||||
export type SendFilterType = (typeof SendFilterType)[keyof typeof SendFilterType];
|
||||
@@ -3,8 +3,9 @@ import { Observable, firstValueFrom, of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"emitDecoratorMetadata": false
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext"
|
||||
},
|
||||
"files": ["./test.setup.ts"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Component, computed, HostBinding, input } from "@angular/core";
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
type CharacterType = "letter" | "emoji" | "special" | "number";
|
||||
@@ -14,7 +23,7 @@ type CharacterType = "letter" | "emoji" | "special" | "number";
|
||||
@Component({
|
||||
selector: "bit-color-password",
|
||||
template: `@for (character of passwordCharArray(); track $index; let i = $index) {
|
||||
<span [class]="getCharacterClass(character)" class="tw-font-mono">
|
||||
<span [class]="getCharacterClass(character)" class="tw-font-mono" data-password-character>
|
||||
<span>{{ character }}</span>
|
||||
@if (showCount()) {
|
||||
<span class="tw-whitespace-nowrap tw-text-xs tw-leading-5 tw-text-main">{{ i + 1 }}</span>
|
||||
@@ -31,6 +40,9 @@ export class ColorPasswordComponent {
|
||||
return Array.from(this.password() ?? "");
|
||||
});
|
||||
|
||||
private platformUtilsService = inject(PlatformUtilsService);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
characterStyles: Record<CharacterType, string[]> = {
|
||||
emoji: [],
|
||||
letter: ["tw-text-main"],
|
||||
@@ -78,4 +90,28 @@ export class ColorPasswordComponent {
|
||||
|
||||
return "letter";
|
||||
}
|
||||
|
||||
@HostListener("copy", ["$event"])
|
||||
onCopy(event: ClipboardEvent) {
|
||||
event.preventDefault();
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spanElements = this.elementRef.nativeElement.querySelectorAll(
|
||||
"span[data-password-character]",
|
||||
);
|
||||
let copiedText = "";
|
||||
|
||||
spanElements.forEach((span: HTMLElement, index: number) => {
|
||||
if (selection.containsNode(span, true)) {
|
||||
copiedText += this.passwordCharArray()[index];
|
||||
}
|
||||
});
|
||||
|
||||
if (copiedText) {
|
||||
this.platformUtilsService.copyToClipboard(copiedText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Meta, StoryObj } from "@storybook/angular";
|
||||
import { applicationConfig, Meta, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
|
||||
|
||||
@@ -9,6 +11,19 @@ const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z0O ";
|
||||
export default {
|
||||
title: "Component Library/Color Password",
|
||||
component: ColorPasswordComponent,
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: {
|
||||
// eslint-disable-next-line
|
||||
copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
password: examplePassword,
|
||||
showCount: false,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ComponentPortal, Portal } from "@angular/cdk/portal";
|
||||
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
|
||||
import { filter, firstValueFrom, map, Observable, Subject, switchMap, take } from "rxjs";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
@@ -62,7 +62,7 @@ export abstract class DialogRef<R = unknown, C = unknown> implements Pick<
|
||||
|
||||
export type DialogConfig<D = unknown, R = unknown> = Pick<
|
||||
CdkDialogConfig<D, R>,
|
||||
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
|
||||
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" | "restoreFocus"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -242,6 +242,11 @@ export class DialogService {
|
||||
};
|
||||
|
||||
ref.cdkDialogRefBase = this.dialog.open<R, D, C>(componentOrTemplateRef, _config);
|
||||
|
||||
if (config?.restoreFocus === undefined) {
|
||||
this.setRestoreFocusEl<R, C>(ref);
|
||||
}
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
@@ -305,6 +310,48 @@ export class DialogService {
|
||||
return this.activeDrawer?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the dialog to return focus to the previous active element upon closing.
|
||||
* @param ref CdkDialogRef
|
||||
*
|
||||
* The cdk dialog already has the optional directive `cdkTrapFocusAutoCapture` to capture the
|
||||
* current active element and return focus to it upon close. However, it does not have a way to
|
||||
* delay the capture of the element. We need this delay in some situations, where the active
|
||||
* element may be changing as the dialog is opening, and we want to wait for that to settle.
|
||||
*
|
||||
* For example -- the menu component often contains menu items that open dialogs. When the dialog
|
||||
* opens, the menu is closing and is setting focus back to the menu trigger since the menu item no
|
||||
* longer exists. We want to capture the menu trigger as the active element, not the about-to-be-
|
||||
* nonexistent menu item. If we wait a tick, we can let the menu finish that focus move.
|
||||
*/
|
||||
private setRestoreFocusEl<R = unknown, C = unknown>(ref: CdkDialogRef<R, C>) {
|
||||
/**
|
||||
* First, capture the current active el with no delay so that we can support normal use cases
|
||||
* where we are not doing manual focus management
|
||||
*/
|
||||
const activeEl = document.activeElement;
|
||||
|
||||
const restoreFocusTimeout = setTimeout(() => {
|
||||
let restoreFocusEl = activeEl;
|
||||
|
||||
/**
|
||||
* If the original active element is no longer connected, it's because we purposely removed it
|
||||
* from the DOM and have moved focus. Select the new active element instead.
|
||||
*/
|
||||
if (!restoreFocusEl?.isConnected) {
|
||||
restoreFocusEl = document.activeElement;
|
||||
}
|
||||
|
||||
if (restoreFocusEl instanceof HTMLElement) {
|
||||
ref.cdkDialogRefBase.config.restoreFocus = restoreFocusEl;
|
||||
}
|
||||
}, 0);
|
||||
|
||||
ref.closed.pipe(take(1)).subscribe(() => {
|
||||
clearTimeout(restoreFocusTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
/** The injector that is passed to the opened dialog */
|
||||
private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector {
|
||||
return Injector.create({
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
|
||||
]"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
@let showHeaderBorder = bodyHasScrolledFrom().top;
|
||||
<header
|
||||
@@ -23,8 +22,8 @@
|
||||
bitTypography="h3"
|
||||
noMargin
|
||||
class="tw-text-main tw-mb-0 tw-line-clamp-2 tw-text-ellipsis tw-break-words focus-visible:tw-outline-none"
|
||||
cdkFocusInitial
|
||||
tabindex="-1"
|
||||
#dialogHeader
|
||||
>
|
||||
{{ title() }}
|
||||
@if (subtitle(); as subtitleText) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DestroyRef,
|
||||
computed,
|
||||
signal,
|
||||
AfterViewInit,
|
||||
} from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
import { combineLatest, switchMap } from "rxjs";
|
||||
@@ -62,8 +63,10 @@ const drawerSizeToWidth = {
|
||||
SpinnerComponent,
|
||||
],
|
||||
})
|
||||
export class DialogComponent {
|
||||
export class DialogComponent implements AfterViewInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dialogHeader =
|
||||
viewChild.required<ElementRef<HTMLHeadingElement>>("dialogHeader");
|
||||
private readonly scrollableBody = viewChild.required(CdkScrollable);
|
||||
private readonly scrollBottom = viewChild.required<ElementRef<HTMLDivElement>>("scrollBottom");
|
||||
|
||||
@@ -141,6 +144,22 @@ export class DialogComponent {
|
||||
return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses];
|
||||
});
|
||||
|
||||
ngAfterViewInit() {
|
||||
/**
|
||||
* Wait a tick for any focus management to occur on the trigger element before moving focus to
|
||||
* the dialog header. We choose the dialog header because it is always present, unlike possible
|
||||
* interactive elements.
|
||||
*
|
||||
* We are doing this manually instead of using `cdkTrapFocusAutoCapture` and `cdkFocusInitial`
|
||||
* because we need this delay behavior.
|
||||
*/
|
||||
const headerFocusTimeout = setTimeout(() => {
|
||||
this.dialogHeader().nativeElement.focus();
|
||||
}, 0);
|
||||
|
||||
this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
|
||||
}
|
||||
|
||||
handleEsc(event: Event) {
|
||||
if (!this.dialogRef?.disableClose) {
|
||||
this.dialogRef?.close();
|
||||
|
||||
@@ -192,7 +192,7 @@ export class MenuTriggerForDirective implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const escKey = this.overlayRef.keydownEvents().pipe(
|
||||
const keyEvents = this.overlayRef.keydownEvents().pipe(
|
||||
filter((event: KeyboardEvent) => {
|
||||
const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"];
|
||||
return keys.includes(event.key);
|
||||
@@ -202,8 +202,8 @@ export class MenuTriggerForDirective implements OnDestroy {
|
||||
const detachments = this.overlayRef.detachments();
|
||||
|
||||
const closeEvents = isContextMenu
|
||||
? merge(detachments, escKey, menuClosed)
|
||||
: merge(detachments, escKey, this.overlayRef.backdropClick(), menuClosed);
|
||||
? merge(detachments, keyEvents, menuClosed)
|
||||
: merge(detachments, keyEvents, this.overlayRef.backdropClick(), menuClosed);
|
||||
|
||||
this.closedEventsSub = closeEvents
|
||||
.pipe(takeUntil(this.overlayRef.detachments()))
|
||||
@@ -215,9 +215,9 @@ export class MenuTriggerForDirective implements OnDestroy {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event instanceof KeyboardEvent && (event.key === "Tab" || event.key === "Escape")) {
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
// Move focus to the menu trigger, since any active menu items are about to be destroyed
|
||||
this.elementRef.nativeElement.focus();
|
||||
|
||||
this.destroyMenu();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import { Overlay, OverlayRef } from "@angular/cdk/overlay";
|
||||
import { ChangeDetectionStrategy, Component, NgZone, TemplateRef, viewChild } from "@angular/core";
|
||||
import { ComponentFixture, TestBed, fakeAsync, flush, tick } from "@angular/core/testing";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { PopoverTriggerForDirective } from "./popover-trigger-for.directive";
|
||||
import { PopoverComponent } from "./popover.component";
|
||||
|
||||
/**
|
||||
* Test component to host the directive.
|
||||
*
|
||||
* Note: When testing RAF (requestAnimationFrame) behavior in fakeAsync tests:
|
||||
* - tick() without arguments advances virtual time but does NOT execute RAF callbacks
|
||||
* - tick(16) advances time by 16ms (typical animation frame duration) and DOES execute RAF callbacks
|
||||
* - tick(0) flushes microtasks, useful for Angular effects that run synchronously
|
||||
*/
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
[bitPopoverTriggerFor]="popoverComponent"
|
||||
[(popoverOpen)]="isOpen"
|
||||
#trigger="popoverTrigger"
|
||||
>
|
||||
Trigger
|
||||
</button>
|
||||
<bit-popover #popoverComponent></bit-popover>
|
||||
`,
|
||||
imports: [PopoverTriggerForDirective, PopoverComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class TestPopoverTriggerComponent {
|
||||
isOpen = false;
|
||||
readonly directive = viewChild("trigger", { read: PopoverTriggerForDirective });
|
||||
readonly popoverComponent = viewChild("popoverComponent", { read: PopoverComponent });
|
||||
readonly templateRef = viewChild("trigger", { read: TemplateRef });
|
||||
}
|
||||
|
||||
describe("PopoverTriggerForDirective", () => {
|
||||
let fixture: ComponentFixture<TestPopoverTriggerComponent>;
|
||||
let component: TestPopoverTriggerComponent;
|
||||
let directive: PopoverTriggerForDirective;
|
||||
let overlayRef: Partial<OverlayRef>;
|
||||
let overlay: Partial<Overlay>;
|
||||
let ngZone: NgZone;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create mock overlay ref
|
||||
overlayRef = {
|
||||
backdropElement: document.createElement("div"),
|
||||
attach: jest.fn(),
|
||||
detach: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
detachments: jest.fn().mockReturnValue(new Subject()),
|
||||
keydownEvents: jest.fn().mockReturnValue(new Subject()),
|
||||
backdropClick: jest.fn().mockReturnValue(new Subject()),
|
||||
};
|
||||
|
||||
// Create mock overlay
|
||||
const mockPositionStrategy = {
|
||||
flexibleConnectedTo: jest.fn().mockReturnThis(),
|
||||
withPositions: jest.fn().mockReturnThis(),
|
||||
withLockedPosition: jest.fn().mockReturnThis(),
|
||||
withFlexibleDimensions: jest.fn().mockReturnThis(),
|
||||
withPush: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
overlay = {
|
||||
create: jest.fn().mockReturnValue(overlayRef),
|
||||
position: jest.fn().mockReturnValue(mockPositionStrategy),
|
||||
scrollStrategies: {
|
||||
reposition: jest.fn().mockReturnValue({}),
|
||||
} as any,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestPopoverTriggerComponent],
|
||||
providers: [{ provide: Overlay, useValue: overlay }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestPopoverTriggerComponent);
|
||||
component = fixture.componentInstance;
|
||||
ngZone = TestBed.inject(NgZone);
|
||||
fixture.detectChanges();
|
||||
directive = component.directive()!;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
describe("Initial popover open with RAF delay", () => {
|
||||
it("should use double RAF delay on first open", fakeAsync(() => {
|
||||
// Spy on requestAnimationFrame to verify it's being called
|
||||
const rafSpy = jest.spyOn(window, "requestAnimationFrame");
|
||||
|
||||
// Set popoverOpen signal directly on the directive inside NgZone
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
// After effect execution, RAF should be scheduled but not executed yet
|
||||
expect(overlay.create).not.toHaveBeenCalled();
|
||||
|
||||
// Execute first RAF - tick(16) advances time by one animation frame (16ms)
|
||||
// This executes the first requestAnimationFrame callback
|
||||
tick(16);
|
||||
expect(overlay.create).not.toHaveBeenCalled();
|
||||
|
||||
// Execute second RAF - the nested requestAnimationFrame callback
|
||||
tick(16);
|
||||
expect(overlay.create).toHaveBeenCalled();
|
||||
expect(overlayRef.attach).toHaveBeenCalled();
|
||||
|
||||
rafSpy.mockRestore();
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("should skip RAF delay on subsequent opens", fakeAsync(() => {
|
||||
// First open with double RAF delay
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
// Execute both RAF callbacks (16ms each = 32ms total for first open)
|
||||
tick(16); // First RAF
|
||||
tick(16); // Second RAF
|
||||
expect(overlay.create).toHaveBeenCalledTimes(1);
|
||||
jest.mocked(overlay.create).mockClear();
|
||||
|
||||
// Close by clicking
|
||||
const button = fixture.nativeElement.querySelector("button");
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Second open should skip RAF delay (hasInitialized is now true)
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
// Only need tick(0) to flush microtasks - NO RAF delay on subsequent opens
|
||||
tick(0);
|
||||
expect(overlay.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
flush();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("Race condition prevention", () => {
|
||||
it("should prevent multiple RAF scheduling when toggled rapidly", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Try to toggle back to false before RAF completes
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(false);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Try to toggle back to true
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Execute RAFs
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
// Should only create overlay once
|
||||
expect(overlay.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("should not schedule new RAF if one is already pending", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Try to open again while RAF is pending (shouldn't schedule another)
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(false);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
// Should only have created one overlay
|
||||
expect(overlay.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("should prevent duplicate overlays from click handler during RAF", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Click to close before RAF completes - this should cancel the RAF and prevent overlay creation
|
||||
const button = fixture.nativeElement.querySelector("button");
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Verify popoverOpen was set to false
|
||||
expect(directive.popoverOpen()).toBe(false);
|
||||
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
// Should NOT have created any overlay because RAF was canceled
|
||||
expect(overlay.create).not.toHaveBeenCalled();
|
||||
|
||||
flush();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("Component destruction during RAF", () => {
|
||||
it("should cancel RAF callbacks when component is destroyed", fakeAsync(() => {
|
||||
const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame");
|
||||
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Destroy component before RAF completes
|
||||
fixture.destroy();
|
||||
|
||||
// Should have cancelled animation frames
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
|
||||
cancelAnimationFrameSpy.mockRestore();
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("should not create overlay if destroyed during RAF delay", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Execute first RAF
|
||||
tick(16);
|
||||
|
||||
// Destroy before second RAF
|
||||
fixture.destroy();
|
||||
|
||||
// Execute second RAF (should be no-op)
|
||||
tick(16);
|
||||
|
||||
expect(overlay.create).not.toHaveBeenCalled();
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("should set isDestroyed flag and prevent further operations", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
// Destroy the component
|
||||
fixture.destroy();
|
||||
|
||||
// Try to toggle (should be blocked by isDestroyed check)
|
||||
const button = fixture.nativeElement.querySelector("button");
|
||||
button.click();
|
||||
|
||||
expect(overlay.create).toHaveBeenCalledTimes(1); // Only from initial open
|
||||
|
||||
flush();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("Click handling", () => {
|
||||
it("should open popover on click when closed", fakeAsync(() => {
|
||||
const button = fixture.nativeElement.querySelector("button");
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isOpen).toBe(true);
|
||||
expect(overlay.create).toHaveBeenCalled();
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("should close popover on click when open", fakeAsync(() => {
|
||||
// Open first
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
// Click to close
|
||||
const button = fixture.nativeElement.querySelector("button");
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isOpen).toBe(false);
|
||||
expect(overlayRef.dispose).toHaveBeenCalled();
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("should not process clicks after component is destroyed", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
const initialCreateCount = jest.mocked(overlay.create).mock.calls.length;
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
const button = fixture.nativeElement.querySelector("button");
|
||||
button.click();
|
||||
|
||||
// Should not have created additional overlay
|
||||
expect(overlay.create).toHaveBeenCalledTimes(initialCreateCount);
|
||||
|
||||
flush();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("Resource cleanup", () => {
|
||||
it("should cancel both RAF IDs in disposeAll", fakeAsync(() => {
|
||||
const cancelAnimationFrameSpy = jest.spyOn(window, "cancelAnimationFrame");
|
||||
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Trigger disposal while RAF is pending
|
||||
directive.ngOnDestroy();
|
||||
|
||||
// Should cancel animation frames
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
|
||||
|
||||
cancelAnimationFrameSpy.mockRestore();
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("should dispose overlay on destroy", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
expect(overlayRef.attach).toHaveBeenCalled();
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
expect(overlayRef.dispose).toHaveBeenCalled();
|
||||
|
||||
flush();
|
||||
}));
|
||||
|
||||
it("should unsubscribe from closed events on destroy", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
// Get the subscription (it's private, so we'll verify via disposal)
|
||||
fixture.destroy();
|
||||
|
||||
// Should have disposed overlay which triggers cleanup
|
||||
expect(overlayRef.dispose).toHaveBeenCalled();
|
||||
|
||||
flush();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("Overlay guard in openPopover", () => {
|
||||
it("should not create duplicate overlay if overlayRef already exists", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
expect(overlay.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Try to open again
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(false);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(overlay.create).toHaveBeenCalledTimes(1);
|
||||
|
||||
flush();
|
||||
}));
|
||||
});
|
||||
|
||||
describe("aria-expanded attribute", () => {
|
||||
it("should set aria-expanded to false when closed", () => {
|
||||
const button = fixture.nativeElement.querySelector("button");
|
||||
expect(button.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("should set aria-expanded to true when open", fakeAsync(() => {
|
||||
ngZone.run(() => {
|
||||
directive.popoverOpen.set(true);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
tick(16);
|
||||
tick(16);
|
||||
|
||||
const button = fixture.nativeElement.querySelector("button");
|
||||
expect(button.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
flush();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import {
|
||||
AfterViewInit,
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
ViewContainerRef,
|
||||
effect,
|
||||
input,
|
||||
model,
|
||||
} from "@angular/core";
|
||||
@@ -22,7 +22,7 @@ import { PopoverComponent } from "./popover.component";
|
||||
"[attr.aria-expanded]": "this.popoverOpen()",
|
||||
},
|
||||
})
|
||||
export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
|
||||
export class PopoverTriggerForDirective implements OnDestroy {
|
||||
readonly popoverOpen = model(false);
|
||||
|
||||
readonly popover = input.required<PopoverComponent>({ alias: "bitPopoverTriggerFor" });
|
||||
@@ -31,6 +31,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
|
||||
|
||||
private overlayRef: OverlayRef | null = null;
|
||||
private closedEventsSub: Subscription | null = null;
|
||||
private hasInitialized = false;
|
||||
private rafId1: number | null = null;
|
||||
private rafId2: number | null = null;
|
||||
private isDestroyed = false;
|
||||
|
||||
get positions() {
|
||||
if (!this.position()) {
|
||||
@@ -65,10 +69,44 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
|
||||
private elementRef: ElementRef<HTMLElement>,
|
||||
private viewContainerRef: ViewContainerRef,
|
||||
private overlay: Overlay,
|
||||
) {}
|
||||
) {
|
||||
effect(() => {
|
||||
if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasInitialized) {
|
||||
this.openPopover();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rafId1 !== null || this.rafId2 !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial open - wait for layout to stabilize
|
||||
// First RAF: Waits for Angular's change detection to complete and queues the next paint
|
||||
this.rafId1 = requestAnimationFrame(() => {
|
||||
// Second RAF: Ensures the browser has actually painted that frame and all layout/position calculations are final
|
||||
this.rafId2 = requestAnimationFrame(() => {
|
||||
if (this.isDestroyed || !this.popoverOpen() || this.overlayRef) {
|
||||
return;
|
||||
}
|
||||
this.openPopover();
|
||||
this.hasInitialized = true;
|
||||
this.rafId2 = null;
|
||||
});
|
||||
this.rafId1 = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener("click")
|
||||
togglePopover() {
|
||||
if (this.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.popoverOpen()) {
|
||||
this.closePopover();
|
||||
} else {
|
||||
@@ -77,6 +115,10 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
private openPopover() {
|
||||
if (this.overlayRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.popoverOpen.set(true);
|
||||
this.overlayRef = this.overlay.create(this.defaultPopoverConfig);
|
||||
|
||||
@@ -104,7 +146,7 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
private destroyPopover() {
|
||||
if (!this.overlayRef || !this.popoverOpen()) {
|
||||
if (!this.popoverOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -117,15 +159,19 @@ export class PopoverTriggerForDirective implements OnDestroy, AfterViewInit {
|
||||
this.closedEventsSub = null;
|
||||
this.overlayRef?.dispose();
|
||||
this.overlayRef = null;
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
if (this.popoverOpen()) {
|
||||
this.openPopover();
|
||||
if (this.rafId1 !== null) {
|
||||
cancelAnimationFrame(this.rafId1);
|
||||
this.rafId1 = null;
|
||||
}
|
||||
if (this.rafId2 !== null) {
|
||||
cancelAnimationFrame(this.rafId2);
|
||||
this.rafId2 = null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.isDestroyed = true;
|
||||
this.disposeAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { GlobalStateProvider } from "@bitwarden/state";
|
||||
|
||||
import { LayoutComponent } from "../../layout";
|
||||
@@ -71,6 +72,13 @@ export default {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: {
|
||||
// eslint-disable-next-line
|
||||
copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useClass: StorybookGlobalStateProvider,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
signal,
|
||||
model,
|
||||
computed,
|
||||
OnDestroy,
|
||||
} from "@angular/core";
|
||||
|
||||
import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
|
||||
@@ -32,7 +33,7 @@ export const TOOLTIP_DELAY_MS = 800;
|
||||
"[attr.aria-describedby]": "resolvedDescribedByIds()",
|
||||
},
|
||||
})
|
||||
export class TooltipDirective implements OnInit {
|
||||
export class TooltipDirective implements OnInit, OnDestroy {
|
||||
private static nextId = 0;
|
||||
/**
|
||||
* The value of this input is forwarded to the tooltip.component to render
|
||||
@@ -51,6 +52,7 @@ export class TooltipDirective implements OnInit {
|
||||
|
||||
private readonly isVisible = signal(false);
|
||||
private overlayRef: OverlayRef | undefined;
|
||||
private showTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private overlay = inject(Overlay);
|
||||
private viewContainerRef = inject(ViewContainerRef);
|
||||
@@ -81,13 +83,29 @@ export class TooltipDirective implements OnInit {
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear any pending show timeout
|
||||
*
|
||||
* Use cases: prevent tooltip from appearing after hide; clear existing timeout before showing a
|
||||
* new tooltip
|
||||
*/
|
||||
private clearTimeout() {
|
||||
if (this.showTimeoutId !== undefined) {
|
||||
clearTimeout(this.showTimeoutId);
|
||||
this.showTimeoutId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private destroyTooltip = () => {
|
||||
this.clearTimeout();
|
||||
this.overlayRef?.dispose();
|
||||
this.overlayRef = undefined;
|
||||
this.isVisible.set(false);
|
||||
};
|
||||
|
||||
protected showTooltip = () => {
|
||||
this.clearTimeout();
|
||||
|
||||
if (!this.overlayRef) {
|
||||
this.overlayRef = this.overlay.create({
|
||||
...this.defaultPopoverConfig,
|
||||
@@ -97,8 +115,9 @@ export class TooltipDirective implements OnInit {
|
||||
this.overlayRef.attach(this.tooltipPortal);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.showTimeoutId = setTimeout(() => {
|
||||
this.isVisible.set(true);
|
||||
this.showTimeoutId = undefined;
|
||||
}, TOOLTIP_DELAY_MS);
|
||||
};
|
||||
|
||||
@@ -134,4 +153,8 @@ export class TooltipDirective implements OnInit {
|
||||
ngOnInit() {
|
||||
this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition()));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface OverlayLike {
|
||||
interface OverlayRefStub {
|
||||
attach: (portal: ComponentPortal<unknown>) => unknown;
|
||||
updatePosition: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
describe("TooltipDirective (visibility only)", () => {
|
||||
@@ -68,6 +69,7 @@ describe("TooltipDirective (visibility only)", () => {
|
||||
},
|
||||
})),
|
||||
updatePosition: jest.fn(),
|
||||
dispose: jest.fn(),
|
||||
};
|
||||
|
||||
const overlayMock: OverlayLike = {
|
||||
|
||||
@@ -29,15 +29,15 @@ import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/opera
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionTypes,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import {
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import * as papa from "papaparse";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Collection, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView, Collection } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { filter, firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Collection } from "@bitwarden/admin-console/common";
|
||||
import { Collection } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
import { ImportResult } from "../models/import-result";
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
|
||||
import { ImportResult } from "../models/import-result";
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { FieldType, SecureNoteType } from "@bitwarden/common/vault/enums";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export abstract class ImportCollectionServiceAbstraction {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
|
||||
import { Importer } from "../importers/importer";
|
||||
|
||||
@@ -2,11 +2,11 @@ import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionTypes,
|
||||
CollectionView,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
|
||||
@@ -4,12 +4,11 @@ import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionWithIdRequest } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionService,
|
||||
CollectionWithIdRequest,
|
||||
CollectionView,
|
||||
CollectionTypes,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||
|
||||
@@ -12,6 +12,8 @@ export abstract class UserAsymmetricKeysRegenerationService {
|
||||
* Performs the regeneration of the user's public/private key pair without checking any preconditions.
|
||||
* This should only be used for V1 encryption accounts
|
||||
* @param userId The user id.
|
||||
* @returns True if regeneration was performed, false otherwise.
|
||||
* @throws An error if the regeneration could not be attempted due to missing state
|
||||
*/
|
||||
abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void>;
|
||||
abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet
|
||||
return false;
|
||||
}
|
||||
|
||||
async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void> {
|
||||
async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<boolean> {
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (userKey == null) {
|
||||
throw new Error("User key not found");
|
||||
@@ -152,19 +152,21 @@ export class DefaultUserAsymmetricKeysRegenerationService implements UserAsymmet
|
||||
this.logService.info(
|
||||
"[UserAsymmetricKeyRegeneration] Regeneration not supported for this user at this time.",
|
||||
);
|
||||
return false;
|
||||
} else {
|
||||
this.logService.error(
|
||||
"[UserAsymmetricKeyRegeneration] Regeneration error when submitting the request to the server: " +
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.keyService.setPrivateKey(makeKeyPairResponse.userKeyEncryptedPrivateKey, userId);
|
||||
this.logService.info(
|
||||
"[UserAsymmetricKeyRegeneration] User's asymmetric keys successfully regenerated.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async userKeyCanDecrypt(userKey: UserKey, userId: UserId): Promise<boolean> {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="tw-flex-1">
|
||||
@let passwordManagerSeats = cart.passwordManager.seats;
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.name | i18n }} x
|
||||
{{ passwordManagerSeats.quantity }} {{ passwordManagerSeats.translationKey | i18n }} x
|
||||
{{ passwordManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ term }}
|
||||
@@ -63,7 +63,7 @@
|
||||
<div id="additional-storage" class="tw-flex tw-justify-between">
|
||||
<div class="tw-flex-1">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalStorage.quantity }} {{ additionalStorage.name | i18n }} x
|
||||
{{ additionalStorage.quantity }} {{ additionalStorage.translationKey | i18n }} x
|
||||
{{ additionalStorage.cost | currency: "USD" : "symbol" }} /
|
||||
{{ term }}
|
||||
</div>
|
||||
@@ -86,7 +86,7 @@
|
||||
<!-- Secrets Manager Members -->
|
||||
<div id="secrets-manager-members" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.name | i18n }} x
|
||||
{{ secretsManagerSeats.quantity }} {{ secretsManagerSeats.translationKey | i18n }} x
|
||||
{{ secretsManagerSeats.cost | currency: "USD" : "symbol" }}
|
||||
/ {{ term }}
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@
|
||||
<div id="additional-service-accounts" class="tw-flex tw-justify-between">
|
||||
<div bitTypography="body1" class="tw-text-muted">
|
||||
{{ additionalServiceAccounts.quantity }}
|
||||
{{ additionalServiceAccounts.name | i18n }} x
|
||||
{{ additionalServiceAccounts.translationKey | i18n }} x
|
||||
{{ additionalServiceAccounts.cost | currency: "USD" : "symbol" }}
|
||||
/
|
||||
{{ term }}
|
||||
|
||||
@@ -67,7 +67,7 @@ The component uses the following Cart and CartItem data structures:
|
||||
|
||||
```typescript
|
||||
export type CartItem = {
|
||||
name: string; // Display name for i18n lookup
|
||||
translationKey: string; // Translation key for i18n lookup
|
||||
quantity: number; // Number of items
|
||||
cost: number; // Cost per item
|
||||
discount?: Discount; // Optional item-level discount
|
||||
@@ -92,7 +92,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
|
||||
|
||||
export type Discount = {
|
||||
type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff
|
||||
active: boolean; // Whether discount is currently applied
|
||||
value: number; // Dollar amount or percentage (20 for 20%)
|
||||
};
|
||||
```
|
||||
@@ -108,7 +107,7 @@ The cart summary component provides flexibility through its structured Cart inpu
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
@@ -124,12 +123,12 @@ The cart summary component provides flexibility through its structured Cart inpu
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
translationKey: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
@@ -145,14 +144,13 @@ The cart summary component provides flexibility through its structured Cart inpu
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
discount: {
|
||||
type: 'percent-off',
|
||||
active: true,
|
||||
value: 20
|
||||
},
|
||||
estimatedTax: 8.00
|
||||
@@ -188,7 +186,7 @@ Show cart with yearly subscription:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 500.00
|
||||
}
|
||||
},
|
||||
@@ -211,12 +209,12 @@ Show cart with password manager and additional storage:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
translationKey: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
@@ -239,14 +237,14 @@ Show cart with password manager and secrets manager seats only:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 30.00
|
||||
}
|
||||
},
|
||||
@@ -269,19 +267,19 @@ Show cart with password manager, secrets manager seats, and additional service a
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 30.00
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'additionalServiceAccounts',
|
||||
translationKey: 'additionalServiceAccounts',
|
||||
cost: 6.00
|
||||
}
|
||||
},
|
||||
@@ -304,24 +302,24 @@ Show a cart with all available products:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
translationKey: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 30.00
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: 'additionalServiceAccounts',
|
||||
translationKey: 'additionalServiceAccounts',
|
||||
cost: 6.00
|
||||
}
|
||||
},
|
||||
@@ -344,19 +342,18 @@ Show cart with percentage-based discount:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: 'additionalStorageGB',
|
||||
translationKey: 'additionalStorageGB',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
cadence: 'monthly',
|
||||
discount: {
|
||||
type: 'percent-off',
|
||||
active: true,
|
||||
value: 20
|
||||
},
|
||||
estimatedTax: 10.40
|
||||
@@ -377,21 +374,20 @@ Show cart with fixed amount discount:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 50.00
|
||||
}
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: 'members',
|
||||
translationKey: 'members',
|
||||
cost: 30.00
|
||||
}
|
||||
},
|
||||
cadence: 'annually',
|
||||
discount: {
|
||||
type: 'amount-off',
|
||||
active: true,
|
||||
value: 50.00
|
||||
},
|
||||
estimatedTax: 95.00
|
||||
@@ -431,7 +427,7 @@ Show cart with premium plan:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: 'premiumMembership',
|
||||
translationKey: 'premiumMembership',
|
||||
cost: 10.00
|
||||
}
|
||||
},
|
||||
@@ -454,7 +450,7 @@ Show cart with families plan:
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: 'familiesMembership',
|
||||
translationKey: 'familiesMembership',
|
||||
cost: 40.00
|
||||
}
|
||||
},
|
||||
@@ -488,8 +484,7 @@ Show cart with families plan:
|
||||
- Use consistent naming and formatting for cart items
|
||||
- Include clear quantity and unit pricing information
|
||||
- Ensure tax estimates are accurate and clearly labeled
|
||||
- Set `active: true` on discounts that should be displayed
|
||||
- Use localized strings for CartItem names (for i18n lookup)
|
||||
- Use valid translation keys for CartItem translationKey (for i18n lookup)
|
||||
- Provide complete Cart object with all required fields
|
||||
- Use "annually" or "monthly" for cadence (not "year" or "month")
|
||||
|
||||
|
||||
@@ -16,24 +16,24 @@ describe("CartSummaryComponent", () => {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "secretsManagerSeats",
|
||||
translationKey: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
},
|
||||
},
|
||||
@@ -270,7 +270,6 @@ describe("CartSummaryComponent", () => {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
},
|
||||
};
|
||||
@@ -296,7 +295,6 @@ describe("CartSummaryComponent", () => {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 50.0,
|
||||
},
|
||||
};
|
||||
@@ -315,33 +313,12 @@ describe("CartSummaryComponent", () => {
|
||||
expect(discountAmount.nativeElement.textContent).toContain("-$50.00");
|
||||
});
|
||||
|
||||
it("should not display discount when discount is inactive", () => {
|
||||
// Arrange
|
||||
const cartWithInactiveDiscount: Cart = {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: false,
|
||||
value: 20,
|
||||
},
|
||||
};
|
||||
fixture.componentRef.setInput("cart", cartWithInactiveDiscount);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Act / Assert
|
||||
const discountSection = fixture.debugElement.query(
|
||||
By.css('[data-testid="discount-section"]'),
|
||||
);
|
||||
expect(discountSection).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should apply discount to total calculation", () => {
|
||||
// Arrange
|
||||
const cartWithDiscount: Cart = {
|
||||
...mockCart,
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
},
|
||||
};
|
||||
@@ -382,24 +359,24 @@ describe("CartSummaryComponent - Custom Header Template", () => {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "secretsManagerSeats",
|
||||
translationKey: "secretsManagerSeats",
|
||||
cost: 30,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
@@ -98,12 +98,12 @@ export const WithAdditionalStorage: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -120,7 +120,7 @@ export const PasswordManagerYearlyCadence: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 500.0,
|
||||
},
|
||||
},
|
||||
@@ -137,14 +137,14 @@ export const SecretsManagerSeatsOnly: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
},
|
||||
@@ -161,19 +161,19 @@ export const SecretsManagerSeatsAndServiceAccounts: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6.0,
|
||||
},
|
||||
},
|
||||
@@ -190,24 +190,24 @@ export const AllProducts: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 2,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 6.0,
|
||||
},
|
||||
},
|
||||
@@ -223,7 +223,7 @@ export const FamiliesPlan: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "familiesMembership",
|
||||
translationKey: "familiesMembership",
|
||||
cost: 40.0,
|
||||
},
|
||||
},
|
||||
@@ -239,7 +239,7 @@ export const PremiumPlan: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "premiumMembership",
|
||||
translationKey: "premiumMembership",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -255,7 +255,7 @@ export const CustomHeaderTemplate: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "premiumMembership",
|
||||
translationKey: "premiumMembership",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -296,19 +296,18 @@ export const WithPercentDiscount: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
cadence: "monthly",
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
},
|
||||
estimatedTax: 10.4,
|
||||
@@ -322,21 +321,20 @@ export const WithAmountDiscount: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50.0,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 30.0,
|
||||
},
|
||||
},
|
||||
cadence: "annually",
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 50.0,
|
||||
},
|
||||
estimatedTax: 95.0,
|
||||
|
||||
@@ -116,7 +116,7 @@ export class CartSummaryComponent {
|
||||
*/
|
||||
readonly discountAmount = computed<number>(() => {
|
||||
const { discount } = this.cart();
|
||||
if (!discount || !discount.active) {
|
||||
if (!discount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export class CartSummaryComponent {
|
||||
*/
|
||||
readonly discountLabel = computed<string>(() => {
|
||||
const { discount } = this.cart();
|
||||
if (!discount || !discount.active) {
|
||||
if (!discount) {
|
||||
return "";
|
||||
}
|
||||
return getLabel(this.i18nService, discount);
|
||||
|
||||
@@ -38,8 +38,6 @@ import { DiscountTypes, DiscountType } from "@bitwarden/pricing";
|
||||
type Discount = {
|
||||
/** The type of discount */
|
||||
type: DiscountType; // DiscountTypes.AmountOff | DiscountTypes.PercentOff
|
||||
/** Whether the discount is currently active */
|
||||
active: boolean;
|
||||
/** The discount value (percentage or amount depending on type) */
|
||||
value: number;
|
||||
};
|
||||
@@ -47,8 +45,7 @@ type Discount = {
|
||||
|
||||
## Behavior
|
||||
|
||||
- The badge is only displayed when `discount` is provided, `active` is `true`, and `value` is
|
||||
greater than 0.
|
||||
- The badge is only displayed when `discount` is provided and `value` is greater than 0.
|
||||
- For `percent-off` type: percentage values can be provided as 0-100 (e.g., `20` for 20%) or 0-1
|
||||
(e.g., `0.2` for 20%).
|
||||
- For `amount-off` type: amount values are formatted as currency (USD) with 2 decimal places.
|
||||
@@ -62,7 +59,3 @@ type Discount = {
|
||||
### Amount Discount
|
||||
|
||||
<Canvas of={DiscountBadgeStories.AmountDiscount} />
|
||||
|
||||
### Inactive Discount
|
||||
|
||||
<Canvas of={DiscountBadgeStories.InactiveDiscount} />
|
||||
|
||||
@@ -35,30 +35,18 @@ describe("DiscountBadgeComponent", () => {
|
||||
expect(component.display()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when discount is inactive", () => {
|
||||
it("should return true when discount has percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: false,
|
||||
value: 20,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.display()).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when discount is active with percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
expect(component.display()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when discount is active with amount-off", () => {
|
||||
it("should return true when discount has amount-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 10.99,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -68,7 +56,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should return false when value is 0 (percent-off)", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -78,7 +65,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should return false when value is 0 (amount-off)", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 0,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -96,7 +82,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should return percentage text when type is percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -108,7 +93,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should convert decimal value to percentage for percent-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0.15,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
@@ -119,7 +103,6 @@ describe("DiscountBadgeComponent", () => {
|
||||
it("should return amount text when type is amount-off", () => {
|
||||
fixture.componentRef.setInput("discount", {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 10.99,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -40,7 +40,6 @@ export const PercentDiscount: Story = {
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 20,
|
||||
} as Discount,
|
||||
},
|
||||
@@ -54,7 +53,6 @@ export const PercentDiscountDecimal: Story = {
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0.15, // 15% in decimal format
|
||||
} as Discount,
|
||||
},
|
||||
@@ -68,7 +66,6 @@ export const AmountDiscount: Story = {
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 10.99,
|
||||
} as Discount,
|
||||
},
|
||||
@@ -82,26 +79,11 @@ export const LargeAmountDiscount: Story = {
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.AmountOff,
|
||||
active: true,
|
||||
value: 99.99,
|
||||
} as Discount,
|
||||
},
|
||||
};
|
||||
|
||||
export const InactiveDiscount: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<billing-discount-badge [discount]="discount"></billing-discount-badge>`,
|
||||
}),
|
||||
args: {
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: false,
|
||||
value: 20,
|
||||
} as Discount,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoDiscount: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -23,7 +23,7 @@ export class DiscountBadgeComponent {
|
||||
if (!discount) {
|
||||
return false;
|
||||
}
|
||||
return discount.active && discount.value > 0;
|
||||
return discount.value > 0;
|
||||
});
|
||||
|
||||
readonly label = computed<Maybe<string>>(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Discount } from "@bitwarden/pricing";
|
||||
|
||||
export type CartItem = {
|
||||
name: string;
|
||||
translationKey: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
discount?: Discount;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user