1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[PM-20644] [Vault] [Browser Extension] Front End Changes to Enforce "Remove card item type policy" (#15147)

* Added service to get restricted cipher and used that to hide in autofill settings

* Referenced files from the work done on web

* Fixed restrictedCardType$ observable

* Created resuseable cipher menu items type

(cherry picked from commit 34be7f7ffef135aea2449e11e45e638ebaf34ee8)

* Updated new item dropdown to filter out restricted type and also render the menu items dynamically

(cherry picked from commit 566099ba9f3dbd7f18077dbc5b8ed44f51a94bfc)

* Updated service to have cipher types as an observable

(cherry picked from commit 6848e5f75803eb45e2262c617c9805359861ad14)

* Refactored service to have use CIPHER MENU ITEMS type and filter restricted rypes and return an observable

(cherry picked from commit e25c4eb18af895deac762b9e2d7ae69cc235f224)

* Fixed type enum

* Referenced files from the work done on web

* Referenced change from the work done on web

* Remove comment

* Remove cipher type from autofill suggestion list when enabled

* revert autofillcipher$ change

* Fixed test

* Added sharereplay to restrictedCardType$ observable

* Added startwith operator

* Add organization exemptions to restricted filter
This commit is contained in:
SmithThe4th
2025-06-16 15:07:29 -04:00
committed by GitHub
parent 9ba593701a
commit fcd24a4d60
10 changed files with 184 additions and 100 deletions

View File

@@ -65,7 +65,10 @@
{{ "showInlineMenuIdentitiesLabel" | i18n }}
</bit-label>
</bit-form-control>
<bit-form-control *ngIf="enableInlineMenu" class="tw-ml-5">
<bit-form-control
*ngIf="enableInlineMenu && !(restrictedCardType$ | async)"
class="tw-ml-5"
>
<input
bitCheckbox
id="show-inline-menu-cards"
@@ -114,7 +117,7 @@
</a>
</bit-hint>
</bit-form-control>
<bit-form-control>
<bit-form-control *ngIf="!(restrictedCardType$ | async)">
<input
bitCheckbox
id="showCardsSuggestions"

View File

@@ -11,7 +11,7 @@ import {
ReactiveFormsModule,
} from "@angular/forms";
import { RouterModule } from "@angular/router";
import { filter, firstValueFrom, Observable, switchMap } from "rxjs";
import { filter, firstValueFrom, map, Observable, shareReplay, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
@@ -44,6 +44,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import {
CardComponent,
CheckboxModule,
@@ -57,6 +58,7 @@ import {
SelectModule,
TypographyModule,
} from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
import { AutofillBrowserSettingsService } from "../../../autofill/services/autofill-browser-settings.service";
import { BrowserApi } from "../../../platform/browser/browser-api";
@@ -111,6 +113,11 @@ export class AutofillComponent implements OnInit {
this.nudgesService.showNudgeSpotlight$(NudgeType.AutofillNudge, account.id),
),
);
protected restrictedCardType$: Observable<boolean> =
this.restrictedItemTypesService.restricted$.pipe(
map((restrictedTypes) => restrictedTypes.some((type) => type.cipherType === CipherType.Card)),
shareReplay({ bufferSize: 1, refCount: true }),
);
protected autofillOnPageLoadForm = new FormGroup({
autofillOnPageLoad: new FormControl(),
@@ -156,6 +163,7 @@ export class AutofillComponent implements OnInit {
private nudgesService: NudgesService,
private accountService: AccountService,
private autofillBrowserSettingsService: AutofillBrowserSettingsService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {
this.autofillOnPageLoadOptions = [
{ name: this.i18nService.t("autoFillOnPageLoadYes"), value: true },

View File

@@ -3,34 +3,12 @@
{{ "new" | i18n }}
</button>
<bit-menu #itemOptions>
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Login)">
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
{{ "typeLogin" | i18n }}
</a>
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Card)">
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
{{ "typeCard" | i18n }}
</a>
<a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.Identity)"
>
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }}
</a>
<a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.SecureNote)"
>
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }}
</a>
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.SshKey)">
<i class="bwi bwi-key" slot="start" aria-hidden="true"></i>
{{ "typeSshKey" | i18n }}
</a>
@for (menuItem of cipherMenuItems$ | async; track menuItem.type) {
<a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(menuItem.type)">
<i [class]="`bwi ${menuItem.icon}`" slot="start" aria-hidden="true"></i>
{{ menuItem.labelKey | i18n }}
</a>
}
<bit-menu-divider></bit-menu-divider>
<button type="button" bitMenuItem (click)="openFolderDialog()">
<i class="bwi bwi-folder" slot="start" aria-hidden="true"></i>

