1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-02 08:33:43 +00:00

[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 <cturnbull@bitwarden.com>
Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com>
This commit is contained in:
cyprain-okeke
2025-04-17 14:59:09 +01:00
committed by GitHub
parent 08b966409f
commit fa268437ef
15 changed files with 559 additions and 3 deletions

View File

@@ -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",
},

View File

@@ -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));
};

View File

@@ -0,0 +1,45 @@
<form>
<bit-dialog>
<span bitDialogTitle>{{ "addSponsorship" | i18n }}</span>
<div bitDialogContent>
<form [formGroup]="sponsorshipForm">
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<div class="tw-col-span-12">
<bit-form-field>
<bit-label>{{ "email" | i18n }}:</bit-label>
<input
bitInput
inputmode="email"
formControlName="sponsorshipEmail"
[attr.aria-invalid]="sponsorshipEmailControl.invalid"
appInputStripSpaces
/>
</bit-form-field>
</div>
<div class="tw-col-span-12">
<bit-form-field>
<bit-label>{{ "notes" | i18n }}:</bit-label>
<input
bitInput
inputmode="text"
formControlName="sponsorshipNote"
[attr.aria-invalid]="sponsorshipNoteControl.invalid"
appInputStripSpaces
/>
</bit-form-field>
</div>
</div>
</form>
</div>
<ng-container bitDialogFooter>
<button bitButton bitFormButton type="button" buttonType="primary" (click)="save()">
{{ "save" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" [bitDialogClose]="false">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>
</form>

View File

@@ -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<string | null>;
sponsorshipNote: FormControl<string | null>;
}
export interface AddSponsorshipDialogResult {
action: AddSponsorshipDialogAction;
value: Partial<AddSponsorshipFormValue> | 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<RequestSponsorshipForm>;
loading = false;
constructor(
private dialogRef: DialogRef<AddSponsorshipDialogResult>,
private formBuilder: FormBuilder,
private accountService: AccountService,
private i18nService: I18nService,
) {
this.sponsorshipForm = this.formBuilder.group<RequestSponsorshipForm>({
sponsorshipEmail: new FormControl<string | null>("", {
validators: [Validators.email, Validators.required],
asyncValidators: [this.validateNotCurrentUserEmail.bind(this)],
updateOn: "change",
}),
sponsorshipNote: new FormControl<string | null>("", {}),
});
}
static open(dialogService: DialogService): DialogRef<AddSponsorshipDialogResult> {
return dialogService.open<AddSponsorshipDialogResult>(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<AddSponsorshipFormValue> = {
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<ValidationErrors | null> {
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;
}
}

View File

@@ -0,0 +1,23 @@
<app-header>
<button type="button" (click)="addSponsorship()" bitButton buttonType="primary">
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
{{ "addSponsorship" | i18n }}
</button>
</app-header>
<bit-tab-group [(selectedIndex)]="tabIndex">
<bit-tab [label]="'sponsoredBitwardenFamilies' | i18n">
<app-organization-sponsored-families
[sponsoredFamilies]="sponsoredFamilies"
(removeSponsorshipEvent)="removeSponsorhip($event)"
></app-organization-sponsored-families>
</bit-tab>
<bit-tab [label]="'memberFamilies' | i18n">
<app-organization-member-families
[memberFamilies]="sponsoredFamilies"
></app-organization-member-families>
</bit-tab>
</bit-tab-group>
<p class="tw-px-4" bitTypography="body2">{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}</p>

View File

@@ -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<AddSponsorshipDialogResult> =
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;
}
}
}

View File

@@ -0,0 +1,47 @@
<bit-container>
<ng-container>
<p bitTypography="body1">
{{ "membersWithSponsoredFamilies" | i18n }}
</p>
<h2 bitTypography="h2" class="">{{ "memberFamilies" | i18n }}</h2>
@if (loading) {
<ng-container>
<i class="bwi bwi-spinner bwi-spin tw-text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
}
@if (!loading && memberFamilies?.length > 0) {
<ng-container>
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ "member" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell></th>
</tr>
</ng-container>
<ng-template body alignContent="middle">
@for (o of memberFamilies; let i = $index; track i) {
<ng-container>
<tr bitRow>
<td bitCell>{{ o.sponsorshipEmail }}</td>
<td bitCell class="tw-text-success">{{ o.status }}</td>
</tr>
</ng-container>
}
</ng-template>
</bit-table>
<hr class="mt-0" />
</ng-container>
} @else {
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">
<img class="tw-w-32" src="./../../../images/search.svg" alt="Search" />
<h4 class="mt-3" bitTypography="h4">{{ "noMemberFamilies" | i18n }}</h4>
<p bitTypography="body2">{{ "noMemberFamiliesDescription" | i18n }}</p>
</div>
}
</ng-container>
</bit-container>

View File

@@ -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<void>();
constructor(private platformUtilsService: PlatformUtilsService) {}
async ngOnInit() {
this.loading = false;
}
ngOnDestroy(): void {
this._destroy.next();
this._destroy.complete();
}
get isSelfHosted(): boolean {
return this.platformUtilsService.isSelfHost();
}
}

