1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 09:43:23 +00:00

[PM-27662] Introduce vault item transfer service (#17876)

* [PM-27662] Add revision date to policy response

* [PM-27662] Introduce vault item transfer service

* [PM-27662] Add feature flag check

* [PM-27662] Add tests

* [PM-27662] Add basic implementation to Web vault

* [PM-27662] Remove redundant for loop

* [PM-27662] Remove unnecessary distinctUntilChanged

* [PM-27662] Avoid subscribing to userMigrationInfo$ if feature flag disabled

* [PM-27662] Make UserMigrationInfo type more strict

* [PM-27662] Typo

* [PM-27662] Fix missing i18n

* [PM-27662] Fix tests

* [PM-27662] Fix tests/types related to policy changes

* [PM-27662] Use getById operator
This commit is contained in:
Shane Melton
2025-12-09 15:14:40 -08:00
committed by GitHub
parent 6dba3ac377
commit f161a8c454
19 changed files with 1090 additions and 2 deletions

View File

@@ -1475,6 +1475,9 @@
"selectFile": { "selectFile": {
"message": "Select a file" "message": "Select a file"
}, },
"itemsTransferred": {
"message": "Items transferred"
},
"maxFileSize": { "maxFileSize": {
"message": "Maximum file size is 500 MB." "message": "Maximum file size is 500 MB."
}, },

View File

@@ -708,6 +708,9 @@
"addAttachment": { "addAttachment": {
"message": "Add attachment" "message": "Add attachment"
}, },
"itemsTransferred": {
"message": "Items transferred"
},
"fixEncryption": { "fixEncryption": {
"message": "Fix encryption" "message": "Fix encryption"
}, },

View File

@@ -84,7 +84,7 @@ import {
CipherViewLikeUtils, CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components"; import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal"; import { CipherListView } from "@bitwarden/sdk-internal";
import { import {
AddEditFolderDialogComponent, AddEditFolderDialogComponent,
@@ -97,6 +97,8 @@ import {
DecryptionFailureDialogComponent, DecryptionFailureDialogComponent,
DefaultCipherFormConfigService, DefaultCipherFormConfigService,
PasswordRepromptService, PasswordRepromptService,
VaultItemsTransferService,
DefaultVaultItemsTransferService,
} from "@bitwarden/vault"; } from "@bitwarden/vault";
import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services"; import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services";
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
@@ -177,12 +179,12 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
VaultItemsModule, VaultItemsModule,
SharedModule, SharedModule,
OrganizationWarningsModule, OrganizationWarningsModule,
BannerComponent,
], ],
providers: [ providers: [
RoutedVaultFilterService, RoutedVaultFilterService,
RoutedVaultFilterBridgeService, RoutedVaultFilterBridgeService,
DefaultCipherFormConfigService, DefaultCipherFormConfigService,
{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
], ],
}) })
export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestroy { export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
@@ -349,6 +351,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private premiumUpgradePromptService: PremiumUpgradePromptService, private premiumUpgradePromptService: PremiumUpgradePromptService,
private autoConfirmService: AutomaticUserConfirmationService, private autoConfirmService: AutomaticUserConfirmationService,
private configService: ConfigService, private configService: ConfigService,
private vaultItemTransferService: VaultItemsTransferService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -644,6 +647,8 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
void this.unifiedUpgradePromptService.displayUpgradePromptConditionally(); void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
this.setupAutoConfirm(); this.setupAutoConfirm();
void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId);
} }
ngOnDestroy() { ngOnDestroy() {

View File

@@ -5185,6 +5185,9 @@
"oldAttachmentsNeedFixDesc": { "oldAttachmentsNeedFixDesc": {
"message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key." "message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key."
}, },
"itemsTransferred": {
"message": "Items transferred"
},
"yourAccountsFingerprint": { "yourAccountsFingerprint": {
"message": "Your account's fingerprint phrase", "message": "Your account's fingerprint phrase",
"description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing."

View File

@@ -11,6 +11,7 @@ export class PolicyData {
type: PolicyType; type: PolicyType;
data: Record<string, string | number | boolean>; data: Record<string, string | number | boolean>;
enabled: boolean; enabled: boolean;
revisionDate: string;
constructor(response?: PolicyResponse) { constructor(response?: PolicyResponse) {
if (response == null) { if (response == null) {
@@ -22,6 +23,7 @@ export class PolicyData {
this.type = response.type; this.type = response.type;
this.data = response.data; this.data = response.data;
this.enabled = response.enabled; this.enabled = response.enabled;
this.revisionDate = response.revisionDate;
} }
static fromPolicy(policy: Policy): PolicyData { static fromPolicy(policy: Policy): PolicyData {

View File

@@ -19,6 +19,8 @@ export class Policy extends Domain {
*/ */
enabled: boolean; enabled: boolean;
revisionDate: Date;
constructor(obj?: PolicyData) { constructor(obj?: PolicyData) {
super(); super();
if (obj == null) { if (obj == null) {
@@ -30,6 +32,7 @@ export class Policy extends Domain {
this.type = obj.type; this.type = obj.type;
this.data = obj.data; this.data = obj.data;
this.enabled = obj.enabled; this.enabled = obj.enabled;
this.revisionDate = new Date(obj.revisionDate);
} }
static fromResponse(response: PolicyResponse): Policy { static fromResponse(response: PolicyResponse): Policy {

View File

@@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
data: any; data: any;
enabled: boolean; enabled: boolean;
canToggleState: boolean; canToggleState: boolean;
revisionDate: string;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
this.data = this.getResponseProperty("Data"); this.data = this.getResponseProperty("Data");
this.enabled = this.getResponseProperty("Enabled"); this.enabled = this.getResponseProperty("Enabled");
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true; this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
this.revisionDate = this.getResponseProperty("RevisionDate");
} }
} }

View File

@@ -83,12 +83,15 @@ describe("PolicyService", () => {
type: PolicyType.MaximumVaultTimeout, type: PolicyType.MaximumVaultTimeout,
enabled: true, enabled: true,
data: { minutes: 14 }, data: { minutes: 14 },
revisionDate: expect.any(Date),
}, },
{ {
id: "99", id: "99",
organizationId: "test-organization", organizationId: "test-organization",
type: PolicyType.DisableSend, type: PolicyType.DisableSend,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@@ -113,6 +116,8 @@ describe("PolicyService", () => {
organizationId: "test-organization", organizationId: "test-organization",
type: PolicyType.DisableSend, type: PolicyType.DisableSend,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@@ -242,6 +247,8 @@ describe("PolicyService", () => {
organizationId: "org1", organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}); });
}); });
@@ -331,24 +338,32 @@ describe("PolicyService", () => {
organizationId: "org4", organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy2", id: "policy2",
organizationId: "org1", organizationId: "org1",
type: PolicyType.ActivateAutofill, type: PolicyType.ActivateAutofill,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy3", id: "policy3",
organizationId: "org5", organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy4", id: "policy4",
organizationId: "org1", organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@@ -371,24 +386,32 @@ describe("PolicyService", () => {
organizationId: "org4", organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy2", id: "policy2",
organizationId: "org1", organizationId: "org1",
type: PolicyType.ActivateAutofill, type: PolicyType.ActivateAutofill,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy3", id: "policy3",
organizationId: "org5", organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: false, enabled: false,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy4", id: "policy4",
organizationId: "org1", organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@@ -411,24 +434,32 @@ describe("PolicyService", () => {
organizationId: "org4", organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy2", id: "policy2",
organizationId: "org1", organizationId: "org1",
type: PolicyType.ActivateAutofill, type: PolicyType.ActivateAutofill,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy3", id: "policy3",
organizationId: "org5", organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy4", id: "policy4",
organizationId: "org2", organizationId: "org2",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@@ -451,24 +482,32 @@ describe("PolicyService", () => {
organizationId: "org4", organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy2", id: "policy2",
organizationId: "org1", organizationId: "org1",
type: PolicyType.ActivateAutofill, type: PolicyType.ActivateAutofill,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy3", id: "policy3",
organizationId: "org3", organizationId: "org3",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy4", id: "policy4",
organizationId: "org1", organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@@ -788,6 +827,7 @@ describe("PolicyService", () => {
policyData.type = type; policyData.type = type;
policyData.enabled = enabled; policyData.enabled = enabled;
policyData.data = data; policyData.data = data;
policyData.revisionDate = new Date().toISOString();
return policyData; return policyData;
} }

View File

@@ -64,6 +64,7 @@ export enum FeatureFlag {
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
/* Platform */ /* Platform */
IpcChannelFramework = "ipc-channel-framework", IpcChannelFramework = "ipc-channel-framework",
@@ -123,6 +124,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.RiskInsightsForPremium]: FALSE, [FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
/* Auth */ /* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,

View File

@@ -24,6 +24,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: override, overridePasswordType: override,
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy]); const result = availableAlgorithms([policy]);
@@ -44,6 +45,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: override, overridePasswordType: override,
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy, policy]); const result = availableAlgorithms([policy, policy]);
@@ -64,6 +66,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: "password", overridePasswordType: "password",
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const passphrase = new Policy({ const passphrase = new Policy({
id: "" as PolicyId, id: "" as PolicyId,
@@ -73,6 +76,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: "passphrase", overridePasswordType: "passphrase",
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([password, passphrase]); const result = availableAlgorithms([password, passphrase]);
@@ -93,6 +97,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy", some: "policy",
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy]); const result = availableAlgorithms([policy]);
@@ -111,6 +116,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy", some: "policy",
}, },
enabled: false, enabled: false,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy]); const result = availableAlgorithms([policy]);
@@ -129,6 +135,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy", some: "policy",
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy]); const result = availableAlgorithms([policy]);

View File

@@ -17,6 +17,7 @@ function createPolicy(
data, data,
enabled, enabled,
type, type,
revisionDate: new Date().toISOString(),
}); });
} }

View File

@@ -17,6 +17,7 @@ function createPolicy(
data, data,
enabled, enabled,
type, type,
revisionDate: new Date().toISOString(),
}); });
} }

View File

@@ -57,6 +57,7 @@ const somePolicy = new Policy({
id: "" as PolicyId, id: "" as PolicyId,
organizationId: "" as OrganizationId, organizationId: "" as OrganizationId,
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const stateProvider = new FakeStateProvider(accountService); const stateProvider = new FakeStateProvider(accountService);

View File

@@ -70,6 +70,7 @@ describe("DefaultGeneratorNavigationService", () => {
enabled: true, enabled: true,
type: PolicyType.PasswordGenerator, type: PolicyType.PasswordGenerator,
data: { overridePasswordType: "password" }, data: { overridePasswordType: "password" },
revisionDate: new Date().toISOString(),
}), }),
]); ]);
}, },

View File

@@ -17,6 +17,7 @@ function createPolicy(
data, data,
enabled, enabled,
type, type,
revisionDate: new Date().toISOString(),
}); });
} }

View File

@@ -0,0 +1,59 @@
import { Observable } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
import { UserId } from "@bitwarden/user-core";
export type UserMigrationInfo =
| {
/**
* Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
* organizational policy change. (Enforce organization data ownership policy enabled)
*/
requiresMigration: false;
}
| {
/**
* Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
* organizational policy change. (Enforce organization data ownership policy enabled)
*/
requiresMigration: true;
/**
* The organization that is enforcing data ownership policies for the given user.
*/
enforcingOrganization: Organization;
/**
* The default collection ID for the user in the enforcing organization, if available.
*/
defaultCollectionId?: CollectionId;
};
export abstract class VaultItemsTransferService {
/**
* 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.
* @param userId
*/
abstract userMigrationInfo$(userId: UserId): Observable<UserMigrationInfo>;
/**
* Enforces organization data ownership for the given user by transferring vault items.
* Checks if any organization policies require the transfer, and if so, prompts the user to confirm before proceeding.
*
* Rejecting the transfer will result in the user being revoked from the organization.
*
* @param userId
*/
abstract enforceOrganizationDataOwnership(userId: UserId): Promise<void>;
/**
* Begins transfer of vault items from My Vault to the specified default collection for the given user.
*/
abstract transferPersonalItems(
userId: UserId,
organizationId: OrganizationId,
defaultCollectionId: CollectionId,
): Promise<void>;
}

View File

@@ -35,5 +35,7 @@ export { DefaultSshImportPromptService } from "./services/default-ssh-import-pro
export { SshImportPromptService } from "./services/ssh-import-prompt.service"; export { SshImportPromptService } from "./services/ssh-import-prompt.service";
export * from "./abstractions/change-login-password.service"; export * from "./abstractions/change-login-password.service";
export * from "./abstractions/vault-items-transfer.service";
export * from "./services/default-vault-items-transfer.service";
export * from "./services/default-change-login-password.service"; export * from "./services/default-change-login-password.service";
export * from "./services/archive-cipher-utilities.service"; export * from "./services/archive-cipher-utilities.service";

View File

@@ -0,0 +1,721 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { CollectionService, CollectionView } 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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
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 { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import { DefaultVaultItemsTransferService } from "./default-vault-items-transfer.service";
describe("DefaultVaultItemsTransferService", () => {
let service: DefaultVaultItemsTransferService;
let mockCipherService: MockProxy<CipherService>;
let mockPolicyService: MockProxy<PolicyService>;
let mockOrganizationService: MockProxy<OrganizationService>;
let mockCollectionService: MockProxy<CollectionService>;
let mockLogService: MockProxy<LogService>;
let mockI18nService: MockProxy<I18nService>;
let mockDialogService: MockProxy<DialogService>;
let mockToastService: MockProxy<ToastService>;
let mockConfigService: MockProxy<ConfigService>;
const userId = "user-id" as UserId;
const organizationId = "org-id" as OrganizationId;
const collectionId = "collection-id" as CollectionId;
beforeEach(() => {
mockCipherService = mock<CipherService>();
mockPolicyService = mock<PolicyService>();
mockOrganizationService = mock<OrganizationService>();
mockCollectionService = mock<CollectionService>();
mockLogService = mock<LogService>();
mockI18nService = mock<I18nService>();
mockDialogService = mock<DialogService>();
mockToastService = mock<ToastService>();
mockConfigService = mock<ConfigService>();
mockI18nService.t.mockImplementation((key) => key);
service = new DefaultVaultItemsTransferService(
mockCipherService,
mockPolicyService,
mockOrganizationService,
mockCollectionService,
mockLogService,
mockI18nService,
mockDialogService,
mockToastService,
mockConfigService,
);
});
describe("userMigrationInfo$", () => {
// Helper to setup common mock scenario
function setupMocksForMigrationScenario(options: {
policies?: Policy[];
organizations?: Organization[];
ciphers?: CipherView[];
collections?: 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 ?? []));
}
it("calls policiesByType$ with correct PolicyType", async () => {
setupMocksForMigrationScenario({ policies: [] });
await firstValueFrom(service.userMigrationInfo$(userId));
expect(mockPolicyService.policiesByType$).toHaveBeenCalledWith(
PolicyType.OrganizationDataOwnership,
userId,
);
});
describe("when no policy exists", () => {
beforeEach(() => {
setupMocksForMigrationScenario({ policies: [] });
});
it("returns requiresMigration: false", async () => {
const result = await firstValueFrom(service.userMigrationInfo$(userId));
expect(result).toEqual({
requiresMigration: false,
});
});
});
describe("when policy exists", () => {
const policy = {
organizationId: organizationId,
revisionDate: new Date("2024-01-01"),
} as Policy;
const organization = {
id: organizationId,
name: "Test Org",
} as Organization;
beforeEach(() => {
setupMocksForMigrationScenario({
policies: [policy],
organizations: [organization],
});
});
describe("and user has no personal ciphers", () => {
beforeEach(() => {
mockCipherService.cipherViews$.mockReturnValue(of([]));
});
it("returns requiresMigration: false", async () => {
const result = await firstValueFrom(service.userMigrationInfo$(userId));
expect(result).toEqual({
requiresMigration: false,
enforcingOrganization: organization,
defaultCollectionId: undefined,
});
});
});
describe("and user has personal ciphers", () => {
beforeEach(() => {
mockCipherService.cipherViews$.mockReturnValue(of([{ id: "cipher-1" } as CipherView]));
});
it("returns requiresMigration: true", async () => {
const result = await firstValueFrom(service.userMigrationInfo$(userId));
expect(result).toEqual({
requiresMigration: true,
enforcingOrganization: organization,
defaultCollectionId: undefined,
});
});
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,
]),
);
const result = await firstValueFrom(service.userMigrationInfo$(userId));
expect(result).toEqual({
requiresMigration: true,
enforcingOrganization: organization,
defaultCollectionId: collectionId,
});
});
});
it("filters out organization ciphers when checking for personal ciphers", async () => {
mockCipherService.cipherViews$.mockReturnValue(
of([
{
id: "cipher-1",
organizationId: organizationId as string,
} as CipherView,
]),
);
const result = await firstValueFrom(service.userMigrationInfo$(userId));
expect(result).toEqual({
requiresMigration: false,
enforcingOrganization: organization,
defaultCollectionId: undefined,
});
});
});
describe("when multiple policies exist", () => {
const olderPolicy = {
organizationId: "older-org-id" as OrganizationId,
revisionDate: new Date("2024-01-01"),
} as Policy;
const newerPolicy = {
organizationId: organizationId,
revisionDate: new Date("2024-06-01"),
} as Policy;
const olderOrganization = {
id: "older-org-id" as OrganizationId,
name: "Older Org",
} as Organization;
const newerOrganization = {
id: organizationId,
name: "Newer Org",
} as Organization;
beforeEach(() => {
setupMocksForMigrationScenario({
policies: [newerPolicy, olderPolicy],
organizations: [olderOrganization, newerOrganization],
ciphers: [{ id: "cipher-1" } as CipherView],
});
});
it("uses the oldest policy when selecting enforcing organization", async () => {
const result = await firstValueFrom(service.userMigrationInfo$(userId));
expect(result).toEqual({
requiresMigration: true,
enforcingOrganization: olderOrganization,
defaultCollectionId: undefined,
});
});
});
});
describe("transferPersonalItems", () => {
it("does nothing when user has no personal ciphers", async () => {
mockCipherService.cipherViews$.mockReturnValue(of([]));
await service.transferPersonalItems(userId, organizationId, collectionId);
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
expect(mockLogService.info).not.toHaveBeenCalled();
});
it("calls shareManyWithServer with correct parameters", async () => {
const personalCiphers = [{ id: "cipher-1" }, { id: "cipher-2" }] as CipherView[];
mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers));
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.transferPersonalItems(userId, organizationId, collectionId);
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
personalCiphers,
organizationId,
[collectionId],
userId,
);
});
it("transfers only personal ciphers, not organization ciphers", async () => {
const allCiphers = [
{ id: "cipher-1" },
{ id: "cipher-2", organizationId: "other-org-id" },
{ id: "cipher-3" },
] as CipherView[];
const expectedPersonalCiphers = [allCiphers[0], allCiphers[2]];
mockCipherService.cipherViews$.mockReturnValue(of(allCiphers));
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.transferPersonalItems(userId, organizationId, collectionId);
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
expectedPersonalCiphers,
organizationId,
[collectionId],
userId,
);
});
it("propagates errors from shareManyWithServer", async () => {
const personalCiphers = [{ id: "cipher-1" }] as CipherView[];
const error = new Error("Transfer failed");
mockCipherService.cipherViews$.mockReturnValue(of(personalCiphers));
mockCipherService.shareManyWithServer.mockRejectedValue(error);
await expect(
service.transferPersonalItems(userId, organizationId, collectionId),
).rejects.toThrow("Transfer failed");
});
});
describe("upgradeOldAttachments", () => {
it("upgrades old attachments before transferring", async () => {
const cipherWithOldAttachment = {
id: "cipher-1",
name: "Cipher 1",
hasOldAttachments: true,
attachments: [{ key: null }],
} as unknown as CipherView;
const upgradedCipher = {
id: "cipher-1",
name: "Cipher 1",
hasOldAttachments: false,
attachments: [{ key: "new-key" }],
} as unknown as CipherView;
mockCipherService.cipherViews$
.mockReturnValueOnce(of([cipherWithOldAttachment]))
.mockReturnValueOnce(of([upgradedCipher]));
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.transferPersonalItems(userId, organizationId, collectionId);
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(
cipherWithOldAttachment,
userId,
);
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
[upgradedCipher],
organizationId,
[collectionId],
userId,
);
});
it("upgrades multiple ciphers with old attachments", async () => {
const cipher1 = {
id: "cipher-1",
name: "Cipher 1",
hasOldAttachments: true,
attachments: [{ key: null }],
} as unknown as CipherView;
const cipher2 = {
id: "cipher-2",
name: "Cipher 2",
hasOldAttachments: true,
attachments: [{ key: null }],
} as unknown as CipherView;
const upgradedCipher1 = { ...cipher1, hasOldAttachments: false } as CipherView;
const upgradedCipher2 = { ...cipher2, hasOldAttachments: false } as CipherView;
mockCipherService.cipherViews$
.mockReturnValueOnce(of([cipher1, cipher2]))
.mockReturnValueOnce(of([upgradedCipher1, upgradedCipher2]));
mockCipherService.upgradeOldCipherAttachments
.mockResolvedValueOnce(upgradedCipher1)
.mockResolvedValueOnce(upgradedCipher2);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.transferPersonalItems(userId, organizationId, collectionId);
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(2);
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher1, userId);
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledWith(cipher2, userId);
});
it("skips attachments that already have keys", async () => {
const cipherWithMixedAttachments = {
id: "cipher-1",
name: "Cipher 1",
hasOldAttachments: true,
attachments: [{ key: "existing-key" }, { key: null }],
} as unknown as CipherView;
const upgradedCipher = {
...cipherWithMixedAttachments,
hasOldAttachments: false,
} as unknown as CipherView;
mockCipherService.cipherViews$
.mockReturnValueOnce(of([cipherWithMixedAttachments]))
.mockReturnValueOnce(of([upgradedCipher]));
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.transferPersonalItems(userId, organizationId, collectionId);
// Should only be called once for the attachment without a key
expect(mockCipherService.upgradeOldCipherAttachments).toHaveBeenCalledTimes(1);
});
it("throws error when upgradeOldCipherAttachments fails", async () => {
const cipherWithOldAttachment = {
id: "cipher-1",
name: "Cipher 1",
hasOldAttachments: true,
attachments: [{ key: null }],
} as unknown as CipherView;
mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment]));
mockCipherService.upgradeOldCipherAttachments.mockRejectedValue(new Error("Upgrade failed"));
await expect(
service.transferPersonalItems(userId, organizationId, collectionId),
).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1");
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("throws error when upgrade returns cipher still having old attachments", async () => {
const cipherWithOldAttachment = {
id: "cipher-1",
name: "Cipher 1",
hasOldAttachments: true,
attachments: [{ key: null }],
} as unknown as CipherView;
// Upgrade returns but cipher still has old attachments
const stillOldCipher = {
...cipherWithOldAttachment,
hasOldAttachments: true,
} as unknown as CipherView;
mockCipherService.cipherViews$.mockReturnValue(of([cipherWithOldAttachment]));
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(stillOldCipher);
await expect(
service.transferPersonalItems(userId, organizationId, collectionId),
).rejects.toThrow("Failed to upgrade old attachments for cipher cipher-1");
expect(mockLogService.error).toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("throws error when sanity check finds remaining old attachments after upgrade", async () => {
const cipherWithOldAttachment = {
id: "cipher-1",
name: "Cipher 1",
hasOldAttachments: true,
attachments: [{ key: null }],
} as unknown as CipherView;
const upgradedCipher = {
...cipherWithOldAttachment,
hasOldAttachments: false,
} as unknown as CipherView;
// First call returns cipher with old attachment, second call (after upgrade) still returns old attachment
mockCipherService.cipherViews$
.mockReturnValueOnce(of([cipherWithOldAttachment]))
.mockReturnValueOnce(of([cipherWithOldAttachment])); // Still has old attachments after re-fetch
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
await expect(
service.transferPersonalItems(userId, organizationId, collectionId),
).rejects.toThrow(
"Failed to upgrade all old attachments. 1 ciphers still have old attachments.",
);
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("logs info when upgrading old attachments", async () => {
const cipherWithOldAttachment = {
id: "cipher-1",
name: "Cipher 1",
hasOldAttachments: true,
attachments: [{ key: null }],
} as unknown as CipherView;
const upgradedCipher = {
...cipherWithOldAttachment,
hasOldAttachments: false,
} as unknown as CipherView;
mockCipherService.cipherViews$
.mockReturnValueOnce(of([cipherWithOldAttachment]))
.mockReturnValueOnce(of([upgradedCipher]));
mockCipherService.upgradeOldCipherAttachments.mockResolvedValue(upgradedCipher);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.transferPersonalItems(userId, organizationId, collectionId);
expect(mockLogService.info).toHaveBeenCalledWith(
expect.stringContaining("Found 1 ciphers with old attachments needing upgrade"),
);
expect(mockLogService.info).toHaveBeenCalledWith(
expect.stringContaining("Successfully upgraded 1 ciphers with old attachments"),
);
});
it("does not upgrade when ciphers have no old attachments", async () => {
const cipherWithoutOldAttachment = {
id: "cipher-1",
name: "Cipher 1",
hasOldAttachments: false,
} as unknown as CipherView;
mockCipherService.cipherViews$.mockReturnValue(of([cipherWithoutOldAttachment]));
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.transferPersonalItems(userId, organizationId, collectionId);
expect(mockCipherService.upgradeOldCipherAttachments).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
});
});
describe("enforceOrganizationDataOwnership", () => {
const policy = {
organizationId: organizationId,
revisionDate: new Date("2024-01-01"),
} as Policy;
const organization = {
id: organizationId,
name: "Test Org",
} as Organization;
function setupMocksForEnforcementScenario(options: {
featureEnabled?: boolean;
policies?: Policy[];
organizations?: Organization[];
ciphers?: CipherView[];
collections?: 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 ?? []));
}
it("does nothing when feature flag is disabled", async () => {
setupMocksForEnforcementScenario({
featureEnabled: false,
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
});
await service.enforceOrganizationDataOwnership(userId);
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.MigrateMyVaultToMyItems,
);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("does nothing when no migration is required", async () => {
setupMocksForEnforcementScenario({ policies: [] });
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("does nothing when user has no personal ciphers", async () => {
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: [],
});
await service.enforceOrganizationDataOwnership(userId);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("logs warning and returns when default collection is missing", async () => {
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: [{ id: "cipher-1" } as CipherView],
collections: [],
});
await service.enforceOrganizationDataOwnership(userId);
expect(mockLogService.warning).toHaveBeenCalledWith(
"Default collection is missing for user during organization data ownership enforcement",
);
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("shows confirmation dialog when migration is required", 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);
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);
await service.enforceOrganizationDataOwnership(userId);
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
});
it("transfers items and shows success toast when user confirms", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
});
mockDialogService.openSimpleDialog.mockResolvedValue(true);
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
await service.enforceOrganizationDataOwnership(userId);
expect(mockCipherService.shareManyWithServer).toHaveBeenCalledWith(
personalCiphers,
organizationId,
[collectionId],
userId,
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "success",
message: "itemsTransferred",
});
});
it("shows error toast when transfer fails", async () => {
const personalCiphers = [{ id: "cipher-1" } as CipherView];
setupMocksForEnforcementScenario({
policies: [policy],
organizations: [organization],
ciphers: personalCiphers,
collections: [
{
id: collectionId,
organizationId: organizationId,
isDefaultCollection: true,
} as CollectionView,
],
});
mockDialogService.openSimpleDialog.mockResolvedValue(true);
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
await service.enforceOrganizationDataOwnership(userId);
expect(mockLogService.error).toHaveBeenCalledWith(
"Error transferring personal items to organization",
expect.any(Error),
);
expect(mockToastService.showToast).toHaveBeenCalledWith({
variant: "error",
message: "errorOccurred",
});
});
});
});

View File

@@ -0,0 +1,231 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, switchMap, map, of, Observable, combineLatest } from "rxjs";
// eslint-disable-next-line no-restricted-imports
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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { getById } from "@bitwarden/common/platform/misc";
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 { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogService, ToastService } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import {
VaultItemsTransferService,
UserMigrationInfo,
} from "../abstractions/vault-items-transfer.service";
@Injectable()
export class DefaultVaultItemsTransferService implements VaultItemsTransferService {
constructor(
private cipherService: CipherService,
private policyService: PolicyService,
private organizationService: OrganizationService,
private collectionService: CollectionService,
private logService: LogService,
private i18nService: I18nService,
private dialogService: DialogService,
private toastService: ToastService,
private configService: ConfigService,
) {}
private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> {
return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe(
map(
(policies) =>
policies.sort((a, b) => a.revisionDate.getTime() - b.revisionDate.getTime())?.[0],
),
switchMap((policy) => {
if (policy == null) {
return of(undefined);
}
return this.organizationService.organizations$(userId).pipe(getById(policy.organizationId));
}),
);
}
private personalCiphers$(userId: UserId): Observable<CipherView[]> {
return this.cipherService.cipherViews$(userId).pipe(
filterOutNullish(),
map((ciphers) => ciphers.filter((c) => c.organizationId == null)),
);
}
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) => {
if (enforcingOrganization == null) {
return of<UserMigrationInfo>({
requiresMigration: false,
});
}
return combineLatest([
this.personalCiphers$(userId),
this.defaultUserCollection$(userId, enforcingOrganization.id),
]).pipe(
map(([personalCiphers, defaultCollectionId]): UserMigrationInfo => {
return {
requiresMigration: personalCiphers.length > 0,
enforcingOrganization,
defaultCollectionId,
};
}),
);
}),
);
}
async enforceOrganizationDataOwnership(userId: UserId): Promise<void> {
const featureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.MigrateMyVaultToMyItems,
);
if (!featureEnabled) {
return;
}
const migrationInfo = await firstValueFrom(this.userMigrationInfo$(userId));
if (!migrationInfo.requiresMigration) {
return;
}
if (migrationInfo.defaultCollectionId == null) {
// TODO: Handle creating the default collection if missing (to be handled by AC in future work)
this.logService.warning(
"Default collection is missing for user during organization data ownership enforcement",
);
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",
});
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
return;
}
try {
await this.transferPersonalItems(
userId,
migrationInfo.enforcingOrganization.id,
migrationInfo.defaultCollectionId,
);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemsTransferred"),
});
} catch (error) {
this.logService.error("Error transferring personal items to organization", error);
this.toastService.showToast({
variant: "error",
message: this.i18nService.t("errorOccurred"),
});
}
}
async transferPersonalItems(
userId: UserId,
organizationId: OrganizationId,
defaultCollectionId: CollectionId,
): Promise<void> {
let personalCiphers = await firstValueFrom(this.personalCiphers$(userId));
if (personalCiphers.length === 0) {
return;
}
const oldAttachmentCiphers = personalCiphers.filter((c) => c.hasOldAttachments);
if (oldAttachmentCiphers.length > 0) {
await this.upgradeOldAttachments(oldAttachmentCiphers, userId, organizationId);
personalCiphers = await firstValueFrom(this.personalCiphers$(userId));
// Sanity check to ensure all old attachments were upgraded, though upgradeOldAttachments should throw if any fail
const remainingOldAttachments = personalCiphers.filter((c) => c.hasOldAttachments);
if (remainingOldAttachments.length > 0) {
throw new Error(
`Failed to upgrade all old attachments. ${remainingOldAttachments.length} ciphers still have old attachments.`,
);
}
}
this.logService.info(
`Starting transfer of ${personalCiphers.length} personal ciphers to organization ${organizationId} for user ${userId}`,
);
await this.cipherService.shareManyWithServer(
personalCiphers,
organizationId,
[defaultCollectionId],
userId,
);
}
/**
* Upgrades old attachments that don't have attachment keys.
* Throws an error if any attachment fails to upgrade as it is not possible to share with an organization without a key.
*/
private async upgradeOldAttachments(
ciphers: CipherView[],
userId: UserId,
organizationId: OrganizationId,
): Promise<void> {
this.logService.info(
`Found ${ciphers.length} ciphers with old attachments needing upgrade during transfer to organization ${organizationId} for user ${userId}`,
);
for (const cipher of ciphers) {
try {
if (!cipher.hasOldAttachments) {
continue;
}
const upgraded = await this.cipherService.upgradeOldCipherAttachments(cipher, userId);
if (upgraded.hasOldAttachments) {
this.logService.error(
`Attachment upgrade did not complete successfully for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}`,
);
throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`);
}
} catch (e) {
this.logService.error(
`Failed to upgrade old attachments for cipher ${cipher.id} during transfer to organization ${organizationId} for user ${userId}: ${e}`,
);
throw new Error(`Failed to upgrade old attachments for cipher ${cipher.id}`);
}
}
this.logService.info(
`Successfully upgraded ${ciphers.length} ciphers with old attachments during transfer to organization ${organizationId} for user ${userId}`,
);
}
}