View File

@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, RouterLink } from "@angular/router";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -12,6 +13,7 @@ import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstraction
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
import { RestrictedCipherType, RestrictedItemTypesService } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
@@ -23,6 +25,7 @@ describe("NewItemDropdownV2Component", () => {
let fixture: ComponentFixture<NewItemDropdownV2Component>;
let dialogServiceMock: jest.Mocked<DialogService>;
let browserApiMock: jest.Mocked<typeof BrowserApi>;
let restrictedItemTypesServiceMock: jest.Mocked<RestrictedItemTypesService>;
const mockTab = { url: "https://example.com" };
@@ -44,6 +47,9 @@ describe("NewItemDropdownV2Component", () => {
const folderServiceMock = mock<FolderService>();
const folderApiServiceAbstractionMock = mock<FolderApiServiceAbstraction>();
const accountServiceMock = mock<AccountService>();
restrictedItemTypesServiceMock = {
restricted$: new BehaviorSubject<RestrictedCipherType[]>([]),
} as any;
await TestBed.configureTestingModule({
imports: [
@@ -65,6 +71,7 @@ describe("NewItemDropdownV2Component", () => {
{ provide: FolderService, useValue: folderServiceMock },
{ provide: FolderApiServiceAbstraction, useValue: folderApiServiceAbstractionMock },
{ provide: AccountService, useValue: accountServiceMock },
{ provide: RestrictedItemTypesService, useValue: restrictedItemTypesServiceMock },
],
}).compileComponents();
});

View File

@@ -3,12 +3,14 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { RouterLink } from "@angular/router";
import { map, Observable } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherMenuItem, CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
import { AddEditFolderDialogComponent } from "@bitwarden/vault";
import { AddEditFolderDialogComponent, RestrictedItemTypesService } from "@bitwarden/vault";
import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
@@ -34,7 +36,22 @@ export class NewItemDropdownV2Component implements OnInit {
@Input()
initialValues: NewItemInitialValues;
constructor(private dialogService: DialogService) {}
/**
* Observable of cipher menu items that are not restricted by policy
*/
readonly cipherMenuItems$: Observable<CipherMenuItem[]> =
this.restrictedItemTypeService.restricted$.pipe(
map((restrictedTypes) => {
const restrictedTypeArr = restrictedTypes.map((item) => item.cipherType);
return CIPHER_MENU_ITEMS.filter((menuItem) => !restrictedTypeArr.includes(menuItem.type));
}),
);
constructor(
private dialogService: DialogService,
private restrictedItemTypeService: RestrictedItemTypesService,
) {}
async ngOnInit() {
this.tab = await BrowserApi.getTabFromCurrentWindow();

View File

@@ -42,7 +42,7 @@
fullWidth
placeholderIcon="bwi-list"
[placeholderText]="'type' | i18n"
[options]="cipherTypes"
[options]="cipherTypes$ | async"
>
</bit-chip-select>
</form>

View File

@@ -18,7 +18,7 @@ export class VaultListFiltersComponent {
protected organizations$ = this.vaultPopupListFiltersService.organizations$;
protected collections$ = this.vaultPopupListFiltersService.collections$;
protected folders$ = this.vaultPopupListFiltersService.folders$;
protected cipherTypes = this.vaultPopupListFiltersService.cipherTypes;
protected cipherTypes$ = this.vaultPopupListFiltersService.cipherTypes$;
// Combine all filters into a single observable to eliminate the filters from loading separately in the UI.
protected allFilters$ = combineLatest([

View File

@@ -20,6 +20,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { RestrictedCipherType, RestrictedItemTypesService } from "@bitwarden/vault";
import {
CachedFilterState,
@@ -70,6 +71,10 @@ describe("VaultPopupListFiltersService", () => {
const state$ = new BehaviorSubject<boolean>(false);
const update = jest.fn().mockResolvedValue(undefined);
const restrictedItemTypesService = {
restricted$: new BehaviorSubject<RestrictedCipherType[]>([]),
};
beforeEach(() => {
_memberOrganizations$ = new BehaviorSubject<Organization[]>([]); // Fresh instance per test
folderViews$ = new BehaviorSubject([]); // Fresh instance per test
@@ -125,21 +130,46 @@ describe("VaultPopupListFiltersService", () => {
provide: ViewCacheService,
useValue: viewCacheService,
},
{
provide: RestrictedItemTypesService,
useValue: restrictedItemTypesService,
},
],
});
service = TestBed.inject(VaultPopupListFiltersService);
});
describe("cipherTypes", () => {
it("returns all cipher types", () => {
expect(service.cipherTypes.map((c) => c.value)).toEqual([
CipherType.Login,
CipherType.Card,
CipherType.Identity,
CipherType.SecureNote,
CipherType.SshKey,
describe("cipherTypes$", () => {
it("returns all cipher types when no restrictions", (done) => {
restrictedItemTypesService.restricted$.next([]);
service.cipherTypes$.subscribe((cipherTypes) => {
expect(cipherTypes.map((c) => c.value)).toEqual([
CipherType.Login,
CipherType.Card,
CipherType.Identity,
CipherType.SecureNote,
CipherType.SshKey,
]);
done();
});
});
it("filters out restricted cipher types", (done) => {
restrictedItemTypesService.restricted$.next([
{ cipherType: CipherType.Card, allowViewOrgIds: [] },
]);
service.cipherTypes$.subscribe((cipherTypes) => {
expect(cipherTypes.map((c) => c.value)).toEqual([
CipherType.Login,
CipherType.Identity,
CipherType.SecureNote,
CipherType.SshKey,
]);
done();
});
});
});
@@ -452,6 +482,10 @@ describe("VaultPopupListFiltersService", () => {
{ type: CipherType.SecureNote, collectionIds: [], organizationId: null },
] as CipherView[];
beforeEach(() => {
restrictedItemTypesService.restricted$.next([]);
});
it("filters by cipherType", (done) => {
service.filterFunction$.subscribe((filterFunction) => {
expect(filterFunction(ciphers)).toEqual([ciphers[0]]);
@@ -690,6 +724,9 @@ function createSeededVaultPopupListFiltersService(
} as any;
const accountServiceMock = mockAccountServiceWith("userId" as UserId);
const restrictedItemTypesServiceMock = {
restricted$: new BehaviorSubject<RestrictedCipherType[]>([]),
} as any;
const formBuilderInstance = new FormBuilder();
const seededCachedSignal = createMockSignal<CachedFilterState>(cachedState);
@@ -713,6 +750,7 @@ function createSeededVaultPopupListFiltersService(
stateProviderMock,
accountServiceMock,
viewCacheServiceMock,
restrictedItemTypesServiceMock,
);
});

View File

@@ -39,7 +39,9 @@ import { ITreeNodeObject, TreeNode } from "@bitwarden/common/vault/models/domain
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
import { ChipSelectOption } from "@bitwarden/components";
import { RestrictedItemTypesService } from "@bitwarden/vault";
const FILTER_VISIBILITY_KEY = new KeyDefinition<boolean>(VAULT_SETTINGS_DISK, "filterVisibility", {
deserializer: (obj) => obj,
@@ -178,6 +180,7 @@ export class VaultPopupListFiltersService {
private stateProvider: StateProvider,
private accountService: AccountService,
private viewCacheService: ViewCacheService,
private restrictedItemTypesService: RestrictedItemTypesService,
) {
this.filterForm.controls.organization.valueChanges
.pipe(takeUntilDestroyed())
@@ -210,74 +213,80 @@ export class VaultPopupListFiltersService {
/**
* Observable whose value is a function that filters an array of `CipherView` objects based on the current filters
*/
filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = this.filters$.pipe(
filterFunction$: Observable<(ciphers: CipherView[]) => CipherView[]> = combineLatest([
this.filters$,
this.restrictedItemTypesService.restricted$.pipe(startWith([])),
]).pipe(
map(
(filters) => (ciphers: CipherView[]) =>
ciphers.filter((cipher) => {
// Vault popup lists never shows deleted ciphers
if (cipher.isDeleted) {
return false;
}
if (filters.cipherType !== null && cipher.type !== filters.cipherType) {
return false;
}
if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id)) {
return false;
}
if (filters.folder && cipher.folderId !== filters.folder.id) {
return false;
}
const isMyVault = filters.organization?.id === MY_VAULT_ID;
if (isMyVault) {
if (cipher.organizationId !== null) {
([filters, restrictions]) =>
(ciphers: CipherView[]) =>
ciphers.filter((cipher) => {
// Vault popup lists never shows deleted ciphers
if (cipher.isDeleted) {
return false;
}
} else if (filters.organization) {
if (cipher.organizationId !== filters.organization.id) {
// Check if cipher type is restricted (with organization exemptions)
if (restrictions && restrictions.length > 0) {
const isRestricted = restrictions.some(
(restrictedType) =>
restrictedType.cipherType === cipher.type &&
(cipher.organizationId
? !restrictedType.allowViewOrgIds.includes(cipher.organizationId)
: restrictedType.allowViewOrgIds.length === 0),
);
if (isRestricted) {
return false;
}
}
if (filters.cipherType !== null && cipher.type !== filters.cipherType) {
return false;
}
}
return true;
}),
if (filters.collection && !cipher.collectionIds?.includes(filters.collection.id)) {
return false;
}
if (filters.folder && cipher.folderId !== filters.folder.id) {
return false;
}
const isMyVault = filters.organization?.id === MY_VAULT_ID;
if (isMyVault) {
if (cipher.organizationId !== null) {
return false;
}
} else if (filters.organization) {
if (cipher.organizationId !== filters.organization.id) {
return false;
}
}
return true;
}),
),
);
/**
* All available cipher types
* All available cipher types (filtered by policy restrictions)
*/
readonly cipherTypes: ChipSelectOption<CipherType>[] = [
{
value: CipherType.Login,
label: this.i18nService.t("typeLogin"),
icon: "bwi-globe",
},
{
value: CipherType.Card,
label: this.i18nService.t("typeCard"),
icon: "bwi-credit-card",
},
{
value: CipherType.Identity,
label: this.i18nService.t("typeIdentity"),
icon: "bwi-id-card",
},
{
value: CipherType.SecureNote,
label: this.i18nService.t("note"),
icon: "bwi-sticky-note",
},
{
value: CipherType.SshKey,
label: this.i18nService.t("typeSshKey"),
icon: "bwi-key",
},
];
readonly cipherTypes$: Observable<ChipSelectOption<CipherType>[]> =
this.restrictedItemTypesService.restricted$.pipe(
map((restrictedTypes) => {
const restrictedCipherTypes = restrictedTypes.map((r) => r.cipherType);
return CIPHER_MENU_ITEMS.filter((item) => !restrictedCipherTypes.includes(item.type)).map(
(item) => ({
value: item.type,
label: this.i18nService.t(item.labelKey),
icon: item.icon,
}),
);
}),
);
/** Resets `filterForm` to the original state */
resetFilterForm(): void {

View File

@@ -0,0 +1,24 @@
import { CipherType } from "../enums";
/**
* Represents a menu item for creating a new cipher of a specific type
*/
export type CipherMenuItem = {
/** The cipher type this menu item represents */
type: CipherType;
/** The icon class name (e.g., "bwi-globe") */
icon: string;
/** The i18n key for the label text */
labelKey: string;
};
/**
* All available cipher menu items with their associated icons and labels
*/
export const CIPHER_MENU_ITEMS = Object.freeze([
{ type: CipherType.Login, icon: "bwi-globe", labelKey: "typeLogin" },
{ type: CipherType.Card, icon: "bwi-credit-card", labelKey: "typeCard" },
{ type: CipherType.Identity, icon: "bwi-id-card", labelKey: "typeIdentity" },
{ type: CipherType.SecureNote, icon: "bwi-sticky-note", labelKey: "note" },
{ type: CipherType.SshKey, icon: "bwi-key", labelKey: "typeSshKey" },
] as const) satisfies readonly CipherMenuItem[];