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 @@
+
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 {
+
+

+
{{ "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 {
+
+

+
{{ "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;
}