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