[PM-27675] Browser item transfer integration (#17918)

* [PM-27675] Integrate dialogs into VaultItemTransferService

* [PM-27675] Update tests for new dialogs

* [PM-27675] Center dialogs and prevent closing with escape or pointer events

* [PM-27675] Add transferInProgress$ observable to VaultItemsTransferService

* [PM-27675] Hook vault item transfer service into browser vault component

* [PM-27675] Move defaultUserCollection$ to collection service

* [PM-27675] Cleanup dialog styles

* [PM-27675] Introduce readySubject to popup vault component to keep prevent flashing content while item transfer is in progress

* [PM-27675] Fix vault-v2 tests
This commit is contained in:
Shane Melton
2025-12-16 15:03:48 -08:00
committed by GitHub
parent 3049cfad7d
commit 06d15e9681
13 changed files with 464 additions and 137 deletions

View File

@@ -108,7 +108,7 @@
</div>
<ng-template #vaultContentTemplate>
<ng-container *ngIf="vaultState === null">
<ng-container *ngIf="vaultState === null && !(loading$ | async)">
<app-autofill-vault-list-items></app-autofill-vault-list-items>
<app-vault-list-items-container
[title]="'favorites' | i18n"

View File

@@ -28,7 +28,11 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res
import { TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService } from "@bitwarden/components";
import { StateProvider } from "@bitwarden/state";
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
import {
DecryptionFailureDialogComponent,
VaultItemsTransferService,
DefaultVaultItemsTransferService,
} from "@bitwarden/vault";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils";
@@ -193,6 +197,11 @@ describe("VaultV2Component", () => {
stop: jest.fn(),
} as Partial<VaultPopupScrollPositionService>;
const vaultItemsTransferSvc = {
transferInProgress$: new BehaviorSubject<boolean>(false),
enforceOrganizationDataOwnership: jest.fn().mockResolvedValue(undefined),
} as Partial<VaultItemsTransferService>;
function getObs<T = unknown>(cmp: any, key: string): Observable<T> {
return cmp[key] as Observable<T>;
}
@@ -283,6 +292,9 @@ describe("VaultV2Component", () => {
AutofillVaultListItemsComponent,
VaultListItemsContainerComponent,
],
providers: [
{ provide: VaultItemsTransferService, useValue: DefaultVaultItemsTransferService },
],
},
add: {
imports: [
@@ -296,6 +308,7 @@ describe("VaultV2Component", () => {
AutofillVaultListItemsStubComponent,
VaultListItemsContainerStubComponent,
],
providers: [{ provide: VaultItemsTransferService, useValue: vaultItemsTransferSvc }],
},
});
@@ -344,6 +357,7 @@ describe("VaultV2Component", () => {
it("loading$ is true when items loading or filters missing; false when both ready", () => {
const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject<boolean>;
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
const readySubject$ = component["readySubject"] as unknown as BehaviorSubject<boolean>;
const values: boolean[] = [];
getObs<boolean>(component, "loading$").subscribe((v) => values.push(!!v));
@@ -354,6 +368,8 @@ describe("VaultV2Component", () => {
itemsLoading$.next(false);
readySubject$.next(true);
expect(values[values.length - 1]).toBe(false);
});

View File

@@ -16,6 +16,7 @@ import {
switchMap,
take,
tap,
BehaviorSubject,
} from "rxjs";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
@@ -42,7 +43,11 @@ import {
NoItemsModule,
TypographyModule,
} from "@bitwarden/components";
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
import {
DecryptionFailureDialogComponent,
VaultItemsTransferService,
DefaultVaultItemsTransferService,
} from "@bitwarden/vault";
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
import { BrowserApi } from "../../../../platform/browser/browser-api";
@@ -105,6 +110,7 @@ type VaultState = UnionOfValues<typeof VaultState>;
VaultFadeInOutSkeletonComponent,
VaultFadeInOutComponent,
],
providers: [{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }],
})
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
@@ -125,7 +131,22 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
activeUserId: UserId | null = null;
private loading$ = this.vaultPopupLoadingService.loading$.pipe(
/**
* Subject that indicates whether the vault is ready to render
* and that all initialization tasks have been completed (ngOnInit).
* @private
*/
private readySubject = new BehaviorSubject(false);
/**
* Indicates whether the vault is loading and not yet ready to be displayed.
* @protected
*/
protected loading$ = combineLatest([
this.vaultPopupLoadingService.loading$,
this.readySubject.asObservable(),
]).pipe(
map(([loading, ready]) => loading || !ready),
distinctUntilChanged(),
tap((loading) => {
const key = loading ? "loadingVault" : "vaultLoaded";
@@ -200,14 +221,15 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
protected showSkeletonsLoaders$ = combineLatest([
this.loading$,
this.searchService.isCipherSearching$,
this.vaultItemsTransferService.transferInProgress$,
this.skeletonFeatureFlag$,
]).pipe(
map(
([loading, cipherSearching, skeletonsEnabled]) =>
(loading || cipherSearching) && skeletonsEnabled,
),
map(([loading, cipherSearching, transferInProgress, skeletonsEnabled]) => {
return (loading || cipherSearching || transferInProgress) && skeletonsEnabled;
}),
distinctUntilChanged(),
skeletonLoadingDelay(),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected newItemItemValues$: Observable<NewItemInitialValues> =
@@ -251,6 +273,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private i18nService: I18nService,
private configService: ConfigService,
private searchService: SearchService,
private vaultItemsTransferService: VaultItemsTransferService,
) {
combineLatest([
this.vaultPopupItemsService.emptyVault$,
@@ -305,6 +328,10 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
cipherIds: ciphers.map((c) => c.id as CipherId),
});
});
await this.vaultItemsTransferService.enforceOrganizationDataOwnership(this.activeUserId);
this.readySubject.next(true);
}
ngOnDestroy() {

View File

@@ -9,6 +9,14 @@ import { CollectionData, Collection, CollectionView } from "../models";
export abstract class CollectionService {
abstract encryptedCollections$(userId: UserId): Observable<Collection[] | null>;
abstract decryptedCollections$(userId: UserId): Observable<CollectionView[]>;
/**
* Gets the default collection for a user in a given organization, if it exists.
*/
abstract defaultUserCollection$(
userId: UserId,
orgId: OrganizationId,
): Observable<CollectionView | undefined>;
abstract upsert(collection: CollectionData, userId: UserId): Promise<any>;
abstract replace(collections: { [id: string]: CollectionData }, userId: UserId): Promise<any>;
/**

View File

@@ -15,9 +15,10 @@ import {
} from "@bitwarden/common/spec";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { newGuid } from "@bitwarden/guid";
import { KeyService } from "@bitwarden/key-management";
import { CollectionData, CollectionView } from "../models";
import { CollectionData, CollectionTypes, CollectionView } from "../models";
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
import { DefaultCollectionService } from "./default-collection.service";
@@ -389,6 +390,83 @@ describe("DefaultCollectionService", () => {
});
});
describe("defaultUserCollection$", () => {
it("returns the default collection when one exists matching the org", async () => {
const orgId = newGuid() as OrganizationId;
const defaultCollection = collectionViewDataFactory(orgId);
defaultCollection.type = CollectionTypes.DefaultUserCollection;
const regularCollection = collectionViewDataFactory(orgId);
regularCollection.type = CollectionTypes.SharedCollection;
await setDecryptedState([defaultCollection, regularCollection]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgId));
expect(result).toBeDefined();
expect(result?.id).toBe(defaultCollection.id);
expect(result?.isDefaultCollection).toBe(true);
});
it("returns undefined when no default collection exists", async () => {
const orgId = newGuid() as OrganizationId;
const collection1 = collectionViewDataFactory(orgId);
collection1.type = CollectionTypes.SharedCollection;
const collection2 = collectionViewDataFactory(orgId);
collection2.type = CollectionTypes.SharedCollection;
await setDecryptedState([collection1, collection2]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgId));
expect(result).toBeUndefined();
});
it("returns undefined when default collection exists but for different org", async () => {
const orgA = newGuid() as OrganizationId;
const orgB = newGuid() as OrganizationId;
const defaultCollectionForOrgA = collectionViewDataFactory(orgA);
defaultCollectionForOrgA.type = CollectionTypes.DefaultUserCollection;
await setDecryptedState([defaultCollectionForOrgA]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgB));
expect(result).toBeUndefined();
});
it("returns undefined when collections array is empty", async () => {
const orgId = newGuid() as OrganizationId;
await setDecryptedState([]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgId));
expect(result).toBeUndefined();
});
it("returns correct collection when multiple orgs have default collections", async () => {
const orgA = newGuid() as OrganizationId;
const orgB = newGuid() as OrganizationId;
const defaultCollectionForOrgA = collectionViewDataFactory(orgA);
defaultCollectionForOrgA.type = CollectionTypes.DefaultUserCollection;
const defaultCollectionForOrgB = collectionViewDataFactory(orgB);
defaultCollectionForOrgB.type = CollectionTypes.DefaultUserCollection;
await setDecryptedState([defaultCollectionForOrgA, defaultCollectionForOrgB]);
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgB));
expect(result).toBeDefined();
expect(result?.id).toBe(defaultCollectionForOrgB.id);
expect(result?.organizationId).toBe(orgB);
});
});
const setEncryptedState = (collectionData: CollectionData[] | null) =>
stateProvider.setUserState(
ENCRYPTED_COLLECTION_DATA_KEY,

View File

@@ -87,6 +87,17 @@ export class DefaultCollectionService implements CollectionService {
return result$;
}
defaultUserCollection$(
userId: UserId,
orgId: OrganizationId,
): Observable<CollectionView | undefined> {
return this.decryptedCollections$(userId).pipe(
map((collections) => {
return collections.find((c) => c.isDefaultCollection && c.organizationId === orgId);
}),
);
}
private initializeDecryptedState(userId: UserId): Observable<CollectionView[]> {
return combineLatest([
this.encryptedCollections$(userId),

View File

@@ -31,6 +31,11 @@ export type UserMigrationInfo =
};
export abstract class VaultItemsTransferService {
/**
* Indicates whether a vault items transfer is currently in progress.
*/
abstract transferInProgress$: Observable<boolean>;
/**
* Gets information about whether the given user requires migration of their vault items
* from My Vault to a My Items collection, and whether they are capable of performing that migration.

View File

@@ -11,7 +11,7 @@
<p bitTypography="body1">
{{ "leaveConfirmationDialogContentOne" | i18n }}
</p>
<p bitTypography="body1">
<p bitTypography="body1" class="tw-mb-0">
{{ "leaveConfirmationDialogContentTwo" | i18n }}
</p>
</ng-container>
@@ -25,7 +25,7 @@
{{ "goBack" | i18n }}
</button>
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center">
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
{{ "howToManageMyVault" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>

View File

@@ -12,6 +12,7 @@ import {
DialogModule,
LinkModule,
TypographyModule,
CenterPositionStrategy,
} from "@bitwarden/components";
export interface LeaveConfirmationDialogParams {
@@ -58,6 +59,8 @@ export class LeaveConfirmationDialogComponent {
static open(dialogService: DialogService, config: DialogConfig<LeaveConfirmationDialogParams>) {
return dialogService.open<LeaveConfirmationDialogResultType>(LeaveConfirmationDialogComponent, {
positionStrategy: new CenterPositionStrategy(),
disableClose: true,
...config,
});
}

View File

@@ -14,7 +14,7 @@
{{ "declineAndLeave" | i18n }}
</button>
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center">
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
{{ "whyAmISeeingThis" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>

View File

@@ -12,6 +12,7 @@ import {
DialogModule,
LinkModule,
TypographyModule,
CenterPositionStrategy,
} from "@bitwarden/components";
export interface TransferItemsDialogParams {
@@ -58,6 +59,8 @@ export class TransferItemsDialogComponent {
static open(dialogService: DialogService, config: DialogConfig<TransferItemsDialogParams>) {
return dialogService.open<TransferItemsDialogResultType>(TransferItemsDialogComponent, {
positionStrategy: new CenterPositionStrategy(),
disableClose: true,
...config,
});
}

View File

@@ -1,5 +1,5 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { firstValueFrom, of, Subject } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
@@ -14,14 +14,20 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService, ToastService } from "@bitwarden/components";
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import {
LeaveConfirmationDialogResult,
TransferItemsDialogResult,
} from "../components/vault-items-transfer";
import { DefaultVaultItemsTransferService } from "./default-vault-items-transfer.service";
describe("DefaultVaultItemsTransferService", () => {
let service: DefaultVaultItemsTransferService;
let transferInProgressValues: boolean[];
let mockCipherService: MockProxy<CipherService>;
let mockPolicyService: MockProxy<PolicyService>;
@@ -37,6 +43,25 @@ describe("DefaultVaultItemsTransferService", () => {
const organizationId = "org-id" as OrganizationId;
const collectionId = "collection-id" as CollectionId;
/**
* Creates a mock DialogRef that emits the provided result when closed
*/
function createMockDialogRef<T>(result: T): DialogRef<T> {
const closed$ = new Subject<T>();
const dialogRef = {
closed: closed$.asObservable(),
close: jest.fn(),
} as unknown as DialogRef<T>;
// Emit the result asynchronously to simulate dialog closing
setTimeout(() => {
closed$.next(result);
closed$.complete();
}, 0);
return dialogRef;
}
beforeEach(() => {
mockCipherService = mock<CipherService>();
mockPolicyService = mock<PolicyService>();
@@ -49,6 +74,7 @@ describe("DefaultVaultItemsTransferService", () => {
mockConfigService = mock<ConfigService>();
mockI18nService.t.mockImplementation((key) => key);
transferInProgressValues = [];
service = new DefaultVaultItemsTransferService(
mockCipherService,
@@ -69,12 +95,12 @@ describe("DefaultVaultItemsTransferService", () => {
policies?: Policy[];
organizations?: Organization[];
ciphers?: CipherView[];
collections?: CollectionView[];
defaultCollection?: CollectionView;
}): void {
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
}
it("calls policiesByType$ with correct PolicyType", async () => {
@@ -151,39 +177,12 @@ describe("DefaultVaultItemsTransferService", () => {
});
it("includes defaultCollectionId when a default collection exists", async () => {
mockCollectionService.decryptedCollections$.mockReturnValue(
of([
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
]),
);
const result = await firstValueFrom(service.userMigrationInfo$(userId));
expect(result).toEqual({
requiresMigration: true,
enforcingOrganization: organization,
defaultCollectionId: collectionId,
});
});
it("returns default collection only for the enforcing organization", async () => {
mockCollectionService.decryptedCollections$.mockReturnValue(
of([
{
id: "wrong-collection-id" as CollectionId,
organizationId: "wrong-org-id" as OrganizationId,
isDefaultCollection: true,
} as CollectionView,
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
]),
mockCollectionService.defaultUserCollection$.mockReturnValue(
of({
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView),
);
const result = await firstValueFrom(service.userMigrationInfo$(userId));
@@ -542,13 +541,13 @@ describe("DefaultVaultItemsTransferService", () => {
policies?: Policy[];
organizations?: Organization[];
ciphers?: CipherView[];
collections?: CollectionView[];
defaultCollection?: CollectionView;
}): void {
mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
}
it("does nothing when feature flag is disabled", async () => {
@@ -557,13 +556,11 @@ describe("DefaultVaultItemsTransferService", () => {
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
await service.enforceOrganizationDataOwnership(userId);
@@ -571,7 +568,7 @@ describe("DefaultVaultItemsTransferService", () => {
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.MigrateMyVaultToMyItems,
);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
@@ -580,7 +577,7 @@ describe("DefaultVaultItemsTransferService", () => {
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
@@ -593,7 +590,7 @@ describe("DefaultVaultItemsTransferService", () => {
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
@@ -602,7 +599,6 @@ describe("DefaultVaultItemsTransferService", () => {
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [],
});
await service.enforceOrganizationDataOwnership(userId);
@@ -610,69 +606,48 @@ describe("DefaultVaultItemsTransferService", () => {
expect(mockLogService.warning).toHaveBeenCalledWith(
"Default collection is missing for user during organization data ownership enforcement",
);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockDialogService.open).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("shows confirmation dialog when migration is required", async () => {
it("does not transfer items when user declines and confirms leaving", async () => {
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.openSimpleDialog.mockResolvedValue(false);
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
title: "Requires migration",
content: "Your vault requires migration of personal items to your organization.",
type: "warning",
});
});
it("does not transfer items when user declines confirmation", async () => {
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
});
mockDialogService.openSimpleDialog.mockResolvedValue(false);
// User declines transfer, then confirms leaving
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
await service.enforceOrganizationDataOwnership(userId);
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("transfers items and shows success toast when user confirms", async () => {
it("transfers items and shows success toast when user accepts transfer", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.openSimpleDialog.mockResolvedValue(true);
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
@@ -695,15 +670,16 @@ describe("DefaultVaultItemsTransferService", () => {
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.openSimpleDialog.mockResolvedValue(true);
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
await service.enforceOrganizationDataOwnership(userId);
@@ -717,5 +693,171 @@ describe("DefaultVaultItemsTransferService", () => {
message: "errorOccurred",
});
});
it("re-shows transfer dialog when user goes back from leave confirmation", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
// User declines, goes back, then accepts
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Accepted));
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
// Dialog should have been opened 3 times: transfer -> leave -> transfer (after going back)
expect(mockDialogService.open).toHaveBeenCalledTimes(3);
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
});
it("allows multiple back navigations before accepting transfer", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
// User declines, goes back, declines again, goes back again, then accepts
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Accepted));
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
// Dialog should have been opened 5 times
expect(mockDialogService.open).toHaveBeenCalledTimes(5);
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
});
it("allows user to go back and then confirm leaving", async () => {
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
// User declines, goes back, declines again, then confirms leaving
mockDialogService.open
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.open).toHaveBeenCalledTimes(4);
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
});
describe("transferInProgress$", () => {
const policy = {
organizationId: organizationId,
revisionDate: new Date("2024-01-01"),
} as Policy;
const organization = {
id: organizationId,
name: "Test Org",
} as Organization;
function setupMocksForTransferScenario(options: {
featureEnabled?: boolean;
policies?: Policy[];
organizations?: Organization[];
ciphers?: CipherView[];
defaultCollection?: CollectionView;
}): void {
mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
}
it("emits false initially", async () => {
const result = await firstValueFrom(service.transferInProgress$);
expect(result).toBe(false);
});
it("emits true during transfer and false after successful completion", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForTransferScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
// Subscribe to track all emitted values
service.transferInProgress$.subscribe((value) => transferInProgressValues.push(value));
await service.enforceOrganizationDataOwnership(userId);
// Should have emitted: false (initial), true (transfer started), false (transfer completed)
expect(transferInProgressValues).toEqual([false, true, false]);
});
it("emits false after transfer fails with error", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForTransferScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
defaultCollection: {
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
});
mockDialogService.open.mockReturnValueOnce(
createMockDialogRef(TransferItemsDialogResult.Accepted),
);
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
// Subscribe to track all emitted values
service.transferInProgress$.subscribe((value) => transferInProgressValues.push(value));
await service.enforceOrganizationDataOwnership(userId);
// Should have emitted: false (initial), true (transfer started), false (transfer failed)
expect(transferInProgressValues).toEqual([false, true, false]);
});
});
});

View File

@@ -1,5 +1,13 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map, of, Observable, combineLatest } from "rxjs";
import {
firstValueFrom,
switchMap,
map,
of,
Observable,
combineLatest,
BehaviorSubject,
} from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService } from "@bitwarden/admin-console/common";
@@ -23,6 +31,12 @@ import {
VaultItemsTransferService,
UserMigrationInfo,
} from "../abstractions/vault-items-transfer.service";
import {
TransferItemsDialogComponent,
TransferItemsDialogResult,
LeaveConfirmationDialogComponent,
LeaveConfirmationDialogResult,
} from "../components/vault-items-transfer";
@Injectable()
export class DefaultVaultItemsTransferService implements VaultItemsTransferService {
@@ -38,6 +52,10 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
private configService: ConfigService,
) {}
private _transferInProgressSubject = new BehaviorSubject(false);
transferInProgress$ = this._transferInProgressSubject.asObservable();
private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> {
return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe(
map(
@@ -60,18 +78,6 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
);
}
private defaultUserCollection$(
userId: UserId,
organizationId: OrganizationId,
): Observable<CollectionId | undefined> {
return this.collectionService.decryptedCollections$(userId).pipe(
map((collections) => {
return collections.find((c) => c.isDefaultCollection && c.organizationId === organizationId)
?.id;
}),
);
}
userMigrationInfo$(userId: UserId): Observable<UserMigrationInfo> {
return this.enforcingOrganization$(userId).pipe(
switchMap((enforcingOrganization) => {
@@ -82,13 +88,13 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
}
return combineLatest([
this.personalCiphers$(userId),
this.defaultUserCollection$(userId, enforcingOrganization.id),
this.collectionService.defaultUserCollection$(userId, enforcingOrganization.id),
]).pipe(
map(([personalCiphers, defaultCollectionId]): UserMigrationInfo => {
map(([personalCiphers, defaultCollection]): UserMigrationInfo => {
return {
requiresMigration: personalCiphers.length > 0,
enforcingOrganization,
defaultCollectionId,
defaultCollectionId: defaultCollection?.id,
};
}),
);
@@ -96,6 +102,35 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
);
}
/**
* Prompts the user to accept or decline the vault items transfer.
* If declined, shows a leave confirmation dialog with option to go back.
* @returns true if user accepts transfer, false if user confirms leaving
*/
private async promptUserForTransfer(organizationName: string): Promise<boolean> {
const confirmDialogRef = TransferItemsDialogComponent.open(this.dialogService, {
data: { organizationName },
});
const confirmResult = await firstValueFrom(confirmDialogRef.closed);
if (confirmResult === TransferItemsDialogResult.Accepted) {
return true;
}
const leaveDialogRef = LeaveConfirmationDialogComponent.open(this.dialogService, {
data: { organizationName },
});
const leaveResult = await firstValueFrom(leaveDialogRef.closed);
if (leaveResult === LeaveConfirmationDialogResult.Back) {
return this.promptUserForTransfer(organizationName);
}
return false;
}
async enforceOrganizationDataOwnership(userId: UserId): Promise<void> {
const featureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.MigrateMyVaultToMyItems,
@@ -119,30 +154,29 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
return;
}
// Temporary confirmation dialog. Full implementation in PM-27663
const confirmMigration = await this.dialogService.openSimpleDialog({
title: "Requires migration",
content: "Your vault requires migration of personal items to your organization.",
type: "warning",
});
const userAcceptedTransfer = await this.promptUserForTransfer(
migrationInfo.enforcingOrganization.name,
);
if (!confirmMigration) {
// TODO: Show secondary confirmation dialog in PM-27663, for now we just exit
// TODO: Revoke user from organization if they decline migration PM-29465
if (!userAcceptedTransfer) {
// TODO: Revoke user from organization if they decline migration and show toast PM-29465
return;
}
try {
this._transferInProgressSubject.next(true);
await this.transferPersonalItems(
userId,
migrationInfo.enforcingOrganization.id,
migrationInfo.defaultCollectionId,
);
this._transferInProgressSubject.next(false);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemsTransferred"),
});
} catch (error) {
this._transferInProgressSubject.next(false);
this.logService.error("Error transferring personal items to organization", error);
this.toastService.showToast({
variant: "error",