1
0
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:
Will Martin
2025-12-16 23:32:59 -05:00
committed by GitHub
20 changed files with 945 additions and 230 deletions

277
.github/renovate.json5 vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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