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:
@@ -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"
|
||||
|
||||
@@ -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$,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user