diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.component.html b/apps/web/src/app/organizations/components/access-selector/access-selector.component.html
new file mode 100644
index 00000000000..10390b3e053
--- /dev/null
+++ b/apps/web/src/app/organizations/components/access-selector/access-selector.component.html
@@ -0,0 +1,136 @@
+
+
+ {{ "permission" | i18n }}
+
+
+
+
+ {{ selectorLabelText }}
+
+ {{ selectorHelpText }}
+
+
+
+
+
+
+ | {{ columnHeader }} |
+
+ {{ "permission" | i18n }}
+ |
+ {{ "role" | i18n }} |
+ {{ "group" | i18n }} |
+ |
+
+
+
+
+
+
+
+
+
+ {{ item.labelName }}
+
+ {{ "invited" | i18n }}
+
+
+ {{ item.email }}
+
+
+
+
+
+ {{ item.labelName }}
+
+ |
+
+
+
+
+
+
+
+
+
+ {{ "canEdit" | i18n }}
+
+
+
+
+ {{ permissionLabelId(item.readonlyPermission) | i18n }}
+
+
+ |
+
+
+ {{ item.role | userType: "-" }}
+ |
+
+
+ {{ item.viaGroupName ?? "-" }}
+ |
+
+
+
+ |
+
+
+ | {{ emptySelectionText }} |
+
+
+
diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.component.spec.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.component.spec.ts
new file mode 100644
index 00000000000..3b2ba911aac
--- /dev/null
+++ b/apps/web/src/app/organizations/components/access-selector/access-selector.component.spec.ts
@@ -0,0 +1,250 @@
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { FormsModule, ReactiveFormsModule } from "@angular/forms";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
+import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
+import {
+ AvatarModule,
+ BadgeModule,
+ ButtonModule,
+ FormFieldModule,
+ IconButtonModule,
+ TableModule,
+ TabsModule,
+} from "@bitwarden/components";
+import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
+
+import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
+
+import { AccessSelectorComponent, PermissionMode } from "./access-selector.component";
+import { AccessItemType, CollectionPermission } from "./access-selector.models";
+import { UserTypePipe } from "./user-type.pipe";
+
+/**
+ * Helper class that makes it easier to test the AccessSelectorComponent by
+ * exposing some protected methods/properties
+ */
+class TestableAccessSelectorComponent extends AccessSelectorComponent {
+ selectItems(items: SelectItemView[]) {
+ super.selectItems(items);
+ }
+ deselectItem(id: string) {
+ this.selectionList.deselectItem(id);
+ }
+
+ /**
+ * Helper used to simulate a user selecting a new permission for a table row
+ * @param index - "Row" index
+ * @param perm - The new permission value
+ */
+ changeSelectedItemPerm(index: number, perm: CollectionPermission) {
+ this.selectionList.formArray.at(index).patchValue({
+ permission: perm,
+ });
+ }
+}
+
+describe("AccessSelectorComponent", () => {
+ let component: TestableAccessSelectorComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ ButtonModule,
+ FormFieldModule,
+ AvatarModule,
+ BadgeModule,
+ ReactiveFormsModule,
+ FormsModule,
+ TabsModule,
+ TableModule,
+ PreloadedEnglishI18nModule,
+ JslibModule,
+ IconButtonModule,
+ ],
+ declarations: [TestableAccessSelectorComponent, UserTypePipe],
+ providers: [],
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(TestableAccessSelectorComponent);
+ component = fixture.componentInstance;
+
+ component.emptySelectionText = "Nothing selected";
+
+ fixture.detectChanges();
+ });
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe("item selection", () => {
+ beforeEach(() => {
+ component.items = [
+ {
+ id: "123",
+ type: AccessItemType.Group,
+ labelName: "Group 1",
+ listName: "Group 1",
+ },
+ ];
+ fixture.detectChanges();
+ });
+
+ it("should show the empty row when nothing is selected", () => {
+ const emptyTableCell = fixture.nativeElement.querySelector("tbody tr td");
+ expect(emptyTableCell?.textContent).toEqual("Nothing selected");
+ });
+
+ it("should show one row when one value is selected", () => {
+ component.selectItems([{ id: "123" } as any]);
+ fixture.detectChanges();
+ const firstColSpan = fixture.nativeElement.querySelector("tbody tr td span");
+ expect(firstColSpan.textContent).toEqual("Group 1");
+ });
+
+ it("should emit value change when a value is selected", () => {
+ // Arrange
+ const mockChange = jest.fn();
+ component.registerOnChange(mockChange);
+ component.permissionMode = PermissionMode.Edit;
+
+ // Act
+ component.selectItems([{ id: "123" } as any]);
+
+ // Assert
+ expect(mockChange.mock.calls.length).toEqual(1);
+ expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
+ });
+
+ it("should emit value change when a row is modified", () => {
+ // Arrange
+ const mockChange = jest.fn();
+ component.permissionMode = PermissionMode.Edit;
+ component.selectItems([{ id: "123" } as any]);
+ component.registerOnChange(mockChange); // Register change listener after setup
+
+ // Act
+ component.changeSelectedItemPerm(0, CollectionPermission.Edit);
+
+ // Assert
+ expect(mockChange.mock.calls.length).toEqual(1);
+ expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
+ expect(mockChange.mock.lastCall[0]).toHaveProperty(
+ "[0].permission",
+ CollectionPermission.Edit
+ );
+ });
+
+ it("should emit value change when a row is removed", () => {
+ // Arrange
+ const mockChange = jest.fn();
+ component.permissionMode = PermissionMode.Edit;
+ component.selectItems([{ id: "123" } as any]);
+ component.registerOnChange(mockChange); // Register change listener after setup
+
+ // Act
+ component.deselectItem("123");
+
+ // Assert
+ expect(mockChange.mock.calls.length).toEqual(1);
+ expect(mockChange.mock.lastCall[0].length).toEqual(0);
+ });
+
+ it("should emit permission values when in edit mode", () => {
+ // Arrange
+ const mockChange = jest.fn();
+ component.registerOnChange(mockChange);
+ component.permissionMode = PermissionMode.Edit;
+
+ // Act
+ component.selectItems([{ id: "123" } as any]);
+
+ // Assert
+ expect(mockChange.mock.calls.length).toEqual(1);
+ expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
+ expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].permission");
+ });
+
+ it("should not emit permission values when not in edit mode", () => {
+ // Arrange
+ const mockChange = jest.fn();
+ component.registerOnChange(mockChange);
+ component.permissionMode = PermissionMode.Hidden;
+
+ // Act
+ component.selectItems([{ id: "123" } as any]);
+
+ // Assert
+ expect(mockChange.mock.calls.length).toEqual(1);
+ expect(mockChange.mock.lastCall[0]).toHaveProperty("[0].id", "123");
+ expect(mockChange.mock.lastCall[0]).not.toHaveProperty("[0].permission");
+ });
+ });
+
+ describe("column rendering", () => {
+ beforeEach(() => {
+ component.items = [
+ {
+ id: "g1",
+ type: AccessItemType.Group,
+ labelName: "Group 1",
+ listName: "Group 1",
+ },
+ {
+ id: "m1",
+ type: AccessItemType.Member,
+ labelName: "Member 1",
+ listName: "Member 1 (member1@email.com)",
+ email: "member1@email.com",
+ role: OrganizationUserType.Manager,
+ status: OrganizationUserStatusType.Confirmed,
+ },
+ ];
+ fixture.detectChanges();
+ });
+
+ test.each([true, false])("should show the role column when enabled", (columnEnabled) => {
+ // Act
+ component.showMemberRoles = columnEnabled;
+ fixture.detectChanges();
+
+ // Assert
+ const colHeading = fixture.nativeElement.querySelector("#roleColHeading");
+ expect(!!colHeading).toEqual(columnEnabled);
+ });
+
+ test.each([true, false])("should show the group column when enabled", (columnEnabled) => {
+ // Act
+ component.showGroupColumn = columnEnabled;
+ fixture.detectChanges();
+
+ // Assert
+ const colHeading = fixture.nativeElement.querySelector("#groupColHeading");
+ expect(!!colHeading).toEqual(columnEnabled);
+ });
+
+ const permissionColumnCases = [
+ [PermissionMode.Hidden, false],
+ [PermissionMode.Edit, true],
+ [PermissionMode.Readonly, true],
+ ];
+
+ test.each(permissionColumnCases)(
+ "should show the permission column when enabled",
+ (mode: PermissionMode, shouldShowColumn) => {
+ // Act
+ component.permissionMode = mode;
+ fixture.detectChanges();
+
+ // Assert
+ const colHeading = fixture.nativeElement.querySelector("#permissionColHeading");
+ expect(!!colHeading).toEqual(shouldShowColumn);
+ }
+ );
+ });
+});
diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.component.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.component.ts
new file mode 100644
index 00000000000..98a49d5c3ab
--- /dev/null
+++ b/apps/web/src/app/organizations/components/access-selector/access-selector.component.ts
@@ -0,0 +1,290 @@
+import { Component, forwardRef, Input, OnDestroy, OnInit } from "@angular/core";
+import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from "@angular/forms";
+import { Subject, takeUntil } from "rxjs";
+
+import { FormSelectionList } from "@bitwarden/angular/utils/form-selection-list";
+import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
+import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
+
+import {
+ AccessItemType,
+ AccessItemValue,
+ AccessItemView,
+ CollectionPermission,
+} from "./access-selector.models";
+
+export enum PermissionMode {
+ /**
+ * No permission controls or column present. No permission values are emitted.
+ */
+ Hidden = "hidden",
+
+ /**
+ * No permission controls. Column rendered an if available on an item. No permission values are emitted
+ */
+ Readonly = "readonly",
+
+ /**
+ * Permission Controls and column present. Permission values are emitted.
+ */
+ Edit = "edit",
+}
+
+@Component({
+ selector: "bit-access-selector",
+ templateUrl: "access-selector.component.html",
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => AccessSelectorComponent),
+ multi: true,
+ },
+ ],
+})
+export class AccessSelectorComponent implements ControlValueAccessor, OnInit, OnDestroy {
+ private destroy$ = new Subject();
+ private notifyOnChange: (v: unknown) => void;
+ private notifyOnTouch: () => void;
+ private pauseChangeNotification: boolean;
+
+ /**
+ * The internal selection list that tracks the value of this form control / component.
+ * It's responsible for keeping items sorted and synced with the rendered form controls
+ * @protected
+ */
+ protected selectionList = new FormSelectionList((item) => {
+ const permissionControl = this.formBuilder.control(this.initialPermission);
+
+ const fg = this.formBuilder.group({
+ id: item.id,
+ type: item.type,
+ permission: permissionControl,
+ });
+
+ // Disable entire row form group if readonly
+ if (item.readonly) {
+ fg.disable();
+ }
+
+ // Disable permission control if accessAllItems is enabled
+ if (item.accessAllItems || this.permissionMode != PermissionMode.Edit) {
+ permissionControl.disable();
+ }
+
+ return fg;
+ }, this._itemComparator.bind(this));
+
+ /**
+ * Internal form group for this component.
+ * @protected
+ */
+ protected formGroup = this.formBuilder.group({
+ items: this.selectionList.formArray,
+ });
+
+ protected itemType = AccessItemType;
+ protected permissionList = [
+ { perm: CollectionPermission.View, labelId: "canView" },
+ { perm: CollectionPermission.ViewExceptPass, labelId: "canViewExceptPass" },
+ { perm: CollectionPermission.Edit, labelId: "canEdit" },
+ { perm: CollectionPermission.EditExceptPass, labelId: "canEditExceptPass" },
+ ];
+ protected initialPermission = CollectionPermission.View;
+
+ disabled: boolean;
+
+ /**
+ * List of all selectable items that. Sorted internally.
+ */
+ @Input()
+ get items(): AccessItemView[] {
+ return this.selectionList.allItems;
+ }
+
+ set items(val: AccessItemView[]) {
+ const selected = (this.selectionList.formArray.getRawValue() ?? []).concat(
+ val.filter((m) => m.readonly)
+ );
+ this.selectionList.populateItems(
+ val.map((m) => {
+ m.icon = m.icon ?? this.itemIcon(m); // Ensure an icon is set
+ return m;
+ }),
+ selected
+ );
+ }
+
+ /**
+ * Permission mode that controls if the permission form controls and column should be present.
+ */
+ @Input()
+ get permissionMode(): PermissionMode {
+ return this._permissionMode;
+ }
+
+ set permissionMode(value: PermissionMode) {
+ this._permissionMode = value;
+ // Toggle any internal permission controls
+ for (const control of this.selectionList.formArray.controls) {
+ if (value == PermissionMode.Edit) {
+ control.get("permission").enable();
+ } else {
+ control.get("permission").disable();
+ }
+ }
+ }
+ private _permissionMode: PermissionMode = PermissionMode.Hidden;
+
+ /**
+ * Column header for the selected items table
+ */
+ @Input() columnHeader: string;
+
+ /**
+ * Label used for the ng selector
+ */
+ @Input() selectorLabelText: string;
+
+ /**
+ * Helper text displayed under the ng selector
+ */
+ @Input() selectorHelpText: string;
+
+ /**
+ * Text that is shown in the table when no items are selected
+ */
+ @Input() emptySelectionText: string;
+
+ /**
+ * Flag for if the member roles column should be present
+ */
+ @Input() showMemberRoles: boolean;
+
+ /**
+ * Flag for if the group column should be present
+ */
+ @Input() showGroupColumn: boolean;
+
+ constructor(
+ private readonly formBuilder: FormBuilder,
+ private readonly i18nService: I18nService
+ ) {}
+
+ /** Required for NG_VALUE_ACCESSOR */
+ registerOnChange(fn: any): void {
+ this.notifyOnChange = fn;
+ }
+
+ /** Required for NG_VALUE_ACCESSOR */
+ registerOnTouched(fn: any): void {
+ this.notifyOnTouch = fn;
+ }
+
+ /** Required for NG_VALUE_ACCESSOR */
+ setDisabledState(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+
+ // Keep the internal FormGroup in sync
+ if (this.disabled) {
+ this.formGroup.disable();
+ } else {
+ this.formGroup.enable();
+ }
+ }
+
+ /** Required for NG_VALUE_ACCESSOR */
+ writeValue(selectedItems: AccessItemValue[]): void {
+ // Modifying the selection list, mistakenly fires valueChanges in the
+ // internal form array, so we need to know to pause external notification
+ this.pauseChangeNotification = true;
+
+ // Always clear the internal selection list on a new value
+ this.selectionList.deselectAll();
+
+ // We need to also select any read only items to appear in the table
+ this.selectionList.selectItems(this.items.filter((m) => m.readonly).map((m) => m.id));
+
+ // If the new value is null, then we're done
+ if (selectedItems == null) {
+ this.pauseChangeNotification = false;
+ return;
+ }
+
+ // Unable to handle other value types, throw
+ if (!Array.isArray(selectedItems)) {
+ throw new Error("The access selector component only supports Array form values!");
+ }
+
+ // Iterate and internally select each item
+ for (const value of selectedItems) {
+ this.selectionList.selectItem(value.id, value);
+ }
+
+ this.pauseChangeNotification = false;
+ }
+
+ ngOnInit() {
+ // Watch the internal formArray for changes and propagate them
+ this.selectionList.formArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((v) => {
+ if (!this.notifyOnChange || this.pauseChangeNotification) {
+ return;
+ }
+ this.notifyOnChange(v);
+ });
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ protected handleBlur() {
+ if (!this.notifyOnTouch) {
+ return;
+ }
+
+ this.notifyOnTouch();
+ }
+
+ protected selectItems(items: SelectItemView[]) {
+ this.pauseChangeNotification = true;
+ this.selectionList.selectItems(items.map((i) => i.id));
+ this.pauseChangeNotification = false;
+ if (this.notifyOnChange != undefined) {
+ this.notifyOnChange(this.selectionList.formArray.value);
+ }
+ }
+
+ protected itemIcon(item: AccessItemView) {
+ switch (item.type) {
+ case AccessItemType.Collection:
+ return "bwi-collection";
+ case AccessItemType.Group:
+ return "bwi-users";
+ case AccessItemType.Member:
+ return "bwi-user";
+ }
+ }
+
+ protected permissionLabelId(perm: CollectionPermission) {
+ return this.permissionList.find((p) => p.perm == perm)?.labelId;
+ }
+
+ protected accessAllLabelId(item: AccessItemView) {
+ return item.type == AccessItemType.Group ? "groupAccessAll" : "memberAccessAll";
+ }
+
+ protected canEditItemPermission(item: AccessItemView) {
+ return this.permissionMode == PermissionMode.Edit && !item.readonly && !item.accessAllItems;
+ }
+
+ private _itemComparator(a: AccessItemView, b: AccessItemView) {
+ if (a.type != b.type) {
+ return a.type - b.type;
+ }
+ return this.i18nService.collator.compare(
+ a.listName + a.labelName + a.readonly,
+ b.listName + b.labelName + b.readonly
+ );
+ }
+}
diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.models.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.models.ts
new file mode 100644
index 00000000000..5dfbacebe13
--- /dev/null
+++ b/apps/web/src/app/organizations/components/access-selector/access-selector.models.ts
@@ -0,0 +1,107 @@
+import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
+import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
+import { SelectionReadOnlyRequest } from "@bitwarden/common/models/request/selectionReadOnlyRequest";
+import { SelectionReadOnlyResponse } from "@bitwarden/common/models/response/selectionReadOnlyResponse";
+import { SelectItemView } from "@bitwarden/components/src/multi-select/models/select-item-view";
+
+/**
+ * Permission options that replace/correspond with readOnly and hidePassword server fields.
+ */
+export enum CollectionPermission {
+ View = "view",
+ ViewExceptPass = "viewExceptPass",
+ Edit = "edit",
+ EditExceptPass = "editExceptPass",
+}
+
+export enum AccessItemType {
+ Collection,
+ Group,
+ Member,
+}
+
+/**
+ * A "generic" type that describes an item that can be selected from a
+ * ng-select list and have its collection permission modified.
+ *
+ * Currently, it supports Collections, Groups, and Members. Members require some additional
+ * details to render in the AccessSelectorComponent so their type is defined separately
+ * and then joined back with the base type.
+ *
+ */
+export type AccessItemView =
+ | SelectItemView & {
+ /**
+ * Flag that this group/member can access all items.
+ * This will disable the permission editor for this item.
+ */
+ accessAllItems?: boolean;
+
+ /**
+ * Flag that this item cannot be modified.
+ * This will disable the permission editor and will keep
+ * the item always selected.
+ */
+ readonly?: boolean;
+
+ /**
+ * Optional permission that will be rendered for this
+ * item if it set to readonly.
+ */
+ readonlyPermission?: CollectionPermission;
+ } & (
+ | {
+ type: AccessItemType.Collection;
+ viaGroupName?: string;
+ }
+ | {
+ type: AccessItemType.Group;
+ }
+ | {
+ type: AccessItemType.Member; // Members have a few extra details required to display, so they're added here
+ email: string;
+ role: OrganizationUserType;
+ status: OrganizationUserStatusType;
+ }
+ );
+
+/**
+ * A type that is emitted as a value for the ngControl
+ */
+export type AccessItemValue = {
+ id: string;
+ permission?: CollectionPermission;
+ type: AccessItemType;
+};
+
+/**
+ * Converts the older SelectionReadOnly interface to one of the new CollectionPermission values
+ * for the dropdown in the AccessSelectorComponent
+ * @param value
+ */
+export const convertToPermission = (value: SelectionReadOnlyResponse) => {
+ if (value.readOnly) {
+ return value.hidePasswords ? CollectionPermission.ViewExceptPass : CollectionPermission.View;
+ } else {
+ return value.hidePasswords ? CollectionPermission.EditExceptPass : CollectionPermission.Edit;
+ }
+};
+
+/**
+ * Converts an AccessItemValue back into a SelectionReadOnly class using the CollectionPermission
+ * to determine the values for `readOnly` and `hidePassword`
+ * @param value
+ */
+export const convertToSelectionReadOnly = (value: AccessItemValue) => {
+ return new SelectionReadOnlyRequest(
+ value.id,
+ readOnly(value.permission),
+ hidePassword(value.permission)
+ );
+};
+
+const readOnly = (perm: CollectionPermission) =>
+ [CollectionPermission.View, CollectionPermission.ViewExceptPass].includes(perm);
+
+const hidePassword = (perm: CollectionPermission) =>
+ [CollectionPermission.ViewExceptPass, CollectionPermission.EditExceptPass].includes(perm);
diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.module.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.module.ts
new file mode 100644
index 00000000000..cbb01137b4d
--- /dev/null
+++ b/apps/web/src/app/organizations/components/access-selector/access-selector.module.ts
@@ -0,0 +1,13 @@
+import { NgModule } from "@angular/core";
+
+import { SharedModule } from "../../../shared";
+
+import { AccessSelectorComponent } from "./access-selector.component";
+import { UserTypePipe } from "./user-type.pipe";
+
+@NgModule({
+ imports: [SharedModule],
+ declarations: [AccessSelectorComponent, UserTypePipe],
+ exports: [AccessSelectorComponent],
+})
+export class AccessSelectorModule {}
diff --git a/apps/web/src/app/organizations/components/access-selector/access-selector.stories.ts b/apps/web/src/app/organizations/components/access-selector/access-selector.stories.ts
new file mode 100644
index 00000000000..059fb1c430c
--- /dev/null
+++ b/apps/web/src/app/organizations/components/access-selector/access-selector.stories.ts
@@ -0,0 +1,302 @@
+import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
+import { action } from "@storybook/addon-actions";
+import { Meta, moduleMetadata, Story } from "@storybook/angular";
+
+import { JslibModule } from "@bitwarden/angular/jslib.module";
+import { OrganizationUserStatusType } from "@bitwarden/common/enums/organizationUserStatusType";
+import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
+import {
+ AvatarModule,
+ BadgeModule,
+ ButtonModule,
+ FormFieldModule,
+ IconButtonModule,
+ TableModule,
+ TabsModule,
+} from "@bitwarden/components";
+
+import { PreloadedEnglishI18nModule } from "../../../tests/preloaded-english-i18n.module";
+
+import { AccessSelectorComponent } from "./access-selector.component";
+import { AccessItemType, AccessItemView, CollectionPermission } from "./access-selector.models";
+import { UserTypePipe } from "./user-type.pipe";
+
+export default {
+ title: "Web/Organizations/Access Selector",
+ decorators: [
+ moduleMetadata({
+ declarations: [AccessSelectorComponent, UserTypePipe],
+ imports: [
+ ButtonModule,
+ FormFieldModule,
+ AvatarModule,
+ BadgeModule,
+ ReactiveFormsModule,
+ FormsModule,
+ TabsModule,
+ TableModule,
+ PreloadedEnglishI18nModule,
+ JslibModule,
+ IconButtonModule,
+ ],
+ providers: [],
+ }),
+ ],
+ parameters: {},
+ argTypes: {
+ formObj: { table: { disable: true } },
+ },
+} as Meta;
+
+const actionsData = {
+ onValueChanged: action("onValueChanged"),
+ onSubmit: action("onSubmit"),
+};
+
+/**
+ * Factory to help build semi-realistic looking items
+ * @param n - The number of items to build
+ * @param type - Which type to build
+ */
+const itemsFactory = (n: number, type: AccessItemType) => {
+ return [...Array(n)].map((_: unknown, id: number) => {
+ const item: AccessItemView = {
+ id: id.toString(),
+ type: type,
+ } as AccessItemView;
+
+ switch (item.type) {
+ case AccessItemType.Collection:
+ item.labelName = item.listName = `Collection ${id}`;
+ item.id = item.id + "c";
+ item.parentGrouping = "Collection Parent Group " + ((id % 2) + 1);
+ break;
+ case AccessItemType.Group:
+ item.labelName = item.listName = `Group ${id}`;
+ item.id = item.id + "g";
+ break;
+ case AccessItemType.Member:
+ item.id = item.id + "m";
+ item.email = `member${id}@email.com`;
+ item.status = id % 3 == 0 ? 0 : 2;
+ item.labelName = item.status == 2 ? `Member ${id}` : item.email;
+ item.listName = item.status == 2 ? `${item.labelName} (${item.email})` : item.email;
+ item.role = id % 5;
+ break;
+ }
+
+ return item;
+ });
+};
+
+const sampleMembers = itemsFactory(10, AccessItemType.Member);
+const sampleGroups = itemsFactory(6, AccessItemType.Group);
+
+const StandaloneAccessSelectorTemplate: Story = (
+ args: AccessSelectorComponent
+) => ({
+ props: {
+ items: [],
+ valueChanged: actionsData.onValueChanged,
+ initialValue: [],
+ ...args,
+ },
+ template: `
+
+`,
+});
+
+const memberCollectionAccessItems = itemsFactory(3, AccessItemType.Collection).concat([
+ {
+ id: "c1-group1",
+ type: AccessItemType.Collection,
+ labelName: "Collection 1",
+ listName: "Collection 1",
+ viaGroupName: "Group 1",
+ readonlyPermission: CollectionPermission.View,
+ readonly: true,
+ },
+ {
+ id: "c1-group2",
+ type: AccessItemType.Collection,
+ labelName: "Collection 1",
+ listName: "Collection 1",
+ viaGroupName: "Group 2",
+ readonlyPermission: CollectionPermission.ViewExceptPass,
+ readonly: true,
+ },
+]);
+
+export const MemberCollectionAccess = StandaloneAccessSelectorTemplate.bind({});
+MemberCollectionAccess.args = {
+ permissionMode: "edit",
+ showMemberRoles: false,
+ showGroupColumn: true,
+ columnHeader: "Collection",
+ selectorLabelText: "Select Collections",
+ selectorHelpText: "Some helper text describing what this does",
+ emptySelectionText: "No collections added",
+ disabled: false,
+ initialValue: [],
+ items: memberCollectionAccessItems,
+};
+MemberCollectionAccess.story = {
+ parameters: {
+ docs: {
+ storyDescription: `
+ Example of an access selector for modifying the collections a member has access to.
+ Includes examples of a readonly group and member that cannot be edited.
+ `,
+ },
+ },
+};
+
+export const MemberGroupAccess = StandaloneAccessSelectorTemplate.bind({});
+MemberGroupAccess.args = {
+ permissionMode: "readonly",
+ showMemberRoles: false,
+ columnHeader: "Groups",
+ selectorLabelText: "Select Groups",
+ selectorHelpText: "Some helper text describing what this does",
+ emptySelectionText: "No groups added",
+ disabled: false,
+ initialValue: [{ id: "3g" }, { id: "0g" }],
+ items: itemsFactory(4, AccessItemType.Group).concat([
+ {
+ id: "admin",
+ type: AccessItemType.Group,
+ listName: "Admin Group",
+ labelName: "Admin Group",
+ accessAllItems: true,
+ },
+ ]),
+};
+MemberGroupAccess.story = {
+ parameters: {
+ docs: {
+ storyDescription: `
+ Example of an access selector for selecting which groups an individual member belongs too.
+ `,
+ },
+ },
+};
+
+export const GroupMembersAccess = StandaloneAccessSelectorTemplate.bind({});
+GroupMembersAccess.args = {
+ permissionMode: "hidden",
+ showMemberRoles: true,
+ columnHeader: "Members",
+ selectorLabelText: "Select Members",
+ selectorHelpText: "Some helper text describing what this does",
+ emptySelectionText: "No members added",
+ disabled: false,
+ initialValue: [{ id: "2m" }, { id: "0m" }],
+ items: sampleMembers,
+};
+GroupMembersAccess.story = {
+ parameters: {
+ docs: {
+ storyDescription: `
+ Example of an access selector for selecting which members belong to an specific group.
+ `,
+ },
+ },
+};
+
+export const CollectionAccess = StandaloneAccessSelectorTemplate.bind({});
+CollectionAccess.args = {
+ permissionMode: "edit",
+ showMemberRoles: false,
+ columnHeader: "Groups/Members",
+ selectorLabelText: "Select groups and members",
+ selectorHelpText:
+ "Permissions set for a member will replace permissions set by that member's group",
+ emptySelectionText: "No members or groups added",
+ disabled: false,
+ initialValue: [
+ { id: "3g", permission: CollectionPermission.EditExceptPass },
+ { id: "0m", permission: CollectionPermission.View },
+ ],
+ items: sampleGroups.concat(sampleMembers).concat([
+ {
+ id: "admin-group",
+ type: AccessItemType.Group,
+ listName: "Admin Group",
+ labelName: "Admin Group",
+ accessAllItems: true,
+ readonly: true,
+ },
+ {
+ id: "admin-member",
+ type: AccessItemType.Member,
+ listName: "Admin Member (admin@email.com)",
+ labelName: "Admin Member",
+ status: OrganizationUserStatusType.Confirmed,
+ role: OrganizationUserType.Admin,
+ email: "admin@email.com",
+ accessAllItems: true,
+ readonly: true,
+ },
+ ]),
+};
+GroupMembersAccess.story = {
+ parameters: {
+ docs: {
+ storyDescription: `
+ Example of an access selector for selecting which members/groups have access to a specific collection.
+ `,
+ },
+ },
+};
+
+const fb = new FormBuilder();
+
+const ReactiveFormAccessSelectorTemplate: Story = (
+ args: AccessSelectorComponent
+) => ({
+ props: {
+ items: [],
+ onSubmit: actionsData.onSubmit,
+ ...args,
+ },
+ template: `
+
+`,
+});
+
+export const ReactiveForm = ReactiveFormAccessSelectorTemplate.bind({});
+ReactiveForm.args = {
+ formObj: fb.group({ formItems: [[{ id: "1g" }]] }),
+ permissionMode: "edit",
+ showMemberRoles: false,
+ columnHeader: "Groups/Members",
+ selectorLabelText: "Select groups and members",
+ selectorHelpText:
+ "Permissions set for a member will replace permissions set by that member's group",
+ emptySelectionText: "No members or groups added",
+ items: sampleGroups.concat(sampleMembers),
+};
diff --git a/apps/web/src/app/organizations/components/access-selector/index.ts b/apps/web/src/app/organizations/components/access-selector/index.ts
new file mode 100644
index 00000000000..86624f8e941
--- /dev/null
+++ b/apps/web/src/app/organizations/components/access-selector/index.ts
@@ -0,0 +1,3 @@
+export * from "./access-selector.component";
+export * from "./access-selector.module";
+export * from "./access-selector.models";
diff --git a/apps/web/src/app/organizations/components/access-selector/user-type.pipe.ts b/apps/web/src/app/organizations/components/access-selector/user-type.pipe.ts
new file mode 100644
index 00000000000..6ef78cb65ea
--- /dev/null
+++ b/apps/web/src/app/organizations/components/access-selector/user-type.pipe.ts
@@ -0,0 +1,29 @@
+import { Pipe, PipeTransform } from "@angular/core";
+
+import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
+import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
+
+@Pipe({
+ name: "userType",
+})
+export class UserTypePipe implements PipeTransform {
+ constructor(private i18nService: I18nService) {}
+
+ transform(value?: OrganizationUserType, unknownText?: string): string {
+ if (value == null) {
+ return unknownText ?? this.i18nService.t("unknown");
+ }
+ switch (value) {
+ case OrganizationUserType.Owner:
+ return this.i18nService.t("owner");
+ case OrganizationUserType.Admin:
+ return this.i18nService.t("admin");
+ case OrganizationUserType.User:
+ return this.i18nService.t("user");
+ case OrganizationUserType.Manager:
+ return this.i18nService.t("manager");
+ case OrganizationUserType.Custom:
+ return this.i18nService.t("custom");
+ }
+ }
+}
diff --git a/apps/web/src/app/organizations/organization.module.ts b/apps/web/src/app/organizations/organization.module.ts
new file mode 100644
index 00000000000..f32e4f53104
--- /dev/null
+++ b/apps/web/src/app/organizations/organization.module.ts
@@ -0,0 +1,11 @@
+import { NgModule } from "@angular/core";
+
+import { SharedModule } from "../shared";
+
+import { AccessSelectorModule } from "./components/access-selector";
+import { OrganizationsRoutingModule } from "./organization-routing.module";
+
+@NgModule({
+ imports: [SharedModule, AccessSelectorModule, OrganizationsRoutingModule],
+})
+export class OrganizationModule {}
diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts
index 6e1c4e569ef..f77043a777e 100644
--- a/apps/web/src/app/oss-routing.module.ts
+++ b/apps/web/src/app/oss-routing.module.ts
@@ -28,7 +28,7 @@ import { VerifyRecoverDeleteComponent } from "./accounts/verify-recover-delete.c
import { HomeGuard } from "./guards/home.guard";
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
import { UserLayoutComponent } from "./layouts/user-layout.component";
-import { OrganizationsRoutingModule } from "./organizations/organization-routing.module";
+import { OrganizationModule } from "./organizations/organization.module";
import { AcceptFamilySponsorshipComponent } from "./organizations/sponsorships/accept-family-sponsorship.component";
import { FamiliesForEnterpriseSetupComponent } from "./organizations/sponsorships/families-for-enterprise-setup.component";
import { ReportsModule } from "./reports";
@@ -251,7 +251,7 @@ const routes: Routes = [
},
{
path: "organizations",
- loadChildren: () => OrganizationsRoutingModule,
+ loadChildren: () => OrganizationModule,
},
];
diff --git a/apps/web/src/app/shared/shared.module.ts b/apps/web/src/app/shared/shared.module.ts
index 380cbcb6667..7e3d96c68d0 100644
--- a/apps/web/src/app/shared/shared.module.ts
+++ b/apps/web/src/app/shared/shared.module.ts
@@ -8,10 +8,12 @@ import { ToastrModule } from "ngx-toastr";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
+ AvatarModule,
BadgeModule,
ButtonModule,
CalloutModule,
FormFieldModule,
+ IconButtonModule,
IconModule,
AsyncActionsModule,
MenuModule,
@@ -49,6 +51,8 @@ import "./locales";
IconModule,
TabsModule,
TableModule,
+ AvatarModule,
+ IconButtonModule,
],
exports: [
CommonModule,
@@ -70,6 +74,8 @@ import "./locales";
IconModule,
TabsModule,
TableModule,
+ AvatarModule,
+ IconButtonModule,
],
providers: [DatePipe],
bootstrap: [],
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index 9ed873eae42..3d3fbd5ea5b 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -4125,6 +4125,9 @@
"permissions": {
"message": "Permissions"
},
+ "permission": {
+ "message": "Permission"
+ },
"managerPermissions": {
"message": "Manager Permissions"
},
@@ -5477,5 +5480,29 @@
},
"update": {
"message": "Update"
+ },
+ "role": {
+ "message": "Role"
+ },
+ "canView": {
+ "message": "Can view"
+ },
+ "canViewExceptPass": {
+ "message": "Can view, except passwords"
+ },
+ "canEdit": {
+ "message": "Can edit"
+ },
+ "canEditExceptPass": {
+ "message": "Can edit, except passwords"
+ },
+ "group": {
+ "message": "Group"
+ },
+ "groupAccessAll": {
+ "message": "This group can access and modify all items."
+ },
+ "memberAccessAll": {
+ "message": "This member can access and modify all items."
}
}
diff --git a/libs/angular/src/utils/form-selection-list.spec.ts b/libs/angular/src/utils/form-selection-list.spec.ts
new file mode 100644
index 00000000000..9d1a3c4e185
--- /dev/null
+++ b/libs/angular/src/utils/form-selection-list.spec.ts
@@ -0,0 +1,253 @@
+import { FormBuilder } from "@angular/forms";
+
+import { FormSelectionList, SelectionItemId } from "./form-selection-list";
+
+interface TestItemView extends SelectionItemId {
+ displayName: string;
+}
+
+interface TestItemValue extends SelectionItemId {
+ value: string;
+}
+
+const initialTestItems: TestItemView[] = [
+ { id: "1", displayName: "1st Item" },
+ { id: "2", displayName: "2nd Item" },
+ { id: "3", displayName: "3rd Item" },
+];
+const totalTestItemCount = initialTestItems.length;
+
+describe("FormSelectionList", () => {
+ let formSelectionList: FormSelectionList;
+ let testItems: TestItemView[];
+
+ const formBuilder = new FormBuilder();
+
+ const testCompareFn = (a: TestItemView, b: TestItemView) => {
+ return a.displayName.localeCompare(b.displayName);
+ };
+
+ const testControlFactory = (item: TestItemView) => {
+ return formBuilder.group({
+ id: [item.id],
+ value: [""],
+ });
+ };
+
+ beforeEach(() => {
+ formSelectionList = new FormSelectionList(
+ testControlFactory,
+ testCompareFn
+ );
+ testItems = [...initialTestItems];
+ });
+
+ it("should create with empty arrays", () => {
+ expect(formSelectionList.selectedItems.length).toEqual(0);
+ expect(formSelectionList.deselectedItems.length).toEqual(0);
+ expect(formSelectionList.formArray.length).toEqual(0);
+ });
+
+ describe("populateItems()", () => {
+ it("should have no selected items when populated without a selection", () => {
+ // Act
+ formSelectionList.populateItems(testItems, []);
+
+ // Assert
+ expect(formSelectionList.selectedItems.length).toEqual(0);
+ });
+
+ it("should have selected items when populated with a list of selected items", () => {
+ // Act
+ formSelectionList.populateItems(testItems, [{ id: "1", value: "test" }]);
+
+ // Assert
+ expect(formSelectionList.selectedItems.length).toEqual(1);
+ expect(formSelectionList.selectedItems).toHaveProperty("[0].id", "1");
+ });
+ });
+
+ describe("selectItem()", () => {
+ beforeEach(() => {
+ formSelectionList.populateItems(testItems);
+ });
+
+ it("should add item to selectedItems, remove from deselectedItems, and create a form control when called with a valid id", () => {
+ // Act
+ formSelectionList.selectItem("1");
+
+ // Assert
+ expect(formSelectionList.selectedItems.length).toEqual(1);
+ expect(formSelectionList.formArray.length).toEqual(1);
+ expect(formSelectionList.deselectedItems.length).toEqual(totalTestItemCount - 1);
+ });
+
+ it("should do nothing when called with a invalid id", () => {
+ // Act
+ formSelectionList.selectItem("bad-id");
+
+ // Assert
+ expect(formSelectionList.selectedItems.length).toEqual(0);
+ expect(formSelectionList.formArray.length).toEqual(0);
+ expect(formSelectionList.deselectedItems.length).toEqual(totalTestItemCount);
+ });
+
+ it("should create a form control with an initial value when called with an initial value and valid id", () => {
+ // Arrange
+ const testValue = "TestValue";
+ const idToSelect = "1";
+
+ // Act
+ formSelectionList.selectItem(idToSelect, { value: testValue });
+
+ // Assert
+ expect(formSelectionList.formArray.length).toEqual(1);
+ expect(formSelectionList.formArray.value).toHaveProperty("[0].id", idToSelect);
+ expect(formSelectionList.formArray.value).toHaveProperty("[0].value", testValue);
+
+ expect(formSelectionList.selectedItems.length).toEqual(1);
+ expect(formSelectionList.deselectedItems.length).toEqual(totalTestItemCount - 1);
+ });
+
+ it("should ensure the id value is set for the form control when called with a valid id", () => {
+ // Arrange
+ const testValue = "TestValue";
+ const idToSelect = "1";
+ const idOverride = "some-other-id";
+
+ // Act
+ formSelectionList.selectItem(idToSelect, { value: testValue, id: idOverride });
+
+ // Assert
+ expect(formSelectionList.formArray.value).toHaveProperty("[0].id", idOverride);
+ expect(formSelectionList.formArray.value).toHaveProperty("[0].value", testValue);
+ });
+
+ // Ensure Angular's Change Detection will pick up any modifications to the array
+ it("should create new copies of the selectedItems and deselectedItems arrays when called with a valid id", () => {
+ // Arrange
+ const initialSelected = formSelectionList.selectedItems;
+ const initialdeselected = formSelectionList.deselectedItems;
+
+ // Act
+ formSelectionList.selectItem("1");
+
+ // Assert
+ expect(formSelectionList.selectedItems).not.toEqual(initialSelected);
+ expect(formSelectionList.deselectedItems).not.toEqual(initialdeselected);
+ });
+
+ it("should add items to selectedItems array in sorted order when called with a valid id", () => {
+ // Act
+ formSelectionList.selectItems(["2", "3", "1"]); // Use out of order ids
+
+ // Assert
+ expect(formSelectionList.selectedItems).toHaveProperty("[0].id", "1");
+ expect(formSelectionList.selectedItems).toHaveProperty("[1].id", "2");
+ expect(formSelectionList.selectedItems).toHaveProperty("[2].id", "3");
+
+ // Form array values should be in the same order
+ expect(formSelectionList.formArray.value[0].id).toEqual(
+ formSelectionList.selectedItems[0].id
+ );
+
+ expect(formSelectionList.formArray.value[1].id).toEqual(
+ formSelectionList.selectedItems[1].id
+ );
+
+ expect(formSelectionList.formArray.value[2].id).toEqual(
+ formSelectionList.selectedItems[2].id
+ );
+ });
+ });
+
+ describe("deselectItem()", () => {
+ beforeEach(() => {
+ formSelectionList.populateItems(testItems, [
+ { id: "1", value: "testValue" },
+ { id: "2", value: "testValue" },
+ ]);
+ });
+
+ it("should add item to deselectedItems, remove from selectedItems and remove from formArray when called with a valid id", () => {
+ // Act
+ formSelectionList.deselectItem("1");
+
+ // Assert
+ expect(formSelectionList.selectedItems.length).toEqual(1);
+ expect(formSelectionList.formArray.length).toEqual(1);
+ expect(formSelectionList.deselectedItems.length).toEqual(2);
+
+ // Value and View should still be in sync
+ expect(formSelectionList.formArray.value[0].id).toEqual(
+ formSelectionList.selectedItems[0].id
+ );
+ });
+
+ it("should do nothing when called with a invalid id", () => {
+ // Act
+ formSelectionList.deselectItem("bad-id");
+
+ // Assert
+ expect(formSelectionList.selectedItems.length).toEqual(2);
+ expect(formSelectionList.formArray.length).toEqual(2);
+ expect(formSelectionList.deselectedItems.length).toEqual(1);
+ });
+
+ // Ensure Angular's Change Detection will pick up any modifications to the array
+ it("should create new copies of the selectedItems and deselectedItems arrays when called with a valid id", () => {
+ // Arrange
+ const initialSelected = formSelectionList.selectedItems;
+ const initialdeselected = formSelectionList.deselectedItems;
+
+ // Act
+ formSelectionList.deselectItem("1");
+
+ // Assert
+ expect(formSelectionList.selectedItems).not.toEqual(initialSelected);
+ expect(formSelectionList.deselectedItems).not.toEqual(initialdeselected);
+ });
+
+ it("should add items to deselectedItems array in sorted order when called with a valid id", () => {
+ // Act
+ formSelectionList.deselectItems(["2", "1"]); // Use out of order ids
+
+ // Assert
+ expect(formSelectionList.deselectedItems).toHaveProperty("[0].id", "1");
+ expect(formSelectionList.deselectedItems).toHaveProperty("[1].id", "2");
+ expect(formSelectionList.deselectedItems).toHaveProperty("[2].id", "3");
+ });
+ });
+
+ describe("deselectAll()", () => {
+ beforeEach(() => {
+ formSelectionList.populateItems(testItems, [
+ { id: "1", value: "testValue" },
+ { id: "2", value: "testValue" },
+ ]);
+ });
+
+ it("should clear the formArray and selectedItems arrays and populate the deselectedItems array when called", () => {
+ // Act
+ formSelectionList.deselectAll();
+
+ // Assert
+ expect(formSelectionList.selectedItems.length).toEqual(0);
+ expect(formSelectionList.formArray.length).toEqual(0);
+ expect(formSelectionList.deselectedItems.length).toEqual(totalTestItemCount);
+ });
+
+ it("should create new arrays for selectedItems and deselectedItems when called", () => {
+ // Arrange
+ const initialSelected = formSelectionList.selectedItems;
+ const initialdeselected = formSelectionList.deselectedItems;
+
+ // Act
+ formSelectionList.deselectAll();
+
+ // Assert
+ expect(formSelectionList.selectedItems).not.toEqual(initialSelected);
+ expect(formSelectionList.deselectedItems).not.toEqual(initialdeselected);
+ });
+ });
+});
diff --git a/libs/angular/src/utils/form-selection-list.ts b/libs/angular/src/utils/form-selection-list.ts
new file mode 100644
index 00000000000..026ef367a4c
--- /dev/null
+++ b/libs/angular/src/utils/form-selection-list.ts
@@ -0,0 +1,201 @@
+import { AbstractControl, FormArray } from "@angular/forms";
+
+export type SelectionItemId = {
+ id: string;
+};
+
+function findSortedIndex(sortedArray: T[], val: T, compareFn: (a: T, b: T) => number) {
+ let low = 0;
+ let high = sortedArray.length || 0;
+ let mid = -1,
+ c = 0;
+ while (low < high) {
+ mid = Math.floor((low + high) / 2);
+ c = compareFn(sortedArray[mid], val);
+ if (c < 0) {
+ low = mid + 1;
+ } else if (c > 0) {
+ high = mid;
+ } else {
+ return mid;
+ }
+ }
+ return low;
+}
+
+/**
+ * Utility to help manage a list of selectable items for use with Reactive Angular forms and FormArrays.
+ *
+ * It supports selecting/deselecting items, keeping items sorted, and synchronizing the selected items
+ * with an array of FormControl.
+ *
+ * The first type parameter TItem represents the item being selected/deselected, it must have an `id`
+ * property for identification/comparison. The second type parameter TControlValue represents the value
+ * type of the form control.
+ */
+export class FormSelectionList<
+ TItem extends SelectionItemId,
+ TControlValue extends SelectionItemId
+> {
+ allItems: TItem[] = [];
+ /**
+ * Sorted list of selected items
+ * Immutable and should be recreated whenever a modification is made
+ */
+ selectedItems: TItem[] = [];
+
+ /**
+ * Sorted list of deselected items
+ * Immutable and should be recreated whenever a modification is made
+ */
+ deselectedItems: TItem[] = [];
+
+ /**
+ * Sorted FormArray that corresponds and stays in sync with the selectedItems
+ */
+ formArray: FormArray, TControlValue>> = new FormArray([]);
+
+ /**
+ * Construct a new FormSelectionList
+ * @param controlFactory - Factory responsible for creating initial form controls for each selected item. It is
+ * provided a copy of the selected item for any form control initialization logic. Specify any additional form
+ * control options or validators here.
+ * @param compareFn - Comparison function used for sorting the items.
+ */
+ constructor(
+ private controlFactory: (item: TItem) => AbstractControl, TControlValue>,
+ private compareFn: (a: TItem, b: TItem) => number
+ ) {}
+
+ /**
+ * Select multiple items by their ids at once. Optionally provide an initial form control value.
+ * @param ids - List of ids to select
+ * @param initialValue - Value that will be applied to the corresponding form controls
+ * The provided `id` arguments will be automatically assigned to each form control value
+ */
+ selectItems(ids: string[], initialValue?: Partial | undefined) {
+ for (const id of ids) {
+ this.selectItem(id, initialValue);
+ }
+ }
+
+ /**
+ * Deselect multiple items by their ids at once
+ * @param ids - List of ids to deselect
+ */
+ deselectItems(ids: string[]) {
+ for (const id of ids) {
+ this.deselectItem(id);
+ }
+ }
+
+ deselectAll() {
+ this.formArray.clear();
+ this.selectedItems = [];
+ this.deselectedItems = [...this.allItems];
+ }
+
+ /**
+ * Select a single item by id.
+ *
+ * Maintains list order for both selected items, deselected items, and the FormArray.
+ *
+ * @param id - Id of the item to select
+ * @param initialValue - Value that will be applied to the corresponding form control for the selected item.
+ * The provided `id` argument will be automatically assigned unless explicitly set in the initialValue.
+ */
+ selectItem(id: string, initialValue?: Partial) {
+ const index = this.deselectedItems.findIndex((o) => o.id === id);
+
+ if (index === -1) {
+ return;
+ }
+
+ const selectedOption = this.deselectedItems[index];
+
+ // Note: Changes to the deselected/selected arrays must create a new copy of the array
+ // in order for Angular's Change Detection to pick up the modification (i.e. treat the arrays as immutable)
+
+ // Remove from the list of deselected options
+ this.deselectedItems = [
+ ...this.deselectedItems.slice(0, index),
+ ...this.deselectedItems.slice(index + 1),
+ ];
+
+ // Insert into the sorted selected options list
+ const sortedInsertIndex = findSortedIndex(this.selectedItems, selectedOption, this.compareFn);
+
+ this.selectedItems = [
+ ...this.selectedItems.slice(0, sortedInsertIndex),
+ selectedOption,
+ ...this.selectedItems.slice(sortedInsertIndex),
+ ];
+
+ const newControl = this.controlFactory(selectedOption);
+
+ // Patch the value and ensure the `id` is set
+ newControl.patchValue({
+ id,
+ ...initialValue,
+ });
+
+ this.formArray.insert(sortedInsertIndex, newControl);
+ }
+
+ /**
+ * Deselect a single item by id.
+ *
+ * Maintains list order for both selected items, deselected items, and the FormArray.
+ *
+ * @param id - Id of the item to deselect
+ */
+ deselectItem(id: string) {
+ const index = this.selectedItems.findIndex((o) => o.id === id);
+
+ if (index === -1) {
+ return;
+ }
+
+ const deselectedOption = this.selectedItems[index];
+
+ // Note: Changes to the deselected/selected arrays must create a new copy of the array
+ // in order for Angular's Change Detection to pick up the modification (i.e. treat the arrays as immutable)
+
+ // Remove from the list of selected items (and FormArray)
+ this.selectedItems = [
+ ...this.selectedItems.slice(0, index),
+ ...this.selectedItems.slice(index + 1),
+ ];
+ this.formArray.removeAt(index);
+
+ // Insert into the sorted deselected array
+ const sortedInsertIndex = findSortedIndex(
+ this.deselectedItems,
+ deselectedOption,
+ this.compareFn
+ );
+
+ this.deselectedItems = [
+ ...this.deselectedItems.slice(0, sortedInsertIndex),
+ deselectedOption,
+ ...this.deselectedItems.slice(sortedInsertIndex),
+ ];
+ }
+
+ /**
+ * Populate the list of deselected items, and optional specify which items should be selected and with what initial
+ * value for their Form Control
+ * @param items - A list of all items. (Will be sorted internally)
+ * @param selectedItems - The items to select initially
+ */
+ populateItems(items: TItem[], selectedItems: TControlValue[] = []) {
+ this.formArray.clear();
+ this.allItems = [...items].sort(this.compareFn);
+ this.selectedItems = [];
+ this.deselectedItems = [...this.allItems];
+
+ for (const selectedItem of selectedItems) {
+ this.selectItem(selectedItem.id, selectedItem);
+ }
+ }
+}
diff --git a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts
index 43cd46c45d9..55f1ba09ed8 100644
--- a/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts
+++ b/libs/angular/src/vault/vault-filter/components/vault-filter.component.ts
@@ -1,12 +1,12 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs";
-import { DeprecatedVaultFilterService } from "@bitwarden/angular/abstractions/deprecated-vault-filter.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { ITreeNodeObject } from "@bitwarden/common/models/domain/treeNode";
import { CollectionView } from "@bitwarden/common/models/view/collectionView";
import { FolderView } from "@bitwarden/common/models/view/folderView";
+import { DeprecatedVaultFilterService } from "../../../abstractions/deprecated-vault-filter.service";
import { DynamicTreeNode } from "../models/dynamic-tree-node.model";
import { VaultFilter } from "../models/vault-filter.model";
diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts
index 2e5b2862599..bfc2ef495da 100644
--- a/libs/components/src/index.ts
+++ b/libs/components/src/index.ts
@@ -1,4 +1,5 @@
export * from "./async-actions";
+export * from "./avatar";
export * from "./badge";
export * from "./banner";
export * from "./button";
diff --git a/libs/components/src/multi-select/models/select-item-view.ts b/libs/components/src/multi-select/models/select-item-view.ts
index 45cf47d73f4..f66680c4406 100644
--- a/libs/components/src/multi-select/models/select-item-view.ts
+++ b/libs/components/src/multi-select/models/select-item-view.ts
@@ -2,6 +2,6 @@ export type SelectItemView = {
id: string; // Unique ID used for comparisons
listName: string; // Default bindValue -> this is what will be displayed in list items
labelName: string; // This is what will be displayed in the selection option badge
- icon: string; // Icon to display within the list
- parentGrouping: string; // Used to group items by parent
+ icon?: string; // Icon to display within the list
+ parentGrouping?: string; // Used to group items by parent
};