mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-08 00:01:28 +01:00
[AC-2436] Show unassigned items banner in web (#8655)
* 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 * Update apps/web/src/locales/en/messages.json --------- Co-authored-by: Addison Beck <github@addisonbeck.com>
This commit is contained in:
parent
1e7329d1ef
commit
be362988b0
@ -1,16 +1,18 @@
|
|||||||
<bit-banner
|
<bit-banner
|
||||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||||
(onClose)="webLayoutMigrationBannerService.hideBanner()"
|
(onClose)="webUnassignedItemsBannerService.hideBanner()"
|
||||||
*ngIf="webLayoutMigrationBannerService.showBanner$ | async"
|
*ngIf="
|
||||||
|
(unassignedItemsBannerEnabled$ | async) && (webUnassignedItemsBannerService.showBanner$ | async)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ "newWebApp" | i18n }}
|
{{ "unassignedItemsBanner" | i18n }}
|
||||||
<a
|
<a
|
||||||
href="https://bitwarden.com/blog/bitwarden-design-updating-the-navigation-in-the-web-app"
|
href="https://bitwarden.com/help/unassigned-vault-items-moved-to-admin-console"
|
||||||
bitLink
|
bitLink
|
||||||
linkType="contrast"
|
linkType="contrast"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>{{ "releaseBlog" | i18n }}</a
|
>{{ "learnMore" | i18n }}</a
|
||||||
>
|
>
|
||||||
</bit-banner>
|
</bit-banner>
|
||||||
<header
|
<header
|
||||||
|
@ -3,13 +3,15 @@ import { ActivatedRoute } from "@angular/router";
|
|||||||
import { combineLatest, map, Observable } from "rxjs";
|
import { combineLatest, map, Observable } from "rxjs";
|
||||||
|
|
||||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||||
import { AccountProfile } from "@bitwarden/common/platform/models/domain/account";
|
import { AccountProfile } from "@bitwarden/common/platform/models/domain/account";
|
||||||
|
|
||||||
import { WebLayoutMigrationBannerService } from "./web-layout-migration-banner.service";
|
import { WebUnassignedItemsBannerService } from "./web-unassigned-items-banner.service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "app-header",
|
selector: "app-header",
|
||||||
@ -31,6 +33,9 @@ export class WebHeaderComponent {
|
|||||||
protected canLock$: Observable<boolean>;
|
protected canLock$: Observable<boolean>;
|
||||||
protected selfHosted: boolean;
|
protected selfHosted: boolean;
|
||||||
protected hostname = location.hostname;
|
protected hostname = location.hostname;
|
||||||
|
protected unassignedItemsBannerEnabled$ = this.configService.getFeatureFlag$(
|
||||||
|
FeatureFlag.UnassignedItemsBanner,
|
||||||
|
);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@ -38,7 +43,8 @@ export class WebHeaderComponent {
|
|||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||||
private messagingService: MessagingService,
|
private messagingService: MessagingService,
|
||||||
protected webLayoutMigrationBannerService: WebLayoutMigrationBannerService,
|
protected webUnassignedItemsBannerService: WebUnassignedItemsBannerService,
|
||||||
|
private configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.routeData$ = this.route.data.pipe(
|
this.routeData$ = this.route.data.pipe(
|
||||||
map((params) => {
|
map((params) => {
|
||||||
|
@ -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<ApiService>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
@ -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 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);
|
||||||
|
}
|
||||||
|
}
|
@ -7899,5 +7899,8 @@
|
|||||||
},
|
},
|
||||||
"machineAccountAccessUpdated": {
|
"machineAccountAccessUpdated": {
|
||||||
"message": "Machine account access updated"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -207,6 +207,7 @@ export abstract class ApiService {
|
|||||||
emergencyAccessId?: string,
|
emergencyAccessId?: string,
|
||||||
) => Promise<AttachmentResponse>;
|
) => Promise<AttachmentResponse>;
|
||||||
getCiphersOrganization: (organizationId: string) => Promise<ListResponse<CipherResponse>>;
|
getCiphersOrganization: (organizationId: string) => Promise<ListResponse<CipherResponse>>;
|
||||||
|
getShowUnassignedCiphersBanner: () => Promise<boolean>;
|
||||||
postCipher: (request: CipherRequest) => Promise<CipherResponse>;
|
postCipher: (request: CipherRequest) => Promise<CipherResponse>;
|
||||||
postCipherCreate: (request: CipherCreateRequest) => Promise<CipherResponse>;
|
postCipherCreate: (request: CipherCreateRequest) => Promise<CipherResponse>;
|
||||||
postCipherAdmin: (request: CipherCreateRequest) => Promise<CipherResponse>;
|
postCipherAdmin: (request: CipherCreateRequest) => Promise<CipherResponse>;
|
||||||
|
@ -9,6 +9,7 @@ export enum FeatureFlag {
|
|||||||
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
|
ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners",
|
||||||
EnableConsolidatedBilling = "enable-consolidated-billing",
|
EnableConsolidatedBilling = "enable-consolidated-billing",
|
||||||
AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section",
|
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
|
// Replace this with a type safe lookup of the feature flag values in PM-2282
|
||||||
|
@ -76,6 +76,10 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne
|
|||||||
web: "disk-local",
|
web: "disk-local",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const UNASSIGNED_ITEMS_BANNER_DISK = new StateDefinition("unassignedItemsBanner", "disk", {
|
||||||
|
web: "disk-local",
|
||||||
|
});
|
||||||
|
|
||||||
// Platform
|
// Platform
|
||||||
|
|
||||||
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", {
|
||||||
|
@ -506,6 +506,11 @@ export class ApiService implements ApiServiceAbstraction {
|
|||||||
return new ListResponse(r, CipherResponse);
|
return new ListResponse(r, CipherResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getShowUnassignedCiphersBanner(): Promise<boolean> {
|
||||||
|
const r = await this.send("GET", "/ciphers/has-unassigned-ciphers", null, true, true);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
async postCipher(request: CipherRequest): Promise<CipherResponse> {
|
async postCipher(request: CipherRequest): Promise<CipherResponse> {
|
||||||
const r = await this.send("POST", "/ciphers", request, true, true);
|
const r = await this.send("POST", "/ciphers", request, true, true);
|
||||||
return new CipherResponse(r);
|
return new CipherResponse(r);
|
||||||
|
Loading…
Reference in New Issue
Block a user