From fa268437efe5745af22e554cc325f090dc1dfc78 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 17 Apr 2025 14:59:09 +0100 Subject: [PATCH] [PM-17774] Build page for admin sponsored families (#14243) * 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 * Add changes for the issue orgs * Added changes for the dialog * Rename the files properly * Remove the commented code * Change the implement to align with design * Add todo comments * Remove the comment todo * Fix the uni test error * Resolve the unit test * Resolve the unit test issue * Resolve the pr comments on any and route * remove the any * remove the generic validator * Resolve the unit test * Resolve the wrong message * Resolve the duplicate route --------- Co-authored-by: Conner Turnbull Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> --- .../members/members-routing.module.ts | 7 +- .../can-access-sponsored-families.guard.ts | 26 ++++ .../add-sponsorship-dialog.component.html | 45 ++++++ .../add-sponsorship-dialog.component.ts | 135 ++++++++++++++++++ .../free-bitwarden-families.component.html | 23 +++ .../free-bitwarden-families.component.ts | 62 ++++++++ ...rganization-member-families.component.html | 47 ++++++ .../organization-member-families.component.ts | 34 +++++ ...nization-sponsored-families.component.html | 87 +++++++++++ ...ganization-sponsored-families.component.ts | 39 +++++ .../billing/members/types/sponsored-family.ts | 5 + .../src/app/shared/loose-components.module.ts | 9 ++ apps/web/src/images/search.svg | 12 ++ apps/web/src/locales/en/messages.json | 30 ++++ ...organization-sponsorship-create.request.ts | 1 + 15 files changed, 559 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/billing/guards/can-access-sponsored-families.guard.ts create mode 100644 apps/web/src/app/billing/members/add-sponsorship-dialog.component.html create mode 100644 apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts create mode 100644 apps/web/src/app/billing/members/free-bitwarden-families.component.html create mode 100644 apps/web/src/app/billing/members/free-bitwarden-families.component.ts create mode 100644 apps/web/src/app/billing/members/organization-member-families.component.html create mode 100644 apps/web/src/app/billing/members/organization-member-families.component.ts create mode 100644 apps/web/src/app/billing/members/organization-sponsored-families.component.html create mode 100644 apps/web/src/app/billing/members/organization-sponsored-families.component.ts create mode 100644 apps/web/src/app/billing/members/types/sponsored-family.ts create mode 100644 apps/web/src/images/search.svg diff --git a/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts b/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts index 9666630fc08..153a2f3a956 100644 --- a/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/members/members-routing.module.ts @@ -3,9 +3,10 @@ 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 { FreeBitwardenFamiliesComponent } from "../../../billing/members/free-bitwarden-families.component"; import { organizationPermissionsGuard } from "../guards/org-permissions.guard"; +import { canAccessSponsoredFamilies } from "./../../../billing/guards/can-access-sponsored-families.guard"; import { MembersComponent } from "./members.component"; const routes: Routes = [ @@ -19,8 +20,8 @@ const routes: Routes = [ }, { path: "sponsored-families", - component: SponsoredFamiliesComponent, - canActivate: [organizationPermissionsGuard(canAccessMembersTab)], + component: FreeBitwardenFamiliesComponent, + canActivate: [organizationPermissionsGuard(canAccessMembersTab), canAccessSponsoredFamilies], data: { titleId: "sponsoredFamilies", }, diff --git a/apps/web/src/app/billing/guards/can-access-sponsored-families.guard.ts b/apps/web/src/app/billing/guards/can-access-sponsored-families.guard.ts new file mode 100644 index 00000000000..9bc6778f0b0 --- /dev/null +++ b/apps/web/src/app/billing/guards/can-access-sponsored-families.guard.ts @@ -0,0 +1,26 @@ +import { inject } from "@angular/core"; +import { ActivatedRouteSnapshot, CanActivateFn } from "@angular/router"; +import { firstValueFrom, switchMap, filter } from "rxjs"; + +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +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 { getById } from "@bitwarden/common/platform/misc"; + +import { FreeFamiliesPolicyService } from "../services/free-families-policy.service"; + +export const canAccessSponsoredFamilies: CanActivateFn = async (route: ActivatedRouteSnapshot) => { + const freeFamiliesPolicyService = inject(FreeFamiliesPolicyService); + const organizationService = inject(OrganizationService); + const accountService = inject(AccountService); + + const org = accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => organizationService.organizations$(userId)), + getById(route.params.organizationId), + filter((org): org is Organization => org !== undefined), + ); + + return await firstValueFrom(freeFamiliesPolicyService.showSponsoredFamiliesDropdown$(org)); +}; diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html new file mode 100644 index 00000000000..2dbcc577e54 --- /dev/null +++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.html @@ -0,0 +1,45 @@ +
+ + {{ "addSponsorship" | i18n }} + +
+ +
+
+ + {{ "email" | i18n }}: + + +
+
+ + {{ "notes" | i18n }}: + + +
+
+ +
+ + + + + +
+ diff --git a/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts new file mode 100644 index 00000000000..54d9ae90009 --- /dev/null +++ b/apps/web/src/app/billing/members/add-sponsorship-dialog.component.ts @@ -0,0 +1,135 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { Component } from "@angular/core"; +import { + AbstractControl, + FormBuilder, + FormControl, + FormGroup, + FormsModule, + ReactiveFormsModule, + ValidationErrors, + Validators, +} from "@angular/forms"; +import { firstValueFrom, map } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ButtonModule, DialogModule, DialogService, FormFieldModule } from "@bitwarden/components"; + +interface RequestSponsorshipForm { + sponsorshipEmail: FormControl; + sponsorshipNote: FormControl; +} + +export interface AddSponsorshipDialogResult { + action: AddSponsorshipDialogAction; + value: Partial | null; +} + +interface AddSponsorshipFormValue { + sponsorshipEmail: string; + sponsorshipNote: string; + status: string; +} + +enum AddSponsorshipDialogAction { + Saved = "saved", + Canceled = "canceled", +} + +@Component({ + templateUrl: "add-sponsorship-dialog.component.html", + standalone: true, + imports: [ + JslibModule, + ButtonModule, + DialogModule, + FormsModule, + ReactiveFormsModule, + FormFieldModule, + ], +}) +export class AddSponsorshipDialogComponent { + sponsorshipForm: FormGroup; + loading = false; + + constructor( + private dialogRef: DialogRef, + private formBuilder: FormBuilder, + private accountService: AccountService, + private i18nService: I18nService, + ) { + this.sponsorshipForm = this.formBuilder.group({ + sponsorshipEmail: new FormControl("", { + validators: [Validators.email, Validators.required], + asyncValidators: [this.validateNotCurrentUserEmail.bind(this)], + updateOn: "change", + }), + sponsorshipNote: new FormControl("", {}), + }); + } + + static open(dialogService: DialogService): DialogRef { + return dialogService.open(AddSponsorshipDialogComponent); + } + + protected async save() { + if (this.sponsorshipForm.invalid) { + return; + } + + this.loading = true; + // TODO: This is a mockup implementation - needs to be updated with actual API integration + await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call + + const formValue = this.sponsorshipForm.getRawValue(); + const dialogValue: Partial = { + status: "Sent", + sponsorshipEmail: formValue.sponsorshipEmail ?? "", + sponsorshipNote: formValue.sponsorshipNote ?? "", + }; + + this.dialogRef.close({ + action: AddSponsorshipDialogAction.Saved, + value: dialogValue, + }); + + this.loading = false; + } + + protected close = () => { + this.dialogRef.close({ action: AddSponsorshipDialogAction.Canceled, value: null }); + }; + + get sponsorshipEmailControl() { + return this.sponsorshipForm.controls.sponsorshipEmail; + } + + get sponsorshipNoteControl() { + return this.sponsorshipForm.controls.sponsorshipNote; + } + + private async validateNotCurrentUserEmail( + control: AbstractControl, + ): Promise { + const value = control.value; + if (!value) { + return null; + } + + const currentUserEmail = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.email ?? "")), + ); + + if (!currentUserEmail) { + return null; + } + + if (value.toLowerCase() === currentUserEmail.toLowerCase()) { + return { currentUserEmail: true }; + } + + return null; + } +} diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.html b/apps/web/src/app/billing/members/free-bitwarden-families.component.html new file mode 100644 index 00000000000..fe1dd15ab15 --- /dev/null +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + +

{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}

diff --git a/apps/web/src/app/billing/members/free-bitwarden-families.component.ts b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts new file mode 100644 index 00000000000..af43e5a4bc1 --- /dev/null +++ b/apps/web/src/app/billing/members/free-bitwarden-families.component.ts @@ -0,0 +1,62 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { Component, OnInit } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; + +import { FreeFamiliesPolicyService } from "../services/free-families-policy.service"; + +import { + AddSponsorshipDialogComponent, + AddSponsorshipDialogResult, +} from "./add-sponsorship-dialog.component"; +import { SponsoredFamily } from "./types/sponsored-family"; + +@Component({ + selector: "app-free-bitwarden-families", + templateUrl: "free-bitwarden-families.component.html", +}) +export class FreeBitwardenFamiliesComponent implements OnInit { + tabIndex = 0; + sponsoredFamilies: SponsoredFamily[] = []; + + constructor( + private router: Router, + private dialogService: DialogService, + private freeFamiliesPolicyService: FreeFamiliesPolicyService, + ) {} + + async ngOnInit() { + await this.preventAccessToFreeFamiliesPage(); + } + + async addSponsorship() { + const addSponsorshipDialogRef: DialogRef = + AddSponsorshipDialogComponent.open(this.dialogService); + + const dialogRef = await firstValueFrom(addSponsorshipDialogRef.closed); + + if (dialogRef?.value) { + this.sponsoredFamilies = [dialogRef.value, ...this.sponsoredFamilies]; + } + } + + removeSponsorhip(sponsorship: any) { + const index = this.sponsoredFamilies.findIndex( + (e) => e.sponsorshipEmail == sponsorship.sponsorshipEmail, + ); + this.sponsoredFamilies.splice(index, 1); + } + + private async preventAccessToFreeFamiliesPage() { + const showFreeFamiliesPage = await firstValueFrom( + this.freeFamiliesPolicyService.showFreeFamilies$, + ); + + if (!showFreeFamiliesPage) { + await this.router.navigate(["/"]); + return; + } + } +} diff --git a/apps/web/src/app/billing/members/organization-member-families.component.html b/apps/web/src/app/billing/members/organization-member-families.component.html new file mode 100644 index 00000000000..c5b7283d9d9 --- /dev/null +++ b/apps/web/src/app/billing/members/organization-member-families.component.html @@ -0,0 +1,47 @@ + + +

+ {{ "membersWithSponsoredFamilies" | i18n }} +

+ +

{{ "memberFamilies" | i18n }}

+ + @if (loading) { + + + {{ "loading" | i18n }} + + } + + @if (!loading && memberFamilies?.length > 0) { + + + + + {{ "member" | i18n }} + {{ "status" | i18n }} + + + + + @for (o of memberFamilies; let i = $index; track i) { + + + {{ o.sponsorshipEmail }} + {{ o.status }} + + + } + + +
+
+ } @else { +
+ Search +

{{ "noMemberFamilies" | i18n }}

+

{{ "noMemberFamiliesDescription" | i18n }}

+
+ } +
+
diff --git a/apps/web/src/app/billing/members/organization-member-families.component.ts b/apps/web/src/app/billing/members/organization-member-families.component.ts new file mode 100644 index 00000000000..52c95646a11 --- /dev/null +++ b/apps/web/src/app/billing/members/organization-member-families.component.ts @@ -0,0 +1,34 @@ +import { Component, Input, OnDestroy, OnInit } from "@angular/core"; +import { Subject } from "rxjs"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { SponsoredFamily } from "./types/sponsored-family"; + +@Component({ + selector: "app-organization-member-families", + templateUrl: "organization-member-families.component.html", +}) +export class OrganizationMemberFamiliesComponent implements OnInit, OnDestroy { + tabIndex = 0; + loading = false; + + @Input() memberFamilies: SponsoredFamily[] = []; + + private _destroy = new Subject(); + + constructor(private platformUtilsService: PlatformUtilsService) {} + + async ngOnInit() { + this.loading = false; + } + + ngOnDestroy(): void { + this._destroy.next(); + this._destroy.complete(); + } + + get isSelfHosted(): boolean { + return this.platformUtilsService.isSelfHost(); + } +} diff --git a/apps/web/src/app/billing/members/organization-sponsored-families.component.html b/apps/web/src/app/billing/members/organization-sponsored-families.component.html new file mode 100644 index 00000000000..7db96deb4ab --- /dev/null +++ b/apps/web/src/app/billing/members/organization-sponsored-families.component.html @@ -0,0 +1,87 @@ + + +

+ {{ "sponsorFreeBitwardenFamilies" | i18n }} +

+
+ {{ "sponsoredFamiliesInclude" | i18n }}: +
    +
  • {{ "sponsoredFamiliesPremiumAccess" | i18n }}
  • +
  • {{ "sponsoredFamiliesSharedCollections" | i18n }}
  • +
+
+ +

{{ "sponsoredBitwardenFamilies" | i18n }}

+ + @if (loading) { + + + {{ "loading" | i18n }} + + } + + @if (!loading && sponsoredFamilies?.length > 0) { + + + + + {{ "recipient" | i18n }} + {{ "status" | i18n }} + {{ "notes" | i18n }} + + + + + @for (o of sponsoredFamilies; let i = $index; track i) { + + + {{ o.sponsorshipEmail }} + {{ o.status }} + {{ o.sponsorshipNote }} + + + + + +
+ + +
+ + +
+ } +
+
+
+
+ } @else { +
+ Search +

{{ "noSponsoredFamilies" | i18n }}

+

{{ "noSponsoredFamiliesDescription" | i18n }}

+
+ } +
+
diff --git a/apps/web/src/app/billing/members/organization-sponsored-families.component.ts b/apps/web/src/app/billing/members/organization-sponsored-families.component.ts new file mode 100644 index 00000000000..7cc46634a38 --- /dev/null +++ b/apps/web/src/app/billing/members/organization-sponsored-families.component.ts @@ -0,0 +1,39 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { Subject } from "rxjs"; + +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { SponsoredFamily } from "./types/sponsored-family"; + +@Component({ + selector: "app-organization-sponsored-families", + templateUrl: "organization-sponsored-families.component.html", +}) +export class OrganizationSponsoredFamiliesComponent implements OnInit, OnDestroy { + loading = false; + tabIndex = 0; + + @Input() sponsoredFamilies: SponsoredFamily[] = []; + @Output() removeSponsorshipEvent = new EventEmitter(); + + private _destroy = new Subject(); + + constructor(private platformUtilsService: PlatformUtilsService) {} + + async ngOnInit() { + this.loading = false; + } + + get isSelfHosted(): boolean { + return this.platformUtilsService.isSelfHost(); + } + + remove(sponsorship: SponsoredFamily) { + this.removeSponsorshipEvent.emit(sponsorship); + } + + ngOnDestroy(): void { + this._destroy.next(); + this._destroy.complete(); + } +} diff --git a/apps/web/src/app/billing/members/types/sponsored-family.ts b/apps/web/src/app/billing/members/types/sponsored-family.ts new file mode 100644 index 00000000000..82d2e3948b2 --- /dev/null +++ b/apps/web/src/app/billing/members/types/sponsored-family.ts @@ -0,0 +1,5 @@ +export interface SponsoredFamily { + sponsorshipEmail?: string; + sponsorshipNote?: string; + status?: string; +} diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 5dc34b3b5b1..469ebe457d0 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -62,6 +62,9 @@ import { OrganizationBadgeModule } from "../vault/individual-vault/organization- import { PipesModule } from "../vault/individual-vault/pipes/pipes.module"; import { PurgeVaultComponent } from "../vault/settings/purge-vault.component"; +import { FreeBitwardenFamiliesComponent } from "./../billing/members/free-bitwarden-families.component"; +import { OrganizationMemberFamiliesComponent } from "./../billing/members/organization-member-families.component"; +import { OrganizationSponsoredFamiliesComponent } from "./../billing/members/organization-sponsored-families.component"; import { EnvironmentSelectorModule } from "./../components/environment-selector/environment-selector.module"; import { AccountFingerprintComponent } from "./components/account-fingerprint/account-fingerprint.component"; import { SharedModule } from "./shared.module"; @@ -128,6 +131,9 @@ import { SharedModule } from "./shared.module"; SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, + OrganizationSponsoredFamiliesComponent, + OrganizationMemberFamiliesComponent, + FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, UpdatePasswordComponent, UpdateTempPasswordComponent, @@ -175,6 +181,9 @@ import { SharedModule } from "./shared.module"; SelectableAvatarComponent, SetPasswordComponent, SponsoredFamiliesComponent, + OrganizationSponsoredFamiliesComponent, + OrganizationMemberFamiliesComponent, + FreeBitwardenFamiliesComponent, SponsoringOrgRowComponent, UpdateTempPasswordComponent, UpdatePasswordComponent, diff --git a/apps/web/src/images/search.svg b/apps/web/src/images/search.svg new file mode 100644 index 00000000000..36e0ea4bd23 --- /dev/null +++ b/apps/web/src/images/search.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 85a7b8cb927..3d6cf0f23a5 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6306,6 +6306,21 @@ "sponsoredFamilies": { "message": "Free Bitwarden Families" }, + "sponsoredBitwardenFamilies": { + "message": "Sponsored families" + }, + "noSponsoredFamilies": { + "message": "No sponsored families" + }, + "noSponsoredFamiliesDescription": { + "message": "Sponsored non-member families plans will display here" + }, + "sponsorFreeBitwardenFamilies": { + "message": "Members of your organization are eligible for Free Bitwarden Families. You can sponsor Free Bitwarden Families for employees who are not a member of your Bitwarden organization. Sponsoring a non-member requires an available seat within your organization." + }, + "sponsoredFamiliesRemoveActiveSponsorship": { + "message": "When you remove an active sponsorship, a seat within your organization will be available after the renewal date of the sponsored organization." + }, "sponsoredFamiliesEligible": { "message": "You and your family are eligible for Free Bitwarden Families. Redeem with your personal email to keep your data secure even when you are not at work." }, @@ -6321,6 +6336,18 @@ "sponsoredFamiliesSharedCollections": { "message": "Shared collections for Family secrets" }, + "memberFamilies": { + "message": "Member families" + }, + "noMemberFamilies": { + "message": "No member families" + }, + "noMemberFamiliesDescription": { + "message": "Members who have redeemed family plans will display here" + }, + "membersWithSponsoredFamilies": { + "message": "Members of your organization are eligible for Free Bitwarden Families. Here you can see members who have sponsored a Families organization." + }, "badToken": { "message": "The link is no longer valid. Please have the sponsor resend the offer." }, @@ -7984,6 +8011,9 @@ "inviteMember": { "message": "Invite member" }, + "addSponsorship": { + "message": "Add sponsorship" + }, "needsConfirmation": { "message": "Needs confirmation" }, diff --git a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts index 534afffd1bb..19e993487c2 100644 --- a/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts +++ b/libs/common/src/admin-console/models/request/organization/organization-sponsorship-create.request.ts @@ -6,4 +6,5 @@ export class OrganizationSponsorshipCreateRequest { sponsoredEmail: string; planSponsorshipType: PlanSponsorshipType; friendlyName: string; + notes?: string; }