1
0
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:
Hinton
2026-01-19 12:16:25 +01:00
545 changed files with 15550 additions and 3078 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

@@ -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";

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View 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);
});
});
});

View 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];
});
}

View File

@@ -0,0 +1 @@
export * from "./collection-utils";

View File

@@ -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!,
};
}
}
};
}

View 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");
}
}

View 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");
}
}

View 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");
}
}

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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");

View File

@@ -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";

View File

@@ -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;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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);

View 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];

View 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];

View File

@@ -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";

View File

@@ -2,7 +2,9 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true,
"emitDecoratorMetadata": false
"emitDecoratorMetadata": false,
"module": "nodenext",
"moduleResolution": "nodenext"
},
"files": ["./test.setup.ts"]
}

View File

@@ -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);
}
}
}

View File

@@ -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,

View File

@@ -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({

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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();
});
}

View File

@@ -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();
}));
});
});

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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();
}
}

View File

@@ -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 = {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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>;
}

View File

@@ -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> {

View File

@@ -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 }}

View File

@@ -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")

View File

@@ -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,
},
},

View File

@@ -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,

View File

@@ -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);

View File

@@ -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} />

View File

@@ -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();

View File

@@ -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,

View File

@@ -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>>(() => {

View File

@@ -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