diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json
index 6c519d70ae..69dae5e732 100644
--- a/apps/web/.eslintrc.json
+++ b/apps/web/.eslintrc.json
@@ -10,6 +10,8 @@
"**/app/core/*",
"**/reports/*",
"**/app/shared/*",
+ "**/organizations/settings/*",
+ "**/organizations/policies/*",
"@bitwarden/web-vault/*",
"src/**/*"
],
diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts
index b1700a8551..9bd0fda6fc 100644
--- a/apps/web/src/app/app.component.ts
+++ b/apps/web/src/app/app.component.ts
@@ -27,15 +27,17 @@ import { SyncService } from "@bitwarden/common/abstractions/sync/sync.service.ab
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vaultTimeout/vaultTimeout.service";
import { PolicyListService, RouterService } from "./core";
-import { DisableSendPolicy } from "./organizations/policies/disable-send.component";
-import { MasterPasswordPolicy } from "./organizations/policies/master-password.component";
-import { PasswordGeneratorPolicy } from "./organizations/policies/password-generator.component";
-import { PersonalOwnershipPolicy } from "./organizations/policies/personal-ownership.component";
-import { RequireSsoPolicy } from "./organizations/policies/require-sso.component";
-import { ResetPasswordPolicy } from "./organizations/policies/reset-password.component";
-import { SendOptionsPolicy } from "./organizations/policies/send-options.component";
-import { SingleOrgPolicy } from "./organizations/policies/single-org.component";
-import { TwoFactorAuthenticationPolicy } from "./organizations/policies/two-factor-authentication.component";
+import {
+ DisableSendPolicy,
+ MasterPasswordPolicy,
+ PasswordGeneratorPolicy,
+ PersonalOwnershipPolicy,
+ RequireSsoPolicy,
+ ResetPasswordPolicy,
+ SendOptionsPolicy,
+ SingleOrgPolicy,
+ TwoFactorAuthenticationPolicy,
+} from "./organizations/policies";
const BroadcasterSubscriptionId = "AppComponent";
const IdleTimeout = 60000 * 10; // 10 minutes
diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts
index 6205418810..4c4e488ff0 100644
--- a/apps/web/src/app/core/event.service.ts
+++ b/apps/web/src/app/core/event.service.ts
@@ -479,16 +479,14 @@ export class EventService implements OnInit, OnDestroy {
private formatGroupId(ev: EventResponse) {
const shortId = this.getShortId(ev.groupId);
const a = this.makeAnchor(shortId);
- a.setAttribute(
- "href",
- "#/organizations/" + ev.organizationId + "/manage/groups?search=" + shortId
- );
+ a.setAttribute("href", "#/organizations/" + ev.organizationId + "/groups?search=" + shortId);
return a.outerHTML;
}
private formatCollectionId(ev: EventResponse) {
const shortId = this.getShortId(ev.collectionId);
const a = this.makeAnchor(shortId);
+ // TODO: Update view/edit collection link after EC-14 is completed
a.setAttribute(
"href",
"#/organizations/" + ev.organizationId + "/manage/collections?search=" + shortId
@@ -503,7 +501,7 @@ export class EventService implements OnInit, OnDestroy {
"href",
"#/organizations/" +
ev.organizationId +
- "/manage/people?search=" +
+ "/members?search=" +
shortId +
"&viewEvents=" +
ev.organizationUserId
diff --git a/apps/web/src/app/core/policy-list.service.ts b/apps/web/src/app/core/policy-list.service.ts
index 70857ef819..bb20700690 100644
--- a/apps/web/src/app/core/policy-list.service.ts
+++ b/apps/web/src/app/core/policy-list.service.ts
@@ -1,4 +1,4 @@
-import { BasePolicy } from "../organizations/policies/base-policy.component";
+import { BasePolicy } from "../organizations/policies";
export class PolicyListService {
private policies: BasePolicy[] = [];
diff --git a/apps/web/src/app/organizations/settings/adjust-subscription.component.html b/apps/web/src/app/organizations/billing/adjust-subscription.component.html
similarity index 100%
rename from apps/web/src/app/organizations/settings/adjust-subscription.component.html
rename to apps/web/src/app/organizations/billing/adjust-subscription.component.html
diff --git a/apps/web/src/app/organizations/settings/adjust-subscription.component.ts b/apps/web/src/app/organizations/billing/adjust-subscription.component.ts
similarity index 100%
rename from apps/web/src/app/organizations/settings/adjust-subscription.component.ts
rename to apps/web/src/app/organizations/billing/adjust-subscription.component.ts
diff --git a/apps/web/src/app/organizations/settings/billing-sync-api-key.component.html b/apps/web/src/app/organizations/billing/billing-sync-api-key.component.html
similarity index 100%
rename from apps/web/src/app/organizations/settings/billing-sync-api-key.component.html
rename to apps/web/src/app/organizations/billing/billing-sync-api-key.component.html
diff --git a/apps/web/src/app/organizations/settings/billing-sync-api-key.component.ts b/apps/web/src/app/organizations/billing/billing-sync-api-key.component.ts
similarity index 100%
rename from apps/web/src/app/organizations/settings/billing-sync-api-key.component.ts
rename to apps/web/src/app/organizations/billing/billing-sync-api-key.component.ts
diff --git a/apps/web/src/app/organizations/settings/change-plan.component.html b/apps/web/src/app/organizations/billing/change-plan.component.html
similarity index 100%
rename from apps/web/src/app/organizations/settings/change-plan.component.html
rename to apps/web/src/app/organizations/billing/change-plan.component.html
diff --git a/apps/web/src/app/organizations/settings/change-plan.component.ts b/apps/web/src/app/organizations/billing/change-plan.component.ts
similarity index 100%
rename from apps/web/src/app/organizations/settings/change-plan.component.ts
rename to apps/web/src/app/organizations/billing/change-plan.component.ts
diff --git a/apps/web/src/app/organizations/settings/download-license.component.html b/apps/web/src/app/organizations/billing/download-license.component.html
similarity index 100%
rename from apps/web/src/app/organizations/settings/download-license.component.html
rename to apps/web/src/app/organizations/billing/download-license.component.html
diff --git a/apps/web/src/app/organizations/settings/download-license.component.ts b/apps/web/src/app/organizations/billing/download-license.component.ts
similarity index 100%
rename from apps/web/src/app/organizations/settings/download-license.component.ts
rename to apps/web/src/app/organizations/billing/download-license.component.ts
diff --git a/apps/web/src/app/organizations/billing/organization-billing-history-view.component.html b/apps/web/src/app/organizations/billing/organization-billing-history-view.component.html
new file mode 100644
index 0000000000..6622245ad1
--- /dev/null
+++ b/apps/web/src/app/organizations/billing/organization-billing-history-view.component.html
@@ -0,0 +1,27 @@
+
+
+
+ {{ "loading" | i18n }}
+
+
+
+
diff --git a/apps/web/src/app/organizations/billing/organization-billing-history-view.component.ts b/apps/web/src/app/organizations/billing/organization-billing-history-view.component.ts
new file mode 100644
index 0000000000..c352bb83f1
--- /dev/null
+++ b/apps/web/src/app/organizations/billing/organization-billing-history-view.component.ts
@@ -0,0 +1,51 @@
+import { Component, OnDestroy, OnInit } from "@angular/core";
+import { ActivatedRoute } from "@angular/router";
+import { concatMap, Subject, takeUntil } from "rxjs";
+
+import { OrganizationApiServiceAbstraction } from "@bitwarden/common/abstractions/organization/organization-api.service.abstraction";
+import { BillingHistoryResponse } from "@bitwarden/common/models/response/billing-history.response";
+
+@Component({
+ selector: "app-org-billing-history-view",
+ templateUrl: "organization-billing-history-view.component.html",
+})
+export class OrgBillingHistoryViewComponent implements OnInit, OnDestroy {
+ loading = false;
+ firstLoaded = false;
+ billing: BillingHistoryResponse;
+ organizationId: string;
+
+ private destroy$ = new Subject();
+
+ constructor(
+ private organizationApiService: OrganizationApiServiceAbstraction,
+ private route: ActivatedRoute
+ ) {}
+
+ async ngOnInit() {
+ this.route.params
+ .pipe(
+ concatMap(async (params) => {
+ this.organizationId = params.organizationId;
+ await this.load();
+ this.firstLoaded = true;
+ }),
+ takeUntil(this.destroy$)
+ )
+ .subscribe();
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ async load() {
+ if (this.loading) {
+ return;
+ }
+ this.loading = true;
+ this.billing = await this.organizationApiService.getBilling(this.organizationId);
+ this.loading = false;
+ }
+}
diff --git a/apps/web/src/app/organizations/billing/organization-billing-routing.module.ts b/apps/web/src/app/organizations/billing/organization-billing-routing.module.ts
new file mode 100644
index 0000000000..0e410d9734
--- /dev/null
+++ b/apps/web/src/app/organizations/billing/organization-billing-routing.module.ts
@@ -0,0 +1,48 @@
+import { NgModule } from "@angular/core";
+import { RouterModule, Routes } from "@angular/router";
+
+import { canAccessBillingTab } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
+
+import { PaymentMethodComponent } from "../../settings/payment-method.component";
+import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard";
+
+import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component";
+import { OrganizationBillingTabComponent } from "./organization-billing-tab.component";
+import { OrganizationSubscriptionComponent } from "./organization-subscription.component";
+
+const routes: Routes = [
+ {
+ path: "",
+ component: OrganizationBillingTabComponent,
+ canActivate: [OrganizationPermissionsGuard],
+ data: { organizationPermissions: canAccessBillingTab },
+ children: [
+ { path: "", pathMatch: "full", redirectTo: "subscription" },
+ {
+ path: "subscription",
+ component: OrganizationSubscriptionComponent,
+ data: { titleId: "subscription" },
+ },
+ {
+ path: "payment-method",
+ component: PaymentMethodComponent,
+ data: {
+ titleId: "paymentMethod",
+ },
+ },
+ {
+ path: "history",
+ component: OrgBillingHistoryViewComponent,
+ data: {
+ titleId: "billingHistory",
+ },
+ },
+ ],
+ },
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule],
+})
+export class OrganizationBillingRoutingModule {}
diff --git a/apps/web/src/app/organizations/billing/organization-billing-tab.component.html b/apps/web/src/app/organizations/billing/organization-billing-tab.component.html
new file mode 100644
index 0000000000..7f755fccbb
--- /dev/null
+++ b/apps/web/src/app/organizations/billing/organization-billing-tab.component.html
@@ -0,0 +1,33 @@
+
diff --git a/apps/web/src/app/organizations/billing/organization-billing-tab.component.ts b/apps/web/src/app/organizations/billing/organization-billing-tab.component.ts
new file mode 100644
index 0000000000..5eb207dee9
--- /dev/null
+++ b/apps/web/src/app/organizations/billing/organization-billing-tab.component.ts
@@ -0,0 +1,14 @@
+import { Component } from "@angular/core";
+
+import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
+
+@Component({
+ selector: "app-org-billing-tab",
+ templateUrl: "organization-billing-tab.component.html",
+})
+export class OrganizationBillingTabComponent {
+ showPaymentAndHistory: boolean;
+ constructor(private platformUtilsService: PlatformUtilsService) {
+ this.showPaymentAndHistory = !this.platformUtilsService.isSelfHost();
+ }
+}
diff --git a/apps/web/src/app/organizations/billing/organization-billing.module.ts b/apps/web/src/app/organizations/billing/organization-billing.module.ts
new file mode 100644
index 0000000000..513b7ba766
--- /dev/null
+++ b/apps/web/src/app/organizations/billing/organization-billing.module.ts
@@ -0,0 +1,26 @@
+import { NgModule } from "@angular/core";
+
+import { LooseComponentsModule, SharedModule } from "../../shared";
+
+import { AdjustSubscription } from "./adjust-subscription.component";
+import { BillingSyncApiKeyComponent } from "./billing-sync-api-key.component";
+import { ChangePlanComponent } from "./change-plan.component";
+import { DownloadLicenseComponent } from "./download-license.component";
+import { OrgBillingHistoryViewComponent } from "./organization-billing-history-view.component";
+import { OrganizationBillingRoutingModule } from "./organization-billing-routing.module";
+import { OrganizationBillingTabComponent } from "./organization-billing-tab.component";
+import { OrganizationSubscriptionComponent } from "./organization-subscription.component";
+
+@NgModule({
+ imports: [SharedModule, LooseComponentsModule, OrganizationBillingRoutingModule],
+ declarations: [
+ AdjustSubscription,
+ BillingSyncApiKeyComponent,
+ ChangePlanComponent,
+ DownloadLicenseComponent,
+ OrganizationBillingTabComponent,
+ OrganizationSubscriptionComponent,
+ OrgBillingHistoryViewComponent,
+ ],
+})
+export class OrganizationBillingModule {}
diff --git a/apps/web/src/app/organizations/settings/organization-subscription.component.html b/apps/web/src/app/organizations/billing/organization-subscription.component.html
similarity index 100%
rename from apps/web/src/app/organizations/settings/organization-subscription.component.html
rename to apps/web/src/app/organizations/billing/organization-subscription.component.html
diff --git a/apps/web/src/app/organizations/settings/organization-subscription.component.ts b/apps/web/src/app/organizations/billing/organization-subscription.component.ts
similarity index 93%
rename from apps/web/src/app/organizations/settings/organization-subscription.component.ts
rename to apps/web/src/app/organizations/billing/organization-subscription.component.ts
index dbcdfd907a..803be05097 100644
--- a/apps/web/src/app/organizations/settings/organization-subscription.component.ts
+++ b/apps/web/src/app/organizations/billing/organization-subscription.component.ts
@@ -1,5 +1,6 @@
-import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
+import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
+import { concatMap, Subject, takeUntil } from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -27,17 +28,13 @@ import { SubscriptionHiddenIcon } from "./subscription-hidden.icon";
selector: "app-org-subscription",
templateUrl: "organization-subscription.component.html",
})
-// eslint-disable-next-line rxjs-angular/prefer-takeuntil
-export class OrganizationSubscriptionComponent implements OnInit {
+export class OrganizationSubscriptionComponent implements OnInit, OnDestroy {
@ViewChild("setupBillingSyncTemplate", { read: ViewContainerRef, static: true })
setupBillingSyncModalRef: ViewContainerRef;
loading = false;
firstLoaded = false;
organizationId: string;
- adjustSeatsAdd = true;
- showAdjustSeats = false;
- showAdjustSeatAutoscale = false;
adjustStorageAdd = true;
showAdjustStorage = false;
showUpdateLicense = false;
@@ -61,6 +58,8 @@ export class OrganizationSubscriptionComponent implements OnInit {
subscriptionHiddenIcon = SubscriptionHiddenIcon;
+ private destroy$ = new Subject();
+
constructor(
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
@@ -76,19 +75,27 @@ export class OrganizationSubscriptionComponent implements OnInit {
}
async ngOnInit() {
- // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
- this.route.parent.parent.params.subscribe(async (params) => {
- this.organizationId = params.organizationId;
- await this.load();
- this.firstLoaded = true;
- });
+ this.route.params
+ .pipe(
+ concatMap(async (params) => {
+ this.organizationId = params.organizationId;
+ await this.load();
+ this.firstLoaded = true;
+ }),
+ takeUntil(this.destroy$)
+ )
+ .subscribe();
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
}
async load() {
if (this.loading) {
return;
}
-
this.loading = true;
this.userOrg = this.organizationService.get(this.organizationId);
if (this.userOrg.canManageBilling) {
@@ -175,7 +182,7 @@ export class OrganizationSubscriptionComponent implements OnInit {
this.showChangePlan = !this.showChangePlan;
}
- closeChangePlan(changed: boolean) {
+ closeChangePlan() {
this.showChangePlan = false;
}
@@ -192,10 +199,14 @@ export class OrganizationSubscriptionComponent implements OnInit {
comp.hasBillingToken = this.hasBillingSyncToken;
}
);
- // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe
- ref.onClosed.subscribe(async () => {
- await this.load();
- });
+ ref.onClosed
+ .pipe(
+ concatMap(async () => {
+ await this.load();
+ }),
+ takeUntil(this.destroy$)
+ )
+ .subscribe();
}
closeDownloadLicense() {
diff --git a/apps/web/src/app/organizations/settings/subscription-hidden.icon.ts b/apps/web/src/app/organizations/billing/subscription-hidden.icon.ts
similarity index 100%
rename from apps/web/src/app/organizations/settings/subscription-hidden.icon.ts
rename to apps/web/src/app/organizations/billing/subscription-hidden.icon.ts
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 0000000000..10390b3e05
--- /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 0000000000..3b2ba911aa
--- /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 0000000000..98a49d5c3a
--- /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 0000000000..d621de271e
--- /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/selection-read-only.request";
+import { SelectionReadOnlyResponse } from "@bitwarden/common/models/response/selection-read-only.response";
+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 0000000000..cbb01137b4
--- /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 0000000000..059fb1c430
--- /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 0000000000..86624f8e94
--- /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 0000000000..6ef78cb65e
--- /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/layouts/organization-layout.component.html b/apps/web/src/app/organizations/layouts/organization-layout.component.html
index 7c04938cf3..5f5aa497f7 100644
--- a/apps/web/src/app/organizations/layouts/organization-layout.component.html
+++ b/apps/web/src/app/organizations/layouts/organization-layout.component.html
@@ -1,49 +1,32 @@
-
-
-
-
+
+
+
+
+
+ {{ "vault" | i18n }}
+
+ {{ "manage" | i18n }}
+
+
+ {{ getReportTabLabel(organization) | i18n }}
+
+ {{
+ "billing" | i18n
+ }}
+ {{
+ "settings" | i18n
+ }}
+
-
+
+
diff --git a/apps/web/src/app/organizations/layouts/organization-layout.component.ts b/apps/web/src/app/organizations/layouts/organization-layout.component.ts
index fe9d9de8b1..8c871f74db 100644
--- a/apps/web/src/app/organizations/layouts/organization-layout.component.ts
+++ b/apps/web/src/app/organizations/layouts/organization-layout.component.ts
@@ -3,11 +3,14 @@ import { ActivatedRoute } from "@angular/router";
import { map, mergeMap, Observable, Subject, takeUntil } from "rxjs";
import {
- OrganizationService,
- getOrganizationById,
+ canAccessBillingTab,
+ canAccessGroupsTab,
canAccessManageTab,
+ canAccessMembersTab,
+ canAccessReportingTab,
canAccessSettingsTab,
- canAccessToolsTab,
+ getOrganizationById,
+ OrganizationService,
} from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/models/domain/organization";
@@ -17,7 +20,6 @@ import { Organization } from "@bitwarden/common/models/domain/organization";
})
export class OrganizationLayoutComponent implements OnInit, OnDestroy {
organization$: Observable
;
- businessTokenPromise: Promise;
private _destroy = new Subject();
@@ -43,27 +45,43 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
this._destroy.complete();
}
- canShowManageTab(organization: Organization): boolean {
- return canAccessManageTab(organization);
- }
-
- canShowToolsTab(organization: Organization): boolean {
- return canAccessToolsTab(organization);
- }
-
canShowSettingsTab(organization: Organization): boolean {
return canAccessSettingsTab(organization);
}
- getToolsRoute(organization: Organization): string {
- return organization.canAccessImportExport ? "tools/import" : "tools/exposed-passwords-report";
+ canShowManageTab(organization: Organization): boolean {
+ return canAccessManageTab(organization);
+ }
+
+ canShowMembersTab(organization: Organization): boolean {
+ return canAccessMembersTab(organization);
+ }
+
+ canShowGroupsTab(organization: Organization): boolean {
+ return canAccessGroupsTab(organization);
+ }
+
+ canShowReportsTab(organization: Organization): boolean {
+ return canAccessReportingTab(organization);
+ }
+
+ canShowBillingTab(organization: Organization): boolean {
+ return canAccessBillingTab(organization);
+ }
+
+ getReportTabLabel(organization: Organization): string {
+ return organization.useEvents ? "reporting" : "reports";
+ }
+
+ getReportRoute(organization: Organization): string {
+ return organization.useEvents ? "reporting/events" : "reporting/reports";
}
getManageRoute(organization: Organization): string {
let route: string;
switch (true) {
case organization.canManageUsers:
- route = "manage/people";
+ route = "manage/members";
break;
case organization.canViewAssignedCollections || organization.canViewAllCollections:
route = "manage/collections";
@@ -71,18 +89,6 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
case organization.canManageGroups:
route = "manage/groups";
break;
- case organization.canManagePolicies:
- route = "manage/policies";
- break;
- case organization.canManageSso:
- route = "manage/sso";
- break;
- case organization.canManageScim:
- route = "manage/scim";
- break;
- case organization.canAccessEventLogs:
- route = "manage/events";
- break;
}
return route;
}
diff --git a/apps/web/src/app/organizations/manage/events.component.html b/apps/web/src/app/organizations/manage/events.component.html
index d27b53e8d9..a7468bcf37 100644
--- a/apps/web/src/app/organizations/manage/events.component.html
+++ b/apps/web/src/app/organizations/manage/events.component.html
@@ -1,54 +1,57 @@
-