1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-17773] Added "Sponsored Families" dropdown nav item in the admin console (#14029)

* Added nav item for f4e in org admin console

* shotgun surgery for adding "useAdminSponsoredFamilies" feature from the org table

* Resolved issue with members nav item also being selected when f4e is selected

* Separated out billing's logic from the org layout component

* Removed unused observable

* Moved logic to existing f4e policy service and added unit tests

* Resolved script typescript error

* Resolved goofy switchMap

---------

Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
This commit is contained in:
Conner Turnbull
2025-04-16 11:58:54 -04:00
committed by GitHub
parent f293c15f4d
commit db16c98a1d
10 changed files with 282 additions and 6 deletions

View File

@@ -19,12 +19,26 @@
*ngIf="canShowVaultTab(organization)"
>
</bit-nav-item>
<bit-nav-item
icon="bwi-user"
[text]="'members' | i18n"
route="members"
*ngIf="canShowMembersTab(organization)"
></bit-nav-item>
<ng-container *ngIf="canShowMembersTab(organization)">
<ng-container *ngIf="showSponsoredFamiliesDropdown$ | async; else regularMembersItem">
<bit-nav-group icon="bwi-user" [text]="'members' | i18n" route="members">
<bit-nav-item
[text]="'members' | i18n"
route="members"
[routerLinkActiveOptions]="{ exact: true }"
></bit-nav-item>
<bit-nav-item
[text]="'sponsoredFamilies' | i18n"
route="members/sponsored-families"
></bit-nav-item>
</bit-nav-group>
</ng-container>
<ng-template #regularMembersItem>
<bit-nav-item icon="bwi-user" [text]="'members' | i18n" route="members"></bit-nav-item>
</ng-template>
</ng-container>
<bit-nav-item
icon="bwi-users"
[text]="'groups' | i18n"

View File

@@ -29,6 +29,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { getById } from "@bitwarden/common/platform/misc";
import { BannerModule, IconModule } from "@bitwarden/components";
import { FreeFamiliesPolicyService } from "../../../billing/services/free-families-policy.service";
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
import { WebLayoutModule } from "../../../layouts/web-layout.module";
import { AdminConsoleLogo } from "../../icons/admin-console-logo";
@@ -66,6 +67,7 @@ export class OrganizationLayoutComponent implements OnInit {
showAccountDeprovisioningBanner$: Observable<boolean>;
protected isBreadcrumbEventLogsEnabled$: Observable<boolean>;
protected showSponsoredFamiliesDropdown$: Observable<boolean>;
constructor(
private route: ActivatedRoute,
@@ -76,6 +78,7 @@ export class OrganizationLayoutComponent implements OnInit {
private providerService: ProviderService,
protected bannerService: AccountDeprovisioningBannerService,
private accountService: AccountService,
private freeFamiliesPolicyService: FreeFamiliesPolicyService,
) {}
async ngOnInit() {
@@ -92,6 +95,8 @@ export class OrganizationLayoutComponent implements OnInit {
),
filter((org) => org != null),
);
this.showSponsoredFamiliesDropdown$ =
this.freeFamiliesPolicyService.showSponsoredFamiliesDropdown$(this.organization$);
this.showAccountDeprovisioningBanner$ = combineLatest([
this.bannerService.showBanner$,

View File

@@ -3,6 +3,7 @@ import { RouterModule, Routes } from "@angular/router";
import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { SponsoredFamiliesComponent } from "../../../billing/settings/sponsored-families.component";
import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
import { MembersComponent } from "./members.component";
@@ -16,6 +17,14 @@ const routes: Routes = [
titleId: "members",
},
},
{
path: "sponsored-families",
component: SponsoredFamiliesComponent,
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
data: {
titleId: "sponsoredFamilies",
},
},
];
@NgModule({

View File

@@ -0,0 +1,193 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { FreeFamiliesPolicyService } from "./free-families-policy.service";
describe("FreeFamiliesPolicyService", () => {
let service: FreeFamiliesPolicyService;
let organizationService: MockProxy<OrganizationService>;
let policyService: MockProxy<PolicyService>;
let configService: MockProxy<ConfigService>;
let accountService: FakeAccountService;
const userId = Utils.newGuid() as UserId;
beforeEach(() => {
organizationService = mock<OrganizationService>();
policyService = mock<PolicyService>();
configService = mock<ConfigService>();
accountService = mockAccountServiceWith(userId);
service = new FreeFamiliesPolicyService(
policyService,
organizationService,
accountService,
configService,
);
});
describe("showSponsoredFamiliesDropdown$", () => {
it("should return true when all conditions are met", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that meets all criteria
const organization = {
id: "org-id",
productTierType: ProductTierType.Enterprise,
useAdminSponsoredFamilies: true,
isAdmin: true,
isOwner: false,
canManageUsers: false,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(true);
});
it("should return false when organization is not Enterprise", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that is not Enterprise tier
const organization = {
id: "org-id",
productTierType: ProductTierType.Teams,
useAdminSponsoredFamilies: true,
isAdmin: true,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(false);
});
it("should return false when feature flag is disabled", async () => {
// Configure mocks to disable feature flag
configService.getFeatureFlag$.mockReturnValue(of(false));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization that meets other criteria
const organization = {
id: "org-id",
productTierType: ProductTierType.Enterprise,
useAdminSponsoredFamilies: true,
isAdmin: true,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(false);
});
it("should return false when families feature is disabled by policy", async () => {
// Configure mocks with a policy that disables the feature
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(
of([{ organizationId: "org-id", enabled: true } as Policy]),
);
// Create a test organization
const organization = {
id: "org-id",
productTierType: ProductTierType.Enterprise,
useAdminSponsoredFamilies: true,
isAdmin: true,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(false);
});
it("should return false when useAdminSponsoredFamilies is false", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization with useAdminSponsoredFamilies set to false
const organization = {
id: "org-id",
productTierType: ProductTierType.Enterprise,
useAdminSponsoredFamilies: false,
isAdmin: true,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(false);
});
it("should return true when user is an owner but not admin", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user is owner but not admin
const organization = {
id: "org-id",
productTierType: ProductTierType.Enterprise,
useAdminSponsoredFamilies: true,
isAdmin: false,
isOwner: true,
canManageUsers: false,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(true);
});
it("should return true when user can manage users but is not admin or owner", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user can manage users but is not admin or owner
const organization = {
id: "org-id",
productTierType: ProductTierType.Enterprise,
useAdminSponsoredFamilies: true,
isAdmin: false,
isOwner: false,
canManageUsers: true,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(true);
});
it("should return false when user has no admin permissions", async () => {
// Configure mocks
configService.getFeatureFlag$.mockReturnValue(of(true));
policyService.policiesByType$.mockReturnValue(of([]));
// Create a test organization where user has no admin permissions
const organization = {
id: "org-id",
productTierType: ProductTierType.Enterprise,
useAdminSponsoredFamilies: true,
isAdmin: false,
isOwner: false,
canManageUsers: false,
} as Organization;
// Test the method
const result = await firstValueFrom(service.showSponsoredFamiliesDropdown$(of(organization)));
expect(result).toBe(false);
});
});
});

View File

@@ -7,6 +7,9 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
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";
interface EnterpriseOrgStatus {
isFreeFamilyPolicyEnabled: boolean;
@@ -26,6 +29,7 @@ export class FreeFamiliesPolicyService {
private policyService: PolicyService,
private organizationService: OrganizationService,
private accountService: AccountService,
private configService: ConfigService,
) {}
organizations$ = this.accountService.activeAccount$.pipe(
@@ -42,6 +46,48 @@ export class FreeFamiliesPolicyService {
return this.getFreeFamiliesVisibility$();
}
/**
* Determines whether to show the sponsored families dropdown in the organization layout
* @param organization The organization to check
* @returns Observable<boolean> indicating whether to show the dropdown
*/
showSponsoredFamiliesDropdown$(organization: Observable<Organization>): Observable<boolean> {
const enterpriseOrganization$ = organization.pipe(
map((org) => org.productTierType === ProductTierType.Enterprise),
);
return this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => {
const policies$ = this.policyService.policiesByType$(
PolicyType.FreeFamiliesSponsorshipPolicy,
userId,
);
return combineLatest([
enterpriseOrganization$,
this.configService.getFeatureFlag$(FeatureFlag.PM17772_AdminInitiatedSponsorships),
organization,
policies$,
]).pipe(
map(([isEnterprise, featureFlagEnabled, org, policies]) => {
const familiesFeatureDisabled = policies.some(
(policy) => policy.organizationId === org.id && policy.enabled,
);
return (
isEnterprise &&
featureFlagEnabled &&
!familiesFeatureDisabled &&
org.useAdminSponsoredFamilies &&
(org.isAdmin || org.isOwner || org.canManageUsers)
);
}),
);
}),
);
}
private getFreeFamiliesVisibility$(): Observable<boolean> {
return combineLatest([
this.checkEnterpriseOrganizationsAndFetchPolicy(),

View File

@@ -58,6 +58,7 @@ describe("ORGANIZATIONS state", () => {
familySponsorshipLastSyncDate: new Date(),
userIsManagedByOrganization: false,
useRiskInsights: false,
useAdminSponsoredFamilies: false,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));

View File

@@ -60,6 +60,7 @@ export class OrganizationData {
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
constructor(
response?: ProfileOrganizationResponse,
@@ -122,6 +123,7 @@ export class OrganizationData {
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
this.useRiskInsights = response.useRiskInsights;
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
this.isMember = options.isMember;
this.isProviderUser = options.isProviderUser;

View File

@@ -90,6 +90,7 @@ export class Organization {
*/
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
constructor(obj?: OrganizationData) {
if (obj == null) {
@@ -148,6 +149,7 @@ export class Organization {
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
this.useRiskInsights = obj.useRiskInsights;
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
}
get canAccess() {

View File

@@ -55,6 +55,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
constructor(response: any) {
super(response);
@@ -121,5 +122,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
);
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
}
}

View File

@@ -34,6 +34,7 @@ export enum FeatureFlag {
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -117,6 +118,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
[FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE,
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,