diff --git a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts
new file mode 100644
index 0000000000..cf9a7b31dc
--- /dev/null
+++ b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.spec.ts
@@ -0,0 +1,115 @@
+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 { DialogService } from "@bitwarden/components";
+
+import { isPaidOrgGuard } from "./is-paid-org.guard";
+
+@Component({
+ template: "
This is the home screen!
",
+})
+export class HomescreenComponent {}
+
+@Component({
+ template: "This component can only be accessed by a paid organization!
",
+})
+export class PaidOrganizationOnlyComponent {}
+
+@Component({
+ template: "This is the organization upgrade screen!
",
+})
+export class OrganizationUpgradeScreen {}
+
+const orgFactory = (props: Partial = {}) =>
+ Object.assign(
+ new Organization(),
+ {
+ id: "myOrgId",
+ enabled: true,
+ type: OrganizationUserType.Admin,
+ },
+ props,
+ );
+
+describe("Is Paid 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/paidOrganizationsOnly",
+ component: PaidOrganizationOnlyComponent,
+ canActivate: [isPaidOrgGuard()],
+ },
+ {
+ path: "organizations/:organizationId/billing/subscription",
+ component: OrganizationUpgradeScreen,
+ },
+ ]),
+ ],
+ });
+
+ 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}/paidOrganizationsOnly`);
+ expect(routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "").toBe(
+ "This is the home screen!",
+ );
+ });
+
+ it("shows a dialog to users of a free organization and does not proceed with navigation", async () => {
+ // `useTotp` is the current indicator of a free org, it is the baseline
+ // feature offered above the free organization level.
+ const org = orgFactory({ type: OrganizationUserType.User, useTotp: false });
+ organizationService.get.calledWith(org.id).mockResolvedValue(org);
+ await routerHarness.navigateByUrl(`organizations/${org.id}/paidOrganizationsOnly`);
+ expect(dialogService.openSimpleDialog).toHaveBeenCalled();
+ expect(
+ routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "",
+ ).not.toBe("This component can only be accessed by a paid organization!");
+ });
+
+ it("redirects users with billing access to the billing screen to upgrade", async () => {
+ // `useTotp` is the current indicator of a free org, it is the baseline
+ // feature offered above the free organization level.
+ const org = orgFactory({ type: OrganizationUserType.Owner, useTotp: false });
+ organizationService.get.calledWith(org.id).mockResolvedValue(org);
+ dialogService.openSimpleDialog.calledWith(any()).mockResolvedValue(true);
+ await routerHarness.navigateByUrl(`organizations/${org.id}/paidOrganizationsOnly`);
+ 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 paid organization", async () => {
+ const org = orgFactory({ useTotp: true });
+ organizationService.get.calledWith(org.id).mockResolvedValue(org);
+ await routerHarness.navigateByUrl(`organizations/${org.id}/paidOrganizationsOnly`);
+ expect(routerHarness.routeNativeElement?.querySelector("h1")?.textContent?.trim() ?? "").toBe(
+ "This component can only be accessed by a paid organization!",
+ );
+ });
+});
diff --git a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts
index aaf24e4834..e5ac9529c8 100644
--- a/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts
+++ b/apps/web/src/app/admin-console/organizations/guards/is-paid-org.guard.ts
@@ -1,32 +1,37 @@
-import { Injectable } from "@angular/core";
-import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router";
+import { inject } from "@angular/core";
+import {
+ ActivatedRouteSnapshot,
+ CanActivateFn,
+ Router,
+ RouterStateSnapshot,
+} from "@angular/router";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
-import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DialogService } from "@bitwarden/components";
-@Injectable({
- providedIn: "root",
-})
-export class IsPaidOrgGuard implements CanActivate {
- constructor(
- private router: Router,
- private organizationService: OrganizationService,
- private messagingService: MessagingService,
- private dialogService: DialogService,
- ) {}
+/**
+ * `CanActivateFn` that checks if the organization matching the id in the URL
+ * parameters is paid or free. If the organization is free instructions are
+ * provided on how to upgrade a free organization, and the user is redirected
+ * if they have access to upgrade the organization. If the organization is
+ * paid routing proceeds."
+ */
+export function isPaidOrgGuard(): 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 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(["/"]);
}
if (org.isFreeOrg) {
// Users without billing permission can't access billing
if (!org.canEditSubscription) {
- await this.dialogService.openSimpleDialog({
+ await dialogService.openSimpleDialog({
title: { key: "upgradeOrganizationCloseSecurityGaps" },
content: { key: "upgradeOrganizationCloseSecurityGapsDesc" },
acceptButtonText: { key: "ok" },
@@ -35,7 +40,7 @@ export class IsPaidOrgGuard implements CanActivate {
});
return false;
} else {
- const upgradeConfirmed = await this.dialogService.openSimpleDialog({
+ const upgradeConfirmed = await dialogService.openSimpleDialog({
title: { key: "upgradeOrganizationCloseSecurityGaps" },
content: { key: "upgradeOrganizationCloseSecurityGapsDesc" },
acceptButtonText: { key: "upgradeOrganization" },
@@ -43,7 +48,7 @@ export class IsPaidOrgGuard 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 },
});
}
@@ -51,5 +56,5 @@ export class IsPaidOrgGuard implements CanActivate {
}
return !org.isFreeOrg;
- }
+ };
}
diff --git a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts
index fafce75e73..6f63e34531 100644
--- a/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts
+++ b/apps/web/src/app/admin-console/organizations/reporting/organization-reporting-routing.module.ts
@@ -9,7 +9,7 @@ import { InactiveTwoFactorReportComponent } from "../../../admin-console/organiz
import { ReusedPasswordsReportComponent } from "../../../admin-console/organizations/tools/reused-passwords-report.component";
import { UnsecuredWebsitesReportComponent } from "../../../admin-console/organizations/tools/unsecured-websites-report.component";
import { WeakPasswordsReportComponent } from "../../../admin-console/organizations/tools/weak-passwords-report.component";
-import { IsPaidOrgGuard } from "../guards/is-paid-org.guard";
+import { isPaidOrgGuard } from "../guards/is-paid-org.guard";
import { OrganizationPermissionsGuard } from "../guards/org-permissions.guard";
import { organizationRedirectGuard } from "../guards/org-redirect.guard";
import { EventsComponent } from "../manage/events.component";
@@ -42,7 +42,7 @@ const routes: Routes = [
data: {
titleId: "exposedPasswordsReport",
},
- canActivate: [IsPaidOrgGuard],
+ canActivate: [isPaidOrgGuard()],
},
{
path: "inactive-two-factor-report",
@@ -50,7 +50,7 @@ const routes: Routes = [
data: {
titleId: "inactive2faReport",
},
- canActivate: [IsPaidOrgGuard],
+ canActivate: [isPaidOrgGuard()],
},
{
path: "reused-passwords-report",
@@ -58,7 +58,7 @@ const routes: Routes = [
data: {
titleId: "reusedPasswordsReport",
},
- canActivate: [IsPaidOrgGuard],
+ canActivate: [isPaidOrgGuard()],
},
{
path: "unsecured-websites-report",
@@ -66,7 +66,7 @@ const routes: Routes = [
data: {
titleId: "unsecuredWebsitesReport",
},
- canActivate: [IsPaidOrgGuard],
+ canActivate: [isPaidOrgGuard()],
},
{
path: "weak-passwords-report",
@@ -74,7 +74,7 @@ const routes: Routes = [
data: {
titleId: "weakPasswordsReport",
},
- canActivate: [IsPaidOrgGuard],
+ canActivate: [isPaidOrgGuard()],
},
],
},