diff --git a/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts new file mode 100644 index 0000000000..75e63d4242 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.spec.ts @@ -0,0 +1,126 @@ +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { RouterTestingHarness } from "@angular/router/testing"; +import { MockProxy, any, mock } from "jest-mock-extended"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { DialogService } from "@bitwarden/components"; + +import { isEnterpriseOrgGuard } from "./is-enterprise-org.guard"; + +@Component({ + template: "

This is the home screen!

", +}) +export class HomescreenComponent {} + +@Component({ + template: "

This component can only be accessed by a enterprise organization!

", +}) +export class IsEnterpriseOrganizationComponent {} + +@Component({ + template: "

This is the organization upgrade screen!

", +}) +export class OrganizationUpgradeScreenComponent {} + +const orgFactory = (props: Partial = {}) => + Object.assign( + new Organization(), + { + id: "myOrgId", + enabled: true, + type: OrganizationUserType.Admin, + }, + props, + ); + +describe("Is Enterprise Org Guard", () => { + let organizationService: MockProxy; + let dialogService: MockProxy; + let routerHarness: RouterTestingHarness; + + beforeEach(async () => { + organizationService = mock(); + dialogService = mock(); + + TestBed.configureTestingModule({ + providers: [ + { provide: OrganizationService, useValue: organizationService }, + { provide: DialogService, useValue: dialogService }, + provideRouter([ + { + path: "", + component: HomescreenComponent, + }, + { + path: "organizations/:organizationId/enterpriseOrgsOnly", + component: IsEnterpriseOrganizationComponent, + canActivate: [isEnterpriseOrgGuard()], + }, + { + path: "organizations/:organizationId/billing/subscription", + component: OrganizationUpgradeScreenComponent, + }, + ]), + ], + }); + + routerHarness = await RouterTestingHarness.create(); + }); + + it("redirects to `/` if the organization id provided is not found", async () => { + const org = orgFactory(); + organizationService.get.calledWith(org.id).mockResolvedValue(null); + await routerHarness.navigateByUrl(`organizations/${org.id}/enterpriseOrgsOnly`); + expect(routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "").toBe( + "This is the home screen!", + ); + }); + + it.each([ + ProductTierType.Free, + ProductTierType.Families, + ProductTierType.Teams, + ProductTierType.TeamsStarter, + ])( + "shows a dialog to users of a not enterprise organization and does not proceed with navigation for productTierType '%s'", + async (productTierType) => { + const org = orgFactory({ + type: OrganizationUserType.User, + productTierType: productTierType, + }); + organizationService.get.calledWith(org.id).mockResolvedValue(org); + await routerHarness.navigateByUrl(`organizations/${org.id}/enterpriseOrgsOnly`); + expect(dialogService.openSimpleDialog).toHaveBeenCalled(); + expect( + routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "", + ).not.toBe("This component can only be accessed by a enterprise organization!"); + }, + ); + + it("redirects users with billing access to the billing screen to upgrade", async () => { + const org = orgFactory({ + type: OrganizationUserType.Owner, + productTierType: ProductTierType.Teams, + }); + organizationService.get.calledWith(org.id).mockResolvedValue(org); + dialogService.openSimpleDialog.calledWith(any()).mockResolvedValue(true); + await routerHarness.navigateByUrl(`organizations/${org.id}/enterpriseOrgsOnly`); + expect(routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "").toBe( + "This is the organization upgrade screen!", + ); + }); + + it("proceeds with navigation if the organization in question is a enterprise organization", async () => { + const org = orgFactory({ productTierType: ProductTierType.Enterprise }); + organizationService.get.calledWith(org.id).mockResolvedValue(org); + await routerHarness.navigateByUrl(`organizations/${org.id}/enterpriseOrgsOnly`); + expect(routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "").toBe( + "This component can only be accessed by a enterprise organization!", + ); + }); +}); diff --git a/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.ts b/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.ts index 605ce0059d..3373f0cfd5 100644 --- a/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.ts +++ b/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.ts @@ -1,44 +1,45 @@ -import { Injectable } from "@angular/core"; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router"; -import { firstValueFrom } from "rxjs"; +import { inject } from "@angular/core"; +import { + ActivatedRouteSnapshot, + CanActivateFn, + Router, + RouterStateSnapshot, +} from "@angular/router"; +import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { ProductTierType } from "@bitwarden/common/billing/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { DialogService } from "@bitwarden/components"; -@Injectable({ - providedIn: "root", -}) -export class IsEnterpriseOrgGuard implements CanActivate { - constructor( - private router: Router, - private organizationService: OrganizationService, - private dialogService: DialogService, - private configService: ConfigService, - ) {} +/** + * `CanActivateFn` that checks if the organization matching the id in the URL + * parameters is of enterprise type. If the organization is not enterprise instructions are + * provided on how to upgrade into an enterprise organization, and the user is redirected + * if they have access to upgrade the organization. If the organization is + * enterprise routing proceeds." + */ +export function isEnterpriseOrgGuard(): CanActivateFn { + return async (route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + const router = inject(Router); + const organizationService = inject(OrganizationService); + const dialogService = inject(DialogService); - async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { - const isMemberAccessReportEnabled = await firstValueFrom( - this.configService.getFeatureFlag$(FeatureFlag.MemberAccessReport), - ); - - // TODO: Remove on "MemberAccessReport" feature flag cleanup - if (!isMemberAccessReportEnabled) { - return this.router.createUrlTree(["/"]); - } - - const org = await this.organizationService.get(route.params.organizationId); + const org = await organizationService.get(route.params.organizationId); if (org == null) { - return this.router.createUrlTree(["/"]); + return router.createUrlTree(["/"]); + } + + // TODO: Remove on "MemberAccessReport" feature flag cleanup + if (!canAccessFeature(FeatureFlag.MemberAccessReport)) { + return router.createUrlTree(["/"]); } if (org.productTierType != ProductTierType.Enterprise) { // Users without billing permission can't access billing if (!org.canEditSubscription) { - await this.dialogService.openSimpleDialog({ + await dialogService.openSimpleDialog({ title: { key: "upgradeOrganizationEnterprise" }, content: { key: "onlyAvailableForEnterpriseOrganization" }, acceptButtonText: { key: "ok" }, @@ -47,7 +48,7 @@ export class IsEnterpriseOrgGuard implements CanActivate { }); return false; } else { - const upgradeConfirmed = await this.dialogService.openSimpleDialog({ + const upgradeConfirmed = await dialogService.openSimpleDialog({ title: { key: "upgradeOrganizationEnterprise" }, content: { key: "onlyAvailableForEnterpriseOrganization" }, acceptButtonText: { key: "upgradeOrganization" }, @@ -55,7 +56,7 @@ export class IsEnterpriseOrgGuard implements CanActivate { icon: "bwi-arrow-circle-up", }); if (upgradeConfirmed) { - await this.router.navigate(["organizations", org.id, "billing", "subscription"], { + await router.navigate(["organizations", org.id, "billing", "subscription"], { queryParams: { upgrade: true, productTierType: ProductTierType.Enterprise }, }); } @@ -63,5 +64,5 @@ export class IsEnterpriseOrgGuard implements CanActivate { } return org.productTierType == ProductTierType.Enterprise; - } + }; } diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts index 1b31341e0d..413ced840d 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/organizations-routing.module.ts @@ -3,7 +3,7 @@ import { RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "@bitwarden/angular/auth/guards"; import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; -import { IsEnterpriseOrgGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/is-enterprise-org.guard"; +import { isEnterpriseOrgGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/is-enterprise-org.guard"; import { organizationPermissionsGuard } from "@bitwarden/web-vault/app/admin-console/organizations/guards/org-permissions.guard"; import { OrganizationLayoutComponent } from "@bitwarden/web-vault/app/admin-console/organizations/layouts/organization-layout.component"; @@ -72,7 +72,7 @@ const routes: Routes = [ data: { titleId: "memberAccessReport", }, - canActivate: [IsEnterpriseOrgGuard], + canActivate: [isEnterpriseOrgGuard()], }, ], },