diff --git a/apps/web/src/app/layouts/header/web-header.component.html b/apps/web/src/app/layouts/header/web-header.component.html index 514e5deebd9..1555726e2b6 100644 --- a/apps/web/src/app/layouts/header/web-header.component.html +++ b/apps/web/src/app/layouts/header/web-header.component.html @@ -1,16 +1,18 @@ - {{ "newWebApp" | i18n }} + {{ "unassignedItemsBanner" | i18n }} {{ "releaseBlog" | i18n }}{{ "learnMore" | i18n }}
; protected selfHosted: boolean; protected hostname = location.hostname; + protected unassignedItemsBannerEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.UnassignedItemsBanner, + ); constructor( private route: ActivatedRoute, @@ -38,7 +43,8 @@ export class WebHeaderComponent { private platformUtilsService: PlatformUtilsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private messagingService: MessagingService, - protected webLayoutMigrationBannerService: WebLayoutMigrationBannerService, + protected webUnassignedItemsBannerService: WebUnassignedItemsBannerService, + private configService: ConfigService, ) { this.routeData$ = this.route.data.pipe( map((params) => { diff --git a/apps/web/src/app/layouts/header/web-unassigned-items-banner.service.spec.ts b/apps/web/src/app/layouts/header/web-unassigned-items-banner.service.spec.ts new file mode 100644 index 00000000000..a9db11a2013 --- /dev/null +++ b/apps/web/src/app/layouts/header/web-unassigned-items-banner.service.spec.ts @@ -0,0 +1,56 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { firstValueFrom, skip } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { + SHOW_BANNER_KEY, + WebUnassignedItemsBannerService, +} from "./web-unassigned-items-banner.service"; + +describe("WebUnassignedItemsBanner", () => { + let stateProvider: FakeStateProvider; + let apiService: MockProxy; + + const sutFactory = () => new WebUnassignedItemsBannerService(stateProvider, apiService); + + beforeEach(() => { + const fakeAccountService = mockAccountServiceWith("userId" as UserId); + stateProvider = new FakeStateProvider(fakeAccountService); + apiService = mock(); + }); + + it("shows the banner if showBanner local state is true", async () => { + const showBanner = stateProvider.activeUser.getFake(SHOW_BANNER_KEY); + showBanner.nextState(true); + + const sut = sutFactory(); + expect(await firstValueFrom(sut.showBanner$)).toBe(true); + expect(apiService.getShowUnassignedCiphersBanner).not.toHaveBeenCalled(); + }); + + it("does not show the banner if showBanner local state is false", async () => { + const showBanner = stateProvider.activeUser.getFake(SHOW_BANNER_KEY); + showBanner.nextState(false); + + const sut = sutFactory(); + expect(await firstValueFrom(sut.showBanner$)).toBe(false); + expect(apiService.getShowUnassignedCiphersBanner).not.toHaveBeenCalled(); + }); + + it("fetches from server if local state has not been set yet", async () => { + apiService.getShowUnassignedCiphersBanner.mockResolvedValue(true); + + const showBanner = stateProvider.activeUser.getFake(SHOW_BANNER_KEY); + showBanner.nextState(undefined); + + const sut = sutFactory(); + // skip first value so we get the recomputed value after the server call + expect(await firstValueFrom(sut.showBanner$.pipe(skip(1)))).toBe(true); + // Expect to have updated local state + expect(await firstValueFrom(showBanner.state$)).toBe(true); + expect(apiService.getShowUnassignedCiphersBanner).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/app/layouts/header/web-unassigned-items-banner.service.ts b/apps/web/src/app/layouts/header/web-unassigned-items-banner.service.ts new file mode 100644 index 00000000000..8f09b685479 --- /dev/null +++ b/apps/web/src/app/layouts/header/web-unassigned-items-banner.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@angular/core"; +import { EMPTY, concatMap } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { + StateProvider, + UNASSIGNED_ITEMS_BANNER_DISK, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; + +export const SHOW_BANNER_KEY = new UserKeyDefinition( + UNASSIGNED_ITEMS_BANNER_DISK, + "showBanner", + { + deserializer: (b) => b, + clearOn: [], + }, +); + +/** Displays a banner that tells users how to move their unassigned items into a collection. */ +@Injectable({ providedIn: "root" }) +export class WebUnassignedItemsBannerService { + private _showBanner = this.stateProvider.getActive(SHOW_BANNER_KEY); + + showBanner$ = this._showBanner.state$.pipe( + concatMap(async (showBanner) => { + // null indicates that the user has not seen or dismissed the banner yet - get the flag from server + if (showBanner == null) { + const showBannerResponse = await this.apiService.getShowUnassignedCiphersBanner(); + await this._showBanner.update(() => showBannerResponse); + return EMPTY; // complete the inner observable without emitting any value; the update on the previous line will trigger another run + } + + return showBanner; + }), + ); + + constructor( + private stateProvider: StateProvider, + private apiService: ApiService, + ) {} + + async hideBanner() { + await this._showBanner.update(() => false); + } +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 307c5be70cb..e8944471cc6 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7899,5 +7899,8 @@ }, "machineAccountAccessUpdated": { "message": "Machine account access updated" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in your All Vaults view across devices and are now only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 20ed3216a54..811cca8638f 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -207,6 +207,7 @@ export abstract class ApiService { emergencyAccessId?: string, ) => Promise; getCiphersOrganization: (organizationId: string) => Promise>; + getShowUnassignedCiphersBanner: () => Promise; postCipher: (request: CipherRequest) => Promise; postCipherCreate: (request: CipherCreateRequest) => Promise; postCipherAdmin: (request: CipherCreateRequest) => Promise; diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9d427034bd5..b937e6c462d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -9,6 +9,7 @@ export enum FeatureFlag { ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners", EnableConsolidatedBilling = "enable-consolidated-billing", AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section", + UnassignedItemsBanner = "unassigned-items-banner", } // Replace this with a type safe lookup of the feature flag values in PM-2282 diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 10c2f3d36d9..4ecddba78d9 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -76,6 +76,10 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne web: "disk-local", }); +export const UNASSIGNED_ITEMS_BANNER_DISK = new StateDefinition("unassignedItemsBanner", "disk", { + web: "disk-local", +}); + // Platform export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", { diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 6306eb1e288..501b924e5b9 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -506,6 +506,11 @@ export class ApiService implements ApiServiceAbstraction { return new ListResponse(r, CipherResponse); } + async getShowUnassignedCiphersBanner(): Promise { + const r = await this.send("GET", "/ciphers/has-unassigned-ciphers", null, true, true); + return r; + } + async postCipher(request: CipherRequest): Promise { const r = await this.send("POST", "/ciphers", request, true, true); return new CipherResponse(r);