View File

@@ -0,0 +1,87 @@
<bit-container>
<ng-container>
<p bitTypography="body1">
{{ "sponsorFreeBitwardenFamilies" | i18n }}
</p>
<div bitTypography="body1">
{{ "sponsoredFamiliesInclude" | i18n }}:
<ul class="tw-list-outside">
<li>{{ "sponsoredFamiliesPremiumAccess" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollections" | i18n }}</li>
</ul>
</div>
<h2 bitTypography="h2" class="">{{ "sponsoredBitwardenFamilies" | i18n }}</h2>
@if (loading) {
<ng-container>
<i class="bwi bwi-spinner bwi-spin tw-text-muted" title="{{ 'loading' | i18n }}"></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
}
@if (!loading && sponsoredFamilies?.length > 0) {
<ng-container>
<bit-table>
<ng-container header>
<tr>
<th bitCell>{{ "recipient" | i18n }}</th>
<th bitCell>{{ "status" | i18n }}</th>
<th bitCell>{{ "notes" | i18n }}</th>
<th bitCell></th>
</tr>
</ng-container>
<ng-template body alignContent="middle">
@for (o of sponsoredFamilies; let i = $index; track i) {
<ng-container>
<tr bitRow>
<td bitCell>{{ o.sponsorshipEmail }}</td>
<td bitCell class="tw-text-success">{{ o.status }}</td>
<td bitCell>{{ o.sponsorshipNote }}</td>
<td bitCell>
<button
type="button"
bitIconButton="bwi-ellipsis-v"
buttonType="main"
[bitMenuTriggerFor]="appListDropdown"
appA11yTitle="{{ 'options' | i18n }}"
></button>
<bit-menu #appListDropdown>
<button
type="button"
bitMenuItem
[attr.aria-label]="'resendEmailLabel' | i18n"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<hr class="m-0" />
<button
type="button"
bitMenuItem
[attr.aria-label]="'revokeAccount' | i18n"
(click)="remove(o)"
>
<i aria-hidden="true" class="bwi bwi-close tw-text-danger"></i>
<span class="tw-text-danger pl-1">{{ "remove" | i18n }}</span>
</button>
</bit-menu>
</td>
</tr>
</ng-container>
}
</ng-template>
</bit-table>
<hr class="mt-0" />
</ng-container>
} @else {
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">
<img class="tw-w-32" src="./../../../images/search.svg" alt="Search" />
<h4 class="mt-3" bitTypography="h4">{{ "noSponsoredFamilies" | i18n }}</h4>
<p bitTypography="body2">{{ "noSponsoredFamiliesDescription" | i18n }}</p>
</div>
}
</ng-container>
</bit-container>

View File

@@ -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<SponsoredFamily>();
private _destroy = new Subject<void>();
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();
}
}

View File

@@ -0,0 +1,5 @@
export interface SponsoredFamily {
sponsorshipEmail?: string;
sponsorshipNote?: string;
status?: string;
}

View File

@@ -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,