mirror of
https://github.com/bitwarden/browser
synced 2026-02-17 18:09:17 +00:00
Merge branch 'main' into uif/CL-854/page-header-web-usage
This commit is contained in:
277
.github/renovate.json5
vendored
277
.github/renovate.json5
vendored
@@ -3,6 +3,7 @@
|
||||
extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies
|
||||
enabledManagers: ["cargo", "github-actions", "npm"],
|
||||
packageRules: [
|
||||
// ==================== Repo-Wide Update Behavior Rules ====================
|
||||
{
|
||||
// Group all Github Action minor updates together to reduce PR noise.
|
||||
groupName: "Minor github-actions updates",
|
||||
@@ -16,13 +17,6 @@
|
||||
matchDepNames: ["rust"],
|
||||
commitMessageTopic: "Rust",
|
||||
},
|
||||
{
|
||||
// By default, we send patch updates to the Dependency Dashboard and do not generate a PR.
|
||||
// We want to generate PRs for a select number of dependencies to ensure we stay up to date on these.
|
||||
matchPackageNames: ["browserslist", "electron", "rxjs", "typescript", "webpack", "zone.js"],
|
||||
matchUpdateTypes: ["patch"],
|
||||
dependencyDashboardApproval: false,
|
||||
},
|
||||
{
|
||||
// Disable major and minor updates for TypeScript and Zone.js because they are managed by Angular.
|
||||
matchPackageNames: ["typescript", "zone.js"],
|
||||
@@ -44,6 +38,8 @@
|
||||
description: "Manually updated using ng update",
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
// ==================== Team Ownership Rules ====================
|
||||
{
|
||||
matchPackageNames: ["buffer", "bufferutil", "core-js", "process", "url", "util"],
|
||||
description: "Admin Console owned dependencies",
|
||||
@@ -79,28 +75,6 @@
|
||||
commitMessagePrefix: "[deps] Architecture:",
|
||||
reviewers: ["team:dept-architecture"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"@angular-eslint/schematics",
|
||||
"@eslint/compat",
|
||||
"@typescript-eslint/rule-tester",
|
||||
"@typescript-eslint/utils",
|
||||
"angular-eslint",
|
||||
"eslint-config-prettier",
|
||||
"eslint-import-resolver-typescript",
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-rxjs-angular",
|
||||
"eslint-plugin-rxjs",
|
||||
"eslint-plugin-storybook",
|
||||
"eslint-plugin-tailwindcss",
|
||||
"eslint",
|
||||
"husky",
|
||||
"lint-staged",
|
||||
"typescript-eslint",
|
||||
],
|
||||
groupName: "Minor and patch linting updates",
|
||||
matchUpdateTypes: ["minor", "patch"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"@emotion/css",
|
||||
@@ -241,60 +215,10 @@
|
||||
reviewers: ["team:team-platform-dev"],
|
||||
},
|
||||
{
|
||||
// We need to group all napi-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "napi",
|
||||
matchPackageNames: ["napi", "napi-build", "napi-derive"],
|
||||
},
|
||||
{
|
||||
// We need to group all macOS/iOS binding-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "macOS/iOS bindings",
|
||||
matchPackageNames: ["core-foundation", "security-framework", "security-framework-sys"],
|
||||
},
|
||||
{
|
||||
// We need to group all zbus-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "zbus",
|
||||
matchPackageNames: ["zbus", "zbus_polkit"],
|
||||
},
|
||||
{
|
||||
// We need to group all windows-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "windows",
|
||||
matchPackageNames: ["windows", "windows-core", "windows-future", "windows-registry"],
|
||||
},
|
||||
{
|
||||
// We need to group all tokio-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "tokio",
|
||||
matchPackageNames: ["bytes", "tokio", "tokio-util"],
|
||||
},
|
||||
{
|
||||
// We group all webpack build-related minor and patch updates together to reduce PR noise.
|
||||
// We include patch updates here because we want PRs for webpack patch updates and it's in this group.
|
||||
matchPackageNames: [
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"babel-loader",
|
||||
"base64-loader",
|
||||
"browserslist",
|
||||
"copy-webpack-plugin",
|
||||
"css-loader",
|
||||
"html-loader",
|
||||
"html-webpack-injector",
|
||||
"html-webpack-plugin",
|
||||
"mini-css-extract-plugin",
|
||||
"postcss-loader",
|
||||
"postcss",
|
||||
"sass-loader",
|
||||
"sass",
|
||||
"style-loader",
|
||||
"ts-loader",
|
||||
"tsconfig-paths-webpack-plugin",
|
||||
"webpack-cli",
|
||||
"webpack-dev-server",
|
||||
"webpack-node-externals",
|
||||
"webpack",
|
||||
],
|
||||
description: "webpack-related build dependencies",
|
||||
groupName: "Minor and patch webpack updates",
|
||||
matchUpdateTypes: ["minor", "patch"],
|
||||
matchUpdateTypes: ["lockFileMaintenance"],
|
||||
description: "Platform owns lock file maintenance",
|
||||
commitMessagePrefix: "[deps] Platform:",
|
||||
reviewers: ["team:team-platform-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
@@ -353,11 +277,6 @@
|
||||
commitMessagePrefix: "[deps] SM:",
|
||||
reviewers: ["team:team-secrets-manager-dev"],
|
||||
},
|
||||
{
|
||||
// We need to update several Jest-related packages together, for version compatibility.
|
||||
groupName: "jest",
|
||||
matchPackageNames: ["@types/jest", "jest", "ts-jest", "jest-preset-angular"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"@microsoft/signalr-protocol-msgpack",
|
||||
@@ -428,6 +347,188 @@
|
||||
commitMessagePrefix: "[deps] KM:",
|
||||
reviewers: ["team:team-key-management-dev"],
|
||||
},
|
||||
|
||||
// ==================== Grouping Rules ====================
|
||||
// These come after any specific team assignment rules to ensure
|
||||
// that grouping is not overridden by subsequent rule definitions.
|
||||
{
|
||||
matchPackageNames: [
|
||||
"@angular-eslint/schematics",
|
||||
"@eslint/compat",
|
||||
"@typescript-eslint/rule-tester",
|
||||
"@typescript-eslint/utils",
|
||||
"angular-eslint",
|
||||
"eslint-config-prettier",
|
||||
"eslint-import-resolver-typescript",
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-rxjs-angular",
|
||||
"eslint-plugin-rxjs",
|
||||
"eslint-plugin-storybook",
|
||||
"eslint-plugin-tailwindcss",
|
||||
"eslint",
|
||||
"husky",
|
||||
"lint-staged",
|
||||
"typescript-eslint",
|
||||
],
|
||||
groupName: "Minor and patch linting updates",
|
||||
matchUpdateTypes: ["minor", "patch"],
|
||||
},
|
||||
{
|
||||
// We need to group all napi-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "napi",
|
||||
matchPackageNames: ["napi", "napi-build", "napi-derive"],
|
||||
},
|
||||
{
|
||||
// We need to group all macOS/iOS binding-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "macOS/iOS bindings",
|
||||
matchPackageNames: ["core-foundation", "security-framework", "security-framework-sys"],
|
||||
},
|
||||
{
|
||||
// We need to group all zbus-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "zbus",
|
||||
matchPackageNames: ["zbus", "zbus_polkit"],
|
||||
},
|
||||
{
|
||||
// We need to group all windows-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "windows",
|
||||
matchPackageNames: ["windows", "windows-core", "windows-future", "windows-registry"],
|
||||
},
|
||||
{
|
||||
// We need to group all tokio-related packages together to avoid build errors caused by version incompatibilities.
|
||||
groupName: "tokio",
|
||||
matchPackageNames: ["bytes", "tokio", "tokio-util"],
|
||||
},
|
||||
{
|
||||
// We group all webpack build-related minor and patch updates together to reduce PR noise.
|
||||
// We include patch updates here because we want PRs for webpack patch updates and it's in this group.
|
||||
matchPackageNames: [
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"babel-loader",
|
||||
"base64-loader",
|
||||
"browserslist",
|
||||
"copy-webpack-plugin",
|
||||
"css-loader",
|
||||
"html-loader",
|
||||
"html-webpack-injector",
|
||||
"html-webpack-plugin",
|
||||
"mini-css-extract-plugin",
|
||||
"postcss-loader",
|
||||
"postcss",
|
||||
"sass-loader",
|
||||
"sass",
|
||||
"style-loader",
|
||||
"ts-loader",
|
||||
"tsconfig-paths-webpack-plugin",
|
||||
"webpack-cli",
|
||||
"webpack-dev-server",
|
||||
"webpack-node-externals",
|
||||
"webpack",
|
||||
],
|
||||
description: "webpack-related build dependencies",
|
||||
groupName: "Minor and patch webpack updates",
|
||||
matchUpdateTypes: ["minor", "patch"],
|
||||
},
|
||||
{
|
||||
// We need to update several Jest-related packages together, for version compatibility.
|
||||
groupName: "jest",
|
||||
matchPackageNames: ["@types/jest", "jest", "ts-jest", "jest-preset-angular"],
|
||||
},
|
||||
|
||||
// ==================== Dashboard Rules ====================
|
||||
{
|
||||
// For the packages below, we have decided we will only be creating PRs
|
||||
// for major updates, and sending minor (as well as patch) to the dashboard.
|
||||
// This rule comes AFTER grouping rules so that groups are respected while still
|
||||
// sending minor/patch updates to the dependency dashboard for approval.
|
||||
matchPackageNames: [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"babel-loader",
|
||||
"base64-loader",
|
||||
"base64",
|
||||
"bindgen",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"core-foundation",
|
||||
"copy-webpack-plugin",
|
||||
"css-loader",
|
||||
"dirs",
|
||||
"electron-builder",
|
||||
"electron-log",
|
||||
"electron-reload",
|
||||
"electron-store",
|
||||
"electron-updater",
|
||||
"embed_plist",
|
||||
"futures",
|
||||
"hex",
|
||||
"homedir",
|
||||
"html-loader",
|
||||
"html-webpack-injector",
|
||||
"html-webpack-plugin",
|
||||
"interprocess",
|
||||
"json5",
|
||||
"keytar",
|
||||
"libc",
|
||||
"lowdb",
|
||||
"mini-css-extract-plugin",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"node-ipc",
|
||||
"nx",
|
||||
"oo7",
|
||||
"oslog",
|
||||
"pin-project",
|
||||
"pkg",
|
||||
"postcss",
|
||||
"postcss-loader",
|
||||
"rand",
|
||||
"sass",
|
||||
"sass-loader",
|
||||
"scopeguard",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simplelog",
|
||||
"style-loader",
|
||||
"sysinfo",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ts-node",
|
||||
"ts-loader",
|
||||
"tsconfig-paths-webpack-plugin",
|
||||
"type-fest",
|
||||
"typenum",
|
||||
"typescript-strict-plugin",
|
||||
"uniffi",
|
||||
"webpack-cli",
|
||||
"webpack-dev-server",
|
||||
"webpack-node-externals",
|
||||
"widestring",
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-registry",
|
||||
"zbus",
|
||||
"zbus_polkit",
|
||||
],
|
||||
matchUpdateTypes: ["minor", "patch"],
|
||||
dependencyDashboardApproval: true,
|
||||
},
|
||||
{
|
||||
// By default, we send patch updates to the Dependency Dashboard and do not generate a PR.
|
||||
// We want to generate PRs for a select number of dependencies to ensure we stay up to date on these.
|
||||
matchPackageNames: ["browserslist", "electron", "rxjs", "typescript", "webpack", "zone.js"],
|
||||
matchUpdateTypes: ["patch"],
|
||||
dependencyDashboardApproval: false,
|
||||
},
|
||||
|
||||
// ==================== Special Version Constraints ====================
|
||||
{
|
||||
// Any versions of lowdb above 1.0.0 are not compatible with CommonJS.
|
||||
matchPackageNames: ["lowdb"],
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
|
||||
<ng-template #vaultContentTemplate>
|
||||
<ng-container *ngIf="vaultState === null">
|
||||
<ng-container *ngIf="vaultState === null && !(loading$ | async)">
|
||||
<app-autofill-vault-list-items></app-autofill-vault-list-items>
|
||||
<app-vault-list-items-container
|
||||
[title]="'favorites' | i18n"
|
||||
|
||||
@@ -28,7 +28,11 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res
|
||||
import { TaskService } from "@bitwarden/common/vault/tasks";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
|
||||
import {
|
||||
DecryptionFailureDialogComponent,
|
||||
VaultItemsTransferService,
|
||||
DefaultVaultItemsTransferService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils";
|
||||
@@ -193,6 +197,11 @@ describe("VaultV2Component", () => {
|
||||
stop: jest.fn(),
|
||||
} as Partial<VaultPopupScrollPositionService>;
|
||||
|
||||
const vaultItemsTransferSvc = {
|
||||
transferInProgress$: new BehaviorSubject<boolean>(false),
|
||||
enforceOrganizationDataOwnership: jest.fn().mockResolvedValue(undefined),
|
||||
} as Partial<VaultItemsTransferService>;
|
||||
|
||||
function getObs<T = unknown>(cmp: any, key: string): Observable<T> {
|
||||
return cmp[key] as Observable<T>;
|
||||
}
|
||||
@@ -283,6 +292,9 @@ describe("VaultV2Component", () => {
|
||||
AutofillVaultListItemsComponent,
|
||||
VaultListItemsContainerComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: VaultItemsTransferService, useValue: DefaultVaultItemsTransferService },
|
||||
],
|
||||
},
|
||||
add: {
|
||||
imports: [
|
||||
@@ -296,6 +308,7 @@ describe("VaultV2Component", () => {
|
||||
AutofillVaultListItemsStubComponent,
|
||||
VaultListItemsContainerStubComponent,
|
||||
],
|
||||
providers: [{ provide: VaultItemsTransferService, useValue: vaultItemsTransferSvc }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -344,6 +357,7 @@ describe("VaultV2Component", () => {
|
||||
it("loading$ is true when items loading or filters missing; false when both ready", () => {
|
||||
const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject<boolean>;
|
||||
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
|
||||
const readySubject$ = component["readySubject"] as unknown as BehaviorSubject<boolean>;
|
||||
|
||||
const values: boolean[] = [];
|
||||
getObs<boolean>(component, "loading$").subscribe((v) => values.push(!!v));
|
||||
@@ -354,6 +368,8 @@ describe("VaultV2Component", () => {
|
||||
|
||||
itemsLoading$.next(false);
|
||||
|
||||
readySubject$.next(true);
|
||||
|
||||
expect(values[values.length - 1]).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
BehaviorSubject,
|
||||
} from "rxjs";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
|
||||
@@ -42,7 +43,11 @@ import {
|
||||
NoItemsModule,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
|
||||
import {
|
||||
DecryptionFailureDialogComponent,
|
||||
VaultItemsTransferService,
|
||||
DefaultVaultItemsTransferService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
@@ -105,6 +110,7 @@ type VaultState = UnionOfValues<typeof VaultState>;
|
||||
VaultFadeInOutSkeletonComponent,
|
||||
VaultFadeInOutComponent,
|
||||
],
|
||||
providers: [{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }],
|
||||
})
|
||||
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
@@ -125,7 +131,22 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
activeUserId: UserId | null = null;
|
||||
|
||||
private loading$ = this.vaultPopupLoadingService.loading$.pipe(
|
||||
/**
|
||||
* Subject that indicates whether the vault is ready to render
|
||||
* and that all initialization tasks have been completed (ngOnInit).
|
||||
* @private
|
||||
*/
|
||||
private readySubject = new BehaviorSubject(false);
|
||||
|
||||
/**
|
||||
* Indicates whether the vault is loading and not yet ready to be displayed.
|
||||
* @protected
|
||||
*/
|
||||
protected loading$ = combineLatest([
|
||||
this.vaultPopupLoadingService.loading$,
|
||||
this.readySubject.asObservable(),
|
||||
]).pipe(
|
||||
map(([loading, ready]) => loading || !ready),
|
||||
distinctUntilChanged(),
|
||||
tap((loading) => {
|
||||
const key = loading ? "loadingVault" : "vaultLoaded";
|
||||
@@ -200,14 +221,15 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
protected showSkeletonsLoaders$ = combineLatest([
|
||||
this.loading$,
|
||||
this.searchService.isCipherSearching$,
|
||||
this.vaultItemsTransferService.transferInProgress$,
|
||||
this.skeletonFeatureFlag$,
|
||||
]).pipe(
|
||||
map(
|
||||
([loading, cipherSearching, skeletonsEnabled]) =>
|
||||
(loading || cipherSearching) && skeletonsEnabled,
|
||||
),
|
||||
map(([loading, cipherSearching, transferInProgress, skeletonsEnabled]) => {
|
||||
return (loading || cipherSearching || transferInProgress) && skeletonsEnabled;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
skeletonLoadingDelay(),
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
);
|
||||
|
||||
protected newItemItemValues$: Observable<NewItemInitialValues> =
|
||||
@@ -251,6 +273,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
private i18nService: I18nService,
|
||||
private configService: ConfigService,
|
||||
private searchService: SearchService,
|
||||
private vaultItemsTransferService: VaultItemsTransferService,
|
||||
) {
|
||||
combineLatest([
|
||||
this.vaultPopupItemsService.emptyVault$,
|
||||
@@ -305,6 +328,10 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
cipherIds: ciphers.map((c) => c.id as CipherId),
|
||||
});
|
||||
});
|
||||
|
||||
await this.vaultItemsTransferService.enforceOrganizationDataOwnership(this.activeUserId);
|
||||
|
||||
this.readySubject.next(true);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -37,14 +37,19 @@
|
||||
@if (showArchiveItem()) {
|
||||
@if (userCanArchive()) {
|
||||
<bit-item>
|
||||
<a bit-item-content routerLink="/archive">
|
||||
<a data-test-id="archive-link" bit-item-content routerLink="/archive">
|
||||
{{ "archiveNoun" | i18n }}
|
||||
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
|
||||
</a>
|
||||
</bit-item>
|
||||
} @else {
|
||||
<bit-item>
|
||||
<a bit-item-content [routerLink]="userHasArchivedItems() ? '/archive' : '/premium'">
|
||||
<a
|
||||
data-test-id="premium-archive-link"
|
||||
bit-item-content
|
||||
href="#"
|
||||
(click)="conditionallyRouteToArchive($event)"
|
||||
>
|
||||
<span class="tw-flex tw-items-center tw-gap-2">
|
||||
{{ "archiveNoun" | i18n }}
|
||||
@if (!userHasArchivedItems()) {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ChangeDetectionStrategy, Component, DebugElement, input } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { provideRouter, Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { NudgesService } from "@bitwarden/angular/vault";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
import { VaultSettingsV2Component } from "./vault-settings-v2.component";
|
||||
|
||||
@Component({
|
||||
selector: "popup-header",
|
||||
template: `<ng-content></ng-content>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockPopupHeaderComponent {
|
||||
readonly pageTitle = input<string>();
|
||||
readonly showBackButton = input<boolean>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "popup-page",
|
||||
template: `<ng-content></ng-content>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockPopupPageComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "app-pop-out",
|
||||
template: ``,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class MockPopOutComponent {
|
||||
readonly show = input(true);
|
||||
}
|
||||
|
||||
describe("VaultSettingsV2Component", () => {
|
||||
let component: VaultSettingsV2Component;
|
||||
let fixture: ComponentFixture<VaultSettingsV2Component>;
|
||||
let router: Router;
|
||||
let mockCipherArchiveService: jest.Mocked<CipherArchiveService>;
|
||||
|
||||
const mockActiveAccount$ = new BehaviorSubject<{ id: string }>({
|
||||
id: "user-id",
|
||||
});
|
||||
const mockUserCanArchive$ = new BehaviorSubject<boolean>(false);
|
||||
const mockHasArchiveFlagEnabled$ = new BehaviorSubject<boolean>(true);
|
||||
const mockArchivedCiphers$ = new BehaviorSubject<CipherView[]>([]);
|
||||
const mockShowNudgeBadge$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
const queryByTestId = (testId: string): DebugElement | null => {
|
||||
return fixture.debugElement.query(By.css(`[data-test-id="${testId}"]`));
|
||||
};
|
||||
|
||||
const setArchiveState = (
|
||||
canArchive: boolean,
|
||||
archivedItems: CipherView[] = [],
|
||||
flagEnabled = true,
|
||||
) => {
|
||||
mockUserCanArchive$.next(canArchive);
|
||||
mockArchivedCiphers$.next(archivedItems);
|
||||
mockHasArchiveFlagEnabled$.next(flagEnabled);
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockCipherArchiveService = mock<CipherArchiveService>({
|
||||
userCanArchive$: jest.fn().mockReturnValue(mockUserCanArchive$),
|
||||
hasArchiveFlagEnabled$: jest.fn().mockReturnValue(mockHasArchiveFlagEnabled$),
|
||||
archivedCiphers$: jest.fn().mockReturnValue(mockArchivedCiphers$),
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultSettingsV2Component],
|
||||
providers: [
|
||||
provideRouter([
|
||||
{ path: "archive", component: VaultSettingsV2Component },
|
||||
{ path: "premium", component: VaultSettingsV2Component },
|
||||
]),
|
||||
{ provide: SyncService, useValue: mock<SyncService>() },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||
{ provide: DialogService, useValue: mock<DialogService>() },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: CipherArchiveService, useValue: mockCipherArchiveService },
|
||||
{
|
||||
provide: NudgesService,
|
||||
useValue: { showNudgeBadge$: jest.fn().mockReturnValue(mockShowNudgeBadge$) },
|
||||
},
|
||||
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: mock<BillingAccountProfileStateService>(),
|
||||
},
|
||||
{
|
||||
provide: AccountService,
|
||||
useValue: { activeAccount$: mockActiveAccount$ },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(VaultSettingsV2Component, {
|
||||
remove: {
|
||||
imports: [PopupHeaderComponent, PopupPageComponent, PopOutComponent],
|
||||
},
|
||||
add: {
|
||||
imports: [MockPopupHeaderComponent, MockPopupPageComponent, MockPopOutComponent],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VaultSettingsV2Component);
|
||||
component = fixture.componentInstance;
|
||||
router = TestBed.inject(Router);
|
||||
jest.spyOn(router, "navigate");
|
||||
});
|
||||
|
||||
describe("archive link", () => {
|
||||
it("shows direct archive link when user can archive", () => {
|
||||
setArchiveState(true);
|
||||
|
||||
const archiveLink = queryByTestId("archive-link");
|
||||
|
||||
expect(archiveLink.nativeElement.getAttribute("routerLink")).toBe("/archive");
|
||||
});
|
||||
|
||||
it("routes to archive when user has archived items but cannot archive", async () => {
|
||||
setArchiveState(false, [{ id: "cipher1" } as CipherView]);
|
||||
|
||||
const premiumArchiveLink = queryByTestId("premium-archive-link");
|
||||
|
||||
premiumArchiveLink.nativeElement.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(router.navigate).toHaveBeenCalledWith(["/archive"]);
|
||||
});
|
||||
|
||||
it("prompts for premium when user cannot archive and has no archived items", async () => {
|
||||
setArchiveState(false, []);
|
||||
const badge = component["premiumBadgeComponent"]();
|
||||
jest.spyOn(badge, "promptForPremium");
|
||||
|
||||
const premiumArchiveLink = queryByTestId("premium-archive-link");
|
||||
|
||||
premiumArchiveLink.nativeElement.click();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(badge.promptForPremium).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("archive visibility", () => {
|
||||
it("displays archive link when user can archive", () => {
|
||||
setArchiveState(true);
|
||||
|
||||
const archiveLink = queryByTestId("archive-link");
|
||||
|
||||
expect(archiveLink).toBeTruthy();
|
||||
expect(component["userCanArchive"]()).toBe(true);
|
||||
});
|
||||
|
||||
it("hides archive link when feature flag is disabled", () => {
|
||||
setArchiveState(false, [], false);
|
||||
|
||||
const archiveLink = queryByTestId("archive-link");
|
||||
const premiumArchiveLink = queryByTestId("premium-archive-link");
|
||||
|
||||
expect(archiveLink).toBeNull();
|
||||
expect(premiumArchiveLink).toBeNull();
|
||||
expect(component["showArchiveItem"]()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows premium badge when user has no archived items and cannot archive", () => {
|
||||
setArchiveState(false, []);
|
||||
|
||||
expect(component["premiumBadgeComponent"]()).toBeTruthy();
|
||||
expect(component["userHasArchivedItems"]()).toBe(false);
|
||||
});
|
||||
|
||||
it("hides premium badge when user has archived items", () => {
|
||||
setArchiveState(false, [{ id: "cipher1" } as CipherView]);
|
||||
|
||||
expect(component["premiumBadgeComponent"]()).toBeUndefined();
|
||||
expect(component["userHasArchivedItems"]()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Component, OnDestroy, OnInit, viewChild } from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { firstValueFrom, map, switchMap } from "rxjs";
|
||||
@@ -42,6 +42,8 @@ import { BrowserPremiumUpgradePromptService } from "../services/browser-premium-
|
||||
],
|
||||
})
|
||||
export class VaultSettingsV2Component implements OnInit, OnDestroy {
|
||||
private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent);
|
||||
|
||||
lastSync = "--";
|
||||
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
|
||||
|
||||
@@ -117,4 +119,18 @@ export class VaultSettingsV2Component implements OnInit, OnDestroy {
|
||||
this.lastSync = this.i18nService.t("never");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a user can archive or has previously archived items, route them to
|
||||
* the archive page. Otherwise, prompt them to upgrade to premium.
|
||||
*/
|
||||
async conditionallyRouteToArchive(event: Event) {
|
||||
event.preventDefault();
|
||||
const premiumBadge = this.premiumBadgeComponent();
|
||||
if (this.userCanArchive() || this.userHasArchivedItems()) {
|
||||
await this.router.navigate(["/archive"]);
|
||||
} else if (premiumBadge) {
|
||||
await premiumBadge.promptForPremium(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { of } from "rxjs";
|
||||
import { of, Subject } from "rxjs";
|
||||
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -11,12 +11,15 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { MenuModule, TableModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
import { RoutedVaultFilterService } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service";
|
||||
import { RoutedVaultFilterModel } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
|
||||
|
||||
import { VaultItem } from "./vault-item";
|
||||
import { VaultItemsComponent } from "./vault-items.component";
|
||||
|
||||
describe("VaultItemsComponent", () => {
|
||||
let component: VaultItemsComponent<CipherViewLike>;
|
||||
let filterSelect: Subject<RoutedVaultFilterModel>;
|
||||
|
||||
const cipher1: Partial<CipherView> = {
|
||||
id: "cipher-1",
|
||||
@@ -31,6 +34,8 @@ describe("VaultItemsComponent", () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
filterSelect = new Subject<RoutedVaultFilterModel>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [VaultItemsComponent],
|
||||
imports: [ScrollingModule, TableModule, I18nPipe, MenuModule],
|
||||
@@ -61,6 +66,12 @@ describe("VaultItemsComponent", () => {
|
||||
hasArchiveFlagEnabled$: of(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RoutedVaultFilterService,
|
||||
useValue: {
|
||||
filter$: filterSelect,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -143,4 +154,22 @@ describe("VaultItemsComponent", () => {
|
||||
expect(component.bulkUnarchiveAllowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter change handling", () => {
|
||||
it("clears selection when routed filter changes", () => {
|
||||
const items: VaultItem<CipherView>[] = [
|
||||
{ cipher: cipher1 as CipherView },
|
||||
{ cipher: cipher2 as CipherView },
|
||||
];
|
||||
|
||||
component["selection"].select(...items);
|
||||
expect(component["selection"].selected.length).toBeGreaterThan(0);
|
||||
|
||||
filterSelect.next({
|
||||
folderId: "folderId",
|
||||
});
|
||||
|
||||
expect(component["selection"].selected.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
import { SelectionModel } from "@angular/cdk/collections";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { Observable, combineLatest, map, of, startWith, switchMap } from "rxjs";
|
||||
import {
|
||||
Observable,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
@@ -19,6 +27,7 @@ import {
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { SortDirection, TableDataSource } from "@bitwarden/components";
|
||||
import { OrganizationId } from "@bitwarden/sdk-internal";
|
||||
import { RoutedVaultFilterService } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service";
|
||||
|
||||
import { GroupView } from "../../../admin-console/organizations/core";
|
||||
|
||||
@@ -152,6 +161,7 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
protected cipherAuthorizationService: CipherAuthorizationService,
|
||||
protected restrictedItemTypesService: RestrictedItemTypesService,
|
||||
protected cipherArchiveService: CipherArchiveService,
|
||||
protected routedVaultFilterService: RoutedVaultFilterService,
|
||||
) {
|
||||
this.canDeleteSelected$ = this.selection.changed.pipe(
|
||||
startWith(null),
|
||||
@@ -219,6 +229,22 @@ export class VaultItemsComponent<C extends CipherViewLike> {
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
this.routedVaultFilterService.filter$
|
||||
.pipe(
|
||||
distinctUntilChanged(
|
||||
(prev, curr) =>
|
||||
prev.organizationId === curr.organizationId &&
|
||||
prev.collectionId === curr.collectionId &&
|
||||
prev.folderId === curr.folderId &&
|
||||
prev.type === curr.type &&
|
||||
prev.organizationIdParamType === curr.organizationIdParamType,
|
||||
),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cip
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { LayoutComponent } from "@bitwarden/components";
|
||||
import { RoutedVaultFilterService } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/services/routed-vault-filter.service";
|
||||
|
||||
import { GroupView } from "../../../admin-console/organizations/core";
|
||||
import { PreloadedEnglishI18nModule } from "../../../core/tests";
|
||||
@@ -150,6 +151,17 @@ export default {
|
||||
hasArchiveFlagEnabled$: of(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RoutedVaultFilterService,
|
||||
useValue: {
|
||||
filter$: of({
|
||||
organizationId: null,
|
||||
collectionId: null,
|
||||
folderId: null,
|
||||
type: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
|
||||
@@ -9,6 +9,14 @@ import { CollectionData, Collection, CollectionView } from "../models";
|
||||
export abstract class CollectionService {
|
||||
abstract encryptedCollections$(userId: UserId): Observable<Collection[] | null>;
|
||||
abstract decryptedCollections$(userId: UserId): Observable<CollectionView[]>;
|
||||
|
||||
/**
|
||||
* Gets the default collection for a user in a given organization, if it exists.
|
||||
*/
|
||||
abstract defaultUserCollection$(
|
||||
userId: UserId,
|
||||
orgId: OrganizationId,
|
||||
): Observable<CollectionView | undefined>;
|
||||
abstract upsert(collection: CollectionData, userId: UserId): Promise<any>;
|
||||
abstract replace(collections: { [id: string]: CollectionData }, userId: UserId): Promise<any>;
|
||||
/**
|
||||
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
} from "@bitwarden/common/spec";
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { CollectionData, CollectionView } from "../models";
|
||||
import { CollectionData, CollectionTypes, CollectionView } from "../models";
|
||||
|
||||
import { DECRYPTED_COLLECTION_DATA_KEY, ENCRYPTED_COLLECTION_DATA_KEY } from "./collection.state";
|
||||
import { DefaultCollectionService } from "./default-collection.service";
|
||||
@@ -389,6 +390,83 @@ describe("DefaultCollectionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultUserCollection$", () => {
|
||||
it("returns the default collection when one exists matching the org", async () => {
|
||||
const orgId = newGuid() as OrganizationId;
|
||||
const defaultCollection = collectionViewDataFactory(orgId);
|
||||
defaultCollection.type = CollectionTypes.DefaultUserCollection;
|
||||
|
||||
const regularCollection = collectionViewDataFactory(orgId);
|
||||
regularCollection.type = CollectionTypes.SharedCollection;
|
||||
|
||||
await setDecryptedState([defaultCollection, regularCollection]);
|
||||
|
||||
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgId));
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(defaultCollection.id);
|
||||
expect(result?.isDefaultCollection).toBe(true);
|
||||
});
|
||||
|
||||
it("returns undefined when no default collection exists", async () => {
|
||||
const orgId = newGuid() as OrganizationId;
|
||||
const collection1 = collectionViewDataFactory(orgId);
|
||||
collection1.type = CollectionTypes.SharedCollection;
|
||||
|
||||
const collection2 = collectionViewDataFactory(orgId);
|
||||
collection2.type = CollectionTypes.SharedCollection;
|
||||
|
||||
await setDecryptedState([collection1, collection2]);
|
||||
|
||||
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgId));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when default collection exists but for different org", async () => {
|
||||
const orgA = newGuid() as OrganizationId;
|
||||
const orgB = newGuid() as OrganizationId;
|
||||
|
||||
const defaultCollectionForOrgA = collectionViewDataFactory(orgA);
|
||||
defaultCollectionForOrgA.type = CollectionTypes.DefaultUserCollection;
|
||||
|
||||
await setDecryptedState([defaultCollectionForOrgA]);
|
||||
|
||||
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgB));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when collections array is empty", async () => {
|
||||
const orgId = newGuid() as OrganizationId;
|
||||
|
||||
await setDecryptedState([]);
|
||||
|
||||
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgId));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns correct collection when multiple orgs have default collections", async () => {
|
||||
const orgA = newGuid() as OrganizationId;
|
||||
const orgB = newGuid() as OrganizationId;
|
||||
|
||||
const defaultCollectionForOrgA = collectionViewDataFactory(orgA);
|
||||
defaultCollectionForOrgA.type = CollectionTypes.DefaultUserCollection;
|
||||
|
||||
const defaultCollectionForOrgB = collectionViewDataFactory(orgB);
|
||||
defaultCollectionForOrgB.type = CollectionTypes.DefaultUserCollection;
|
||||
|
||||
await setDecryptedState([defaultCollectionForOrgA, defaultCollectionForOrgB]);
|
||||
|
||||
const result = await firstValueFrom(collectionService.defaultUserCollection$(userId, orgB));
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(defaultCollectionForOrgB.id);
|
||||
expect(result?.organizationId).toBe(orgB);
|
||||
});
|
||||
});
|
||||
|
||||
const setEncryptedState = (collectionData: CollectionData[] | null) =>
|
||||
stateProvider.setUserState(
|
||||
ENCRYPTED_COLLECTION_DATA_KEY,
|
||||
|
||||
@@ -87,6 +87,17 @@ export class DefaultCollectionService implements CollectionService {
|
||||
return result$;
|
||||
}
|
||||
|
||||
defaultUserCollection$(
|
||||
userId: UserId,
|
||||
orgId: OrganizationId,
|
||||
): Observable<CollectionView | undefined> {
|
||||
return this.decryptedCollections$(userId).pipe(
|
||||
map((collections) => {
|
||||
return collections.find((c) => c.isDefaultCollection && c.organizationId === orgId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private initializeDecryptedState(userId: UserId): Observable<CollectionView[]> {
|
||||
return combineLatest([
|
||||
this.encryptedCollections$(userId),
|
||||
|
||||
@@ -31,6 +31,11 @@ export type UserMigrationInfo =
|
||||
};
|
||||
|
||||
export abstract class VaultItemsTransferService {
|
||||
/**
|
||||
* Indicates whether a vault items transfer is currently in progress.
|
||||
*/
|
||||
abstract transferInProgress$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Gets information about whether the given user requires migration of their vault items
|
||||
* from My Vault to a My Items collection, and whether they are capable of performing that migration.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<p bitTypography="body1">
|
||||
{{ "leaveConfirmationDialogContentOne" | i18n }}
|
||||
</p>
|
||||
<p bitTypography="body1">
|
||||
<p bitTypography="body1" class="tw-mb-0">
|
||||
{{ "leaveConfirmationDialogContentTwo" | i18n }}
|
||||
</p>
|
||||
</ng-container>
|
||||
@@ -25,7 +25,7 @@
|
||||
{{ "goBack" | i18n }}
|
||||
</button>
|
||||
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center">
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
|
||||
{{ "howToManageMyVault" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
CenterPositionStrategy,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export interface LeaveConfirmationDialogParams {
|
||||
@@ -58,6 +59,8 @@ export class LeaveConfirmationDialogComponent {
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<LeaveConfirmationDialogParams>) {
|
||||
return dialogService.open<LeaveConfirmationDialogResultType>(LeaveConfirmationDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
disableClose: true,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
{{ "declineAndLeave" | i18n }}
|
||||
</button>
|
||||
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center">
|
||||
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center tw-text-sm">
|
||||
{{ "whyAmISeeingThis" | i18n }}
|
||||
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||
</a>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogModule,
|
||||
LinkModule,
|
||||
TypographyModule,
|
||||
CenterPositionStrategy,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
export interface TransferItemsDialogParams {
|
||||
@@ -58,6 +59,8 @@ export class TransferItemsDialogComponent {
|
||||
|
||||
static open(dialogService: DialogService, config: DialogConfig<TransferItemsDialogParams>) {
|
||||
return dialogService.open<TransferItemsDialogResultType>(TransferItemsDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
disableClose: true,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
import { firstValueFrom, of, Subject } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
@@ -14,14 +14,20 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import {
|
||||
LeaveConfirmationDialogResult,
|
||||
TransferItemsDialogResult,
|
||||
} from "../components/vault-items-transfer";
|
||||
|
||||
import { DefaultVaultItemsTransferService } from "./default-vault-items-transfer.service";
|
||||
|
||||
describe("DefaultVaultItemsTransferService", () => {
|
||||
let service: DefaultVaultItemsTransferService;
|
||||
let transferInProgressValues: boolean[];
|
||||
|
||||
let mockCipherService: MockProxy<CipherService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
@@ -37,6 +43,25 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
const organizationId = "org-id" as OrganizationId;
|
||||
const collectionId = "collection-id" as CollectionId;
|
||||
|
||||
/**
|
||||
* Creates a mock DialogRef that emits the provided result when closed
|
||||
*/
|
||||
function createMockDialogRef<T>(result: T): DialogRef<T> {
|
||||
const closed$ = new Subject<T>();
|
||||
const dialogRef = {
|
||||
closed: closed$.asObservable(),
|
||||
close: jest.fn(),
|
||||
} as unknown as DialogRef<T>;
|
||||
|
||||
// Emit the result asynchronously to simulate dialog closing
|
||||
setTimeout(() => {
|
||||
closed$.next(result);
|
||||
closed$.complete();
|
||||
}, 0);
|
||||
|
||||
return dialogRef;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockCipherService = mock<CipherService>();
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
@@ -49,6 +74,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockConfigService = mock<ConfigService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => key);
|
||||
transferInProgressValues = [];
|
||||
|
||||
service = new DefaultVaultItemsTransferService(
|
||||
mockCipherService,
|
||||
@@ -69,12 +95,12 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
policies?: Policy[];
|
||||
organizations?: Organization[];
|
||||
ciphers?: CipherView[];
|
||||
collections?: CollectionView[];
|
||||
defaultCollection?: CollectionView;
|
||||
}): void {
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
|
||||
}
|
||||
|
||||
it("calls policiesByType$ with correct PolicyType", async () => {
|
||||
@@ -151,39 +177,12 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
});
|
||||
|
||||
it("includes defaultCollectionId when a default collection exists", async () => {
|
||||
mockCollectionService.decryptedCollections$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresMigration: true,
|
||||
enforcingOrganization: organization,
|
||||
defaultCollectionId: collectionId,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns default collection only for the enforcing organization", async () => {
|
||||
mockCollectionService.decryptedCollections$.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: "wrong-collection-id" as CollectionId,
|
||||
organizationId: "wrong-org-id" as OrganizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
]),
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(
|
||||
of({
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(service.userMigrationInfo$(userId));
|
||||
@@ -542,13 +541,13 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
policies?: Policy[];
|
||||
organizations?: Organization[];
|
||||
ciphers?: CipherView[];
|
||||
collections?: CollectionView[];
|
||||
defaultCollection?: CollectionView;
|
||||
}): void {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.decryptedCollections$.mockReturnValue(of(options.collections ?? []));
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
|
||||
}
|
||||
|
||||
it("does nothing when feature flag is disabled", async () => {
|
||||
@@ -557,13 +556,11 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
@@ -571,7 +568,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.MigrateMyVaultToMyItems,
|
||||
);
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -580,7 +577,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -593,7 +590,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -602,7 +599,6 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
collections: [],
|
||||
});
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
@@ -610,69 +606,48 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
expect(mockLogService.warning).toHaveBeenCalledWith(
|
||||
"Default collection is missing for user during organization data ownership enforcement",
|
||||
);
|
||||
expect(mockDialogService.openSimpleDialog).not.toHaveBeenCalled();
|
||||
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows confirmation dialog when migration is required", async () => {
|
||||
it("does not transfer items when user declines and confirms leaving", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({
|
||||
title: "Requires migration",
|
||||
content: "Your vault requires migration of personal items to your organization.",
|
||||
type: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not transfer items when user declines confirmation", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(false);
|
||||
// User declines transfer, then confirms leaving
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("transfers items and shows success toast when user confirms", async () => {
|
||||
it("transfers items and shows success toast when user accepts transfer", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
mockDialogService.open.mockReturnValueOnce(
|
||||
createMockDialogRef(TransferItemsDialogResult.Accepted),
|
||||
);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
@@ -695,15 +670,16 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
collections: [
|
||||
{
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
],
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
mockDialogService.openSimpleDialog.mockResolvedValue(true);
|
||||
|
||||
mockDialogService.open.mockReturnValueOnce(
|
||||
createMockDialogRef(TransferItemsDialogResult.Accepted),
|
||||
);
|
||||
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
@@ -717,5 +693,171 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
message: "errorOccurred",
|
||||
});
|
||||
});
|
||||
|
||||
it("re-shows transfer dialog when user goes back from leave confirmation", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
// User declines, goes back, then accepts
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Accepted));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
// Dialog should have been opened 3 times: transfer -> leave -> transfer (after going back)
|
||||
expect(mockDialogService.open).toHaveBeenCalledTimes(3);
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows multiple back navigations before accepting transfer", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
// User declines, goes back, declines again, goes back again, then accepts
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Accepted));
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
// Dialog should have been opened 5 times
|
||||
expect(mockDialogService.open).toHaveBeenCalledTimes(5);
|
||||
expect(mockCipherService.shareManyWithServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows user to go back and then confirm leaving", async () => {
|
||||
setupMocksForEnforcementScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: [{ id: "cipher-1" } as CipherView],
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
// User declines, goes back, declines again, then confirms leaving
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Back))
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockDialogService.open).toHaveBeenCalledTimes(4);
|
||||
expect(mockCipherService.shareManyWithServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferInProgress$", () => {
|
||||
const policy = {
|
||||
organizationId: organizationId,
|
||||
revisionDate: new Date("2024-01-01"),
|
||||
} as Policy;
|
||||
const organization = {
|
||||
id: organizationId,
|
||||
name: "Test Org",
|
||||
} as Organization;
|
||||
|
||||
function setupMocksForTransferScenario(options: {
|
||||
featureEnabled?: boolean;
|
||||
policies?: Policy[];
|
||||
organizations?: Organization[];
|
||||
ciphers?: CipherView[];
|
||||
defaultCollection?: CollectionView;
|
||||
}): void {
|
||||
mockConfigService.getFeatureFlag.mockResolvedValue(options.featureEnabled ?? true);
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of(options.policies ?? []));
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
|
||||
}
|
||||
|
||||
it("emits false initially", async () => {
|
||||
const result = await firstValueFrom(service.transferInProgress$);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("emits true during transfer and false after successful completion", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForTransferScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
mockDialogService.open.mockReturnValueOnce(
|
||||
createMockDialogRef(TransferItemsDialogResult.Accepted),
|
||||
);
|
||||
mockCipherService.shareManyWithServer.mockResolvedValue(undefined);
|
||||
|
||||
// Subscribe to track all emitted values
|
||||
service.transferInProgress$.subscribe((value) => transferInProgressValues.push(value));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
// Should have emitted: false (initial), true (transfer started), false (transfer completed)
|
||||
expect(transferInProgressValues).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it("emits false after transfer fails with error", async () => {
|
||||
const personalCiphers = [{ id: "cipher-1" } as CipherView];
|
||||
setupMocksForTransferScenario({
|
||||
policies: [policy],
|
||||
organizations: [organization],
|
||||
ciphers: personalCiphers,
|
||||
defaultCollection: {
|
||||
id: collectionId,
|
||||
organizationId: organizationId,
|
||||
isDefaultCollection: true,
|
||||
} as CollectionView,
|
||||
});
|
||||
|
||||
mockDialogService.open.mockReturnValueOnce(
|
||||
createMockDialogRef(TransferItemsDialogResult.Accepted),
|
||||
);
|
||||
mockCipherService.shareManyWithServer.mockRejectedValue(new Error("Transfer failed"));
|
||||
|
||||
// Subscribe to track all emitted values
|
||||
service.transferInProgress$.subscribe((value) => transferInProgressValues.push(value));
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
// Should have emitted: false (initial), true (transfer started), false (transfer failed)
|
||||
expect(transferInProgressValues).toEqual([false, true, false]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { firstValueFrom, switchMap, map, of, Observable, combineLatest } from "rxjs";
|
||||
import {
|
||||
firstValueFrom,
|
||||
switchMap,
|
||||
map,
|
||||
of,
|
||||
Observable,
|
||||
combineLatest,
|
||||
BehaviorSubject,
|
||||
} from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
@@ -23,6 +31,12 @@ import {
|
||||
VaultItemsTransferService,
|
||||
UserMigrationInfo,
|
||||
} from "../abstractions/vault-items-transfer.service";
|
||||
import {
|
||||
TransferItemsDialogComponent,
|
||||
TransferItemsDialogResult,
|
||||
LeaveConfirmationDialogComponent,
|
||||
LeaveConfirmationDialogResult,
|
||||
} from "../components/vault-items-transfer";
|
||||
|
||||
@Injectable()
|
||||
export class DefaultVaultItemsTransferService implements VaultItemsTransferService {
|
||||
@@ -38,6 +52,10 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
private _transferInProgressSubject = new BehaviorSubject(false);
|
||||
|
||||
transferInProgress$ = this._transferInProgressSubject.asObservable();
|
||||
|
||||
private enforcingOrganization$(userId: UserId): Observable<Organization | undefined> {
|
||||
return this.policyService.policiesByType$(PolicyType.OrganizationDataOwnership, userId).pipe(
|
||||
map(
|
||||
@@ -60,18 +78,6 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
);
|
||||
}
|
||||
|
||||
private defaultUserCollection$(
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
): Observable<CollectionId | undefined> {
|
||||
return this.collectionService.decryptedCollections$(userId).pipe(
|
||||
map((collections) => {
|
||||
return collections.find((c) => c.isDefaultCollection && c.organizationId === organizationId)
|
||||
?.id;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
userMigrationInfo$(userId: UserId): Observable<UserMigrationInfo> {
|
||||
return this.enforcingOrganization$(userId).pipe(
|
||||
switchMap((enforcingOrganization) => {
|
||||
@@ -82,13 +88,13 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
}
|
||||
return combineLatest([
|
||||
this.personalCiphers$(userId),
|
||||
this.defaultUserCollection$(userId, enforcingOrganization.id),
|
||||
this.collectionService.defaultUserCollection$(userId, enforcingOrganization.id),
|
||||
]).pipe(
|
||||
map(([personalCiphers, defaultCollectionId]): UserMigrationInfo => {
|
||||
map(([personalCiphers, defaultCollection]): UserMigrationInfo => {
|
||||
return {
|
||||
requiresMigration: personalCiphers.length > 0,
|
||||
enforcingOrganization,
|
||||
defaultCollectionId,
|
||||
defaultCollectionId: defaultCollection?.id,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -96,6 +102,35 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to accept or decline the vault items transfer.
|
||||
* If declined, shows a leave confirmation dialog with option to go back.
|
||||
* @returns true if user accepts transfer, false if user confirms leaving
|
||||
*/
|
||||
private async promptUserForTransfer(organizationName: string): Promise<boolean> {
|
||||
const confirmDialogRef = TransferItemsDialogComponent.open(this.dialogService, {
|
||||
data: { organizationName },
|
||||
});
|
||||
|
||||
const confirmResult = await firstValueFrom(confirmDialogRef.closed);
|
||||
|
||||
if (confirmResult === TransferItemsDialogResult.Accepted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const leaveDialogRef = LeaveConfirmationDialogComponent.open(this.dialogService, {
|
||||
data: { organizationName },
|
||||
});
|
||||
|
||||
const leaveResult = await firstValueFrom(leaveDialogRef.closed);
|
||||
|
||||
if (leaveResult === LeaveConfirmationDialogResult.Back) {
|
||||
return this.promptUserForTransfer(organizationName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async enforceOrganizationDataOwnership(userId: UserId): Promise<void> {
|
||||
const featureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.MigrateMyVaultToMyItems,
|
||||
@@ -119,30 +154,29 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporary confirmation dialog. Full implementation in PM-27663
|
||||
const confirmMigration = await this.dialogService.openSimpleDialog({
|
||||
title: "Requires migration",
|
||||
content: "Your vault requires migration of personal items to your organization.",
|
||||
type: "warning",
|
||||
});
|
||||
const userAcceptedTransfer = await this.promptUserForTransfer(
|
||||
migrationInfo.enforcingOrganization.name,
|
||||
);
|
||||
|
||||
if (!confirmMigration) {
|
||||
// TODO: Show secondary confirmation dialog in PM-27663, for now we just exit
|
||||
// TODO: Revoke user from organization if they decline migration PM-29465
|
||||
if (!userAcceptedTransfer) {
|
||||
// TODO: Revoke user from organization if they decline migration and show toast PM-29465
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._transferInProgressSubject.next(true);
|
||||
await this.transferPersonalItems(
|
||||
userId,
|
||||
migrationInfo.enforcingOrganization.id,
|
||||
migrationInfo.defaultCollectionId,
|
||||
);
|
||||
this._transferInProgressSubject.next(false);
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("itemsTransferred"),
|
||||
});
|
||||
} catch (error) {
|
||||
this._transferInProgressSubject.next(false);
|
||||
this.logService.error("Error transferring personal items to organization", error);
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
|
||||
Reference in New Issue
Block a user