[AC-2436] Show unassigned items banner in browser (#8656)
* Boostrap basic banner, show for all admins * Remove UI banner, fix method calls * Invert showBanner -> hideBanner * Add api call * Minor tweaks and wording * Change to active user state * Add tests * Fix mixed up names * Simplify logic * Add feature flag * Do not clear on logout * Show banner in browser as well * Update apps/browser/src/_locales/en/messages.json * Update copy --------- Co-authored-by: Addison Beck <github@addisonbeck.com> Co-authored-by: Addison Beck <hello@addisonbeck.com>
This commit is contained in:
parent
4c2afb4121
commit
98ed744ae8
|
@ -3005,5 +3005,8 @@
|
|||
},
|
||||
"passkeyRemoved": {
|
||||
"message": "Passkey removed"
|
||||
},
|
||||
"unassignedItemsBanner": {
|
||||
"message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,19 +36,32 @@
|
|||
</div>
|
||||
<ng-container *ngIf="loaded">
|
||||
<app-vault-select (onVaultSelectionChanged)="load()"></app-vault-select>
|
||||
<app-callout *ngIf="showHowToAutofill" type="info" title="{{ 'howToAutofill' | i18n }}">
|
||||
<p>{{ autofillCalloutText }}</p>
|
||||
<app-callout
|
||||
*ngIf="
|
||||
(unassignedItemsBannerEnabled$ | async) &&
|
||||
(unassignedItemsBannerService.showBanner$ | async)
|
||||
"
|
||||
type="info"
|
||||
>
|
||||
<p>
|
||||
{{ "unassignedItemsBanner" | i18n }}
|
||||
<a
|
||||
href="https://bitwarden.com/help/unassigned-vault-items-moved-to-admin-console"
|
||||
bitLink
|
||||
linkType="contrast"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>{{ "learnMore" | i18n }}</a
|
||||
>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary callout-half"
|
||||
appStopClick
|
||||
(click)="dismissCallout()"
|
||||
(click)="unassignedItemsBannerService.hideBanner()"
|
||||
>
|
||||
{{ "gotIt" | i18n }}
|
||||
</button>
|
||||
<button type="button" class="btn callout-half" appStopClick (click)="goToSettings()">
|
||||
{{ "autofillSettings" | i18n }}
|
||||
</button>
|
||||
</app-callout>
|
||||
<div class="box list" *ngIf="loginCiphers">
|
||||
<h2 class="box-header">
|
||||
|
|
|
@ -3,11 +3,14 @@ import { Router } from "@angular/router";
|
|||
import { Subject, firstValueFrom, from } from "rxjs";
|
||||
import { debounceTime, switchMap, takeUntil } from "rxjs/operators";
|
||||
|
||||
import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service";
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
@ -54,6 +57,10 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
|
|||
private loadedTimeout: number;
|
||||
private searchTimeout: number;
|
||||
|
||||
protected unassignedItemsBannerEnabled$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.UnassignedItemsBanner,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private cipherService: CipherService,
|
||||
|
@ -70,6 +77,8 @@ export class CurrentTabComponent implements OnInit, OnDestroy {
|
|||
private organizationService: OrganizationService,
|
||||
private vaultFilterService: VaultFilterService,
|
||||
private vaultSettingsService: VaultSettingsService,
|
||||
private configService: ConfigService,
|
||||
protected unassignedItemsBannerService: UnassignedItemsBannerService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
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, UnassignedItemsBannerService } from "./unassigned-items-banner.service";
|
||||
|
||||
describe("UnassignedItemsBanner", () => {
|
||||
let stateProvider: FakeStateProvider;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
|
||||
const sutFactory = () => new UnassignedItemsBannerService(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);
|
||||
});
|
||||
});
|
|
@ -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<boolean>(
|
||||
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 UnassignedItemsBannerService {
|
||||
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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue