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:
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -708,6 +708,9 @@
|
|||||||
"addAttachment": {
|
"addAttachment": {
|
||||||
"message": "Add attachment"
|
"message": "Add attachment"
|
||||||
},
|
},
|
||||||
|
"itemsTransferred": {
|
||||||
|
"message": "Items transferred"
|
||||||
|
},
|
||||||
"fixEncryption": {
|
"fixEncryption": {
|
||||||
"message": "Fix encryption"
|
"message": "Fix encryption"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function createPolicy(
|
|||||||
data,
|
data,
|
||||||
enabled,
|
enabled,
|
||||||
type,
|
type,
|
||||||
|
revisionDate: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function createPolicy(
|
|||||||
data,
|
data,
|
||||||
enabled,
|
enabled,
|
||||||
type,
|
type,
|
||||||
|
revisionDate: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function createPolicy(
|
|||||||
data,
|
data,
|
||||||
enabled,
|
enabled,
|
||||||
type,
|
type,
|
||||||
|
revisionDate: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
59
libs/vault/src/abstractions/vault-items-transfer.service.ts
Normal file
59
libs/vault/src/abstractions/vault-items-transfer.service.ts
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
231
libs/vault/src/services/default-vault-items-transfer.service.ts
Normal file
231
libs/vault/src/services/default-vault-items-transfer.service.ts
Normal 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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user