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 @@ -