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 new file mode 100644 index 0000000000..8e35c60db9 --- /dev/null +++ b/apps/web/src/app/admin-console/organizations/guards/is-enterprise-org.guard.ts @@ -0,0 +1,67 @@ +import { Injectable } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +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, + ) {} + + 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); + + if (org == null) { + return this.router.createUrlTree(["/"]); + } + + if (org.productTierType != ProductTierType.Enterprise) { + // Users without billing permission can't access billing + if (!org.canEditSubscription) { + await this.dialogService.openSimpleDialog({ + title: { key: "upgradeOrganizationEnterprise" }, + content: { key: "onlyAvailableForEnterpriseOrganization" }, + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + type: "info", + }); + return false; + } else { + const upgradeConfirmed = await this.dialogService.openSimpleDialog({ + title: { key: "upgradeOrganizationEnterprise" }, + content: { key: "onlyAvailableForEnterpriseOrganization" }, + acceptButtonText: { key: "upgradeOrganization" }, + type: "info", + icon: "bwi-arrow-circle-up", + }); + if (upgradeConfirmed) { + await this.router.navigate(["organizations", org.id, "billing", "subscription"], { + queryParams: { upgrade: true }, + }); + } + } + } + + return org.productTierType == ProductTierType.Enterprise; + } +} diff --git a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts index 765637be39..c89ff280dd 100644 --- a/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts +++ b/apps/web/src/app/admin-console/organizations/reporting/reports-home.component.ts @@ -1,8 +1,11 @@ import { Component, OnInit } from "@angular/core"; import { ActivatedRoute, NavigationEnd, Router } from "@angular/router"; -import { filter, map, Observable, startWith, concatMap } from "rxjs"; +import { filter, map, Observable, startWith, concatMap, firstValueFrom } from "rxjs"; 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 { ReportVariant, reports, ReportType, ReportEntry } from "../../../tools/reports"; @@ -14,13 +17,21 @@ export class ReportsHomeComponent implements OnInit { reports$: Observable; homepage$: Observable; + private isMemberAccessReportEnabled: boolean; + constructor( private route: ActivatedRoute, private organizationService: OrganizationService, private router: Router, + private configService: ConfigService, ) {} - ngOnInit() { + async ngOnInit() { + // TODO: Remove on "MemberAccessReport" feature flag cleanup + this.isMemberAccessReportEnabled = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.MemberAccessReport), + ); + this.homepage$ = this.router.events.pipe( filter((event) => event instanceof NavigationEnd), map((event) => this.isReportsHomepageRouteUrl((event as NavigationEnd).urlAfterRedirects)), @@ -29,16 +40,15 @@ export class ReportsHomeComponent implements OnInit { this.reports$ = this.route.params.pipe( concatMap((params) => this.organizationService.get$(params.organizationId)), - map((org) => this.buildReports(org.isFreeOrg)), + map((org) => this.buildReports(org.productTierType)), ); } - private buildReports(upgradeRequired: boolean): ReportEntry[] { - const reportRequiresUpgrade = upgradeRequired - ? ReportVariant.RequiresUpgrade - : ReportVariant.Enabled; + private buildReports(productType: ProductTierType): ReportEntry[] { + const reportRequiresUpgrade = + productType == ProductTierType.Free ? ReportVariant.RequiresUpgrade : ReportVariant.Enabled; - return [ + const reportsArray = [ { ...reports[ReportType.ExposedPasswords], variant: reportRequiresUpgrade, @@ -60,6 +70,18 @@ export class ReportsHomeComponent implements OnInit { variant: reportRequiresUpgrade, }, ]; + + if (this.isMemberAccessReportEnabled) { + reportsArray.push({ + ...reports[ReportType.MemberAccessReport], + variant: + productType == ProductTierType.Enterprise + ? ReportVariant.Enabled + : ReportVariant.RequiresEnterprise, + }); + } + + return reportsArray; } private isReportsHomepageRouteUrl(url: string): boolean { diff --git a/apps/web/src/app/tools/reports/icons/report-member-access.icon.ts b/apps/web/src/app/tools/reports/icons/report-member-access.icon.ts new file mode 100644 index 0000000000..825968cd0c --- /dev/null +++ b/apps/web/src/app/tools/reports/icons/report-member-access.icon.ts @@ -0,0 +1,14 @@ +import { svgIcon } from "@bitwarden/components"; + +export const MemberAccess = svgIcon` + + + + + + + + + + +`; diff --git a/apps/web/src/app/tools/reports/reports.ts b/apps/web/src/app/tools/reports/reports.ts index 6f802e5478..500ae23e5c 100644 --- a/apps/web/src/app/tools/reports/reports.ts +++ b/apps/web/src/app/tools/reports/reports.ts @@ -1,6 +1,7 @@ import { ReportBreach } from "./icons/report-breach.icon"; import { ReportExposedPasswords } from "./icons/report-exposed-passwords.icon"; import { ReportInactiveTwoFactor } from "./icons/report-inactive-two-factor.icon"; +import { MemberAccess } from "./icons/report-member-access.icon"; import { ReportReusedPasswords } from "./icons/report-reused-passwords.icon"; import { ReportUnsecuredWebsites } from "./icons/report-unsecured-websites.icon"; import { ReportWeakPasswords } from "./icons/report-weak-passwords.icon"; @@ -13,6 +14,7 @@ export enum ReportType { UnsecuredWebsites = "unsecuredWebsites", Inactive2fa = "inactive2fa", DataBreach = "dataBreach", + MemberAccessReport = "memberAccessReport", } type ReportWithoutVariant = Omit; @@ -54,4 +56,10 @@ export const reports: Record = { route: "breach-report", icon: ReportBreach, }, + [ReportType.MemberAccessReport]: { + title: "memberAccessReport", + description: "memberAccessReportDesc", + route: "member-access-report", + icon: MemberAccess, + }, }; diff --git a/apps/web/src/app/tools/reports/shared/models/report-variant.ts b/apps/web/src/app/tools/reports/shared/models/report-variant.ts index d011d106c6..3beba65f7d 100644 --- a/apps/web/src/app/tools/reports/shared/models/report-variant.ts +++ b/apps/web/src/app/tools/reports/shared/models/report-variant.ts @@ -2,4 +2,5 @@ export enum ReportVariant { Enabled = "Enabled", RequiresPremium = "RequiresPremium", RequiresUpgrade = "RequiresUpgrade", + RequiresEnterprise = "RequiresEnterprise", } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 87c66f9b73..bd8345ce81 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8384,6 +8384,12 @@ "message": "This email address will receive all invoices pertaining to this provider", "description": "A hint that shows up on the Provider setup page to inform the admin the billing email will receive the provider's invoices." }, + "upgradeOrganizationEnterprise": { + "message": "Identify security risks by auditing member access" + }, + "onlyAvailableForEnterpriseOrganization": { + "message": "Quickly view member access across the organization by upgrading to an Enterprise plan." + }, "date": { "message": "Date" }, @@ -8393,5 +8399,11 @@ "invoiceNumberHeader": { "message": "Invoice number", "description": "A table header for an invoice's number" + }, + "memberAccessReport": { + "message": "Member access" + }, + "memberAccessReportDesc": { + "message": "Ensure members have access to the right credentials and their accounts are secure. Use this report to obtain a CSV of member access and account configurations." } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 552712f4d0..5a06dbae20 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -19,6 +19,7 @@ export enum FeatureFlag { BulkDeviceApproval = "bulk-device-approval", EmailVerification = "email-verification", InlineMenuFieldQualification = "inline-menu-field-qualification", + MemberAccessReport = "ac-2059-member-access-report", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -48,6 +49,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.BulkDeviceApproval]: FALSE, [FeatureFlag.EmailVerification]: FALSE, [FeatureFlag.InlineMenuFieldQualification]: FALSE, + [FeatureFlag.MemberAccessReport]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;