Skip to content

Commit fa26843

Browse files
[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 <[email protected]> Co-authored-by: Conner Turnbull <[email protected]>
1 parent 08b9664 commit fa26843

15 files changed

+559
-3
lines changed

apps/web/src/app/admin-console/organizations/members/members-routing.module.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import { RouterModule, Routes } from "@angular/router";
33

44
import { canAccessMembersTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
55

6-
import { SponsoredFamiliesComponent } from "../../../billing/settings/sponsored-families.component";
6+
import { FreeBitwardenFamiliesComponent } from "../../../billing/members/free-bitwarden-families.component";
77
import { organizationPermissionsGuard } from "../guards/org-permissions.guard";
88

9+
import { canAccessSponsoredFamilies } from "./../../../billing/guards/can-access-sponsored-families.guard";
910
import { MembersComponent } from "./members.component";
1011

1112
const routes: Routes = [
@@ -19,8 +20,8 @@ const routes: Routes = [
1920
},
2021
{
2122
path: "sponsored-families",
22-
component: SponsoredFamiliesComponent,
23-
canActivate: [organizationPermissionsGuard(canAccessMembersTab)],
23+
component: FreeBitwardenFamiliesComponent,
24+
canActivate: [organizationPermissionsGuard(canAccessMembersTab), canAccessSponsoredFamilies],
2425
data: {
2526
titleId: "sponsoredFamilies",
2627
},
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { inject } from "@angular/core";
2+
import { ActivatedRouteSnapshot, CanActivateFn } from "@angular/router";
3+
import { firstValueFrom, switchMap, filter } from "rxjs";
4+
5+
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
6+
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
7+
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
8+
import { getUserId } from "@bitwarden/common/auth/services/account.service";
9+
import { getById } from "@bitwarden/common/platform/misc";
10+
11+
import { FreeFamiliesPolicyService } from "../services/free-families-policy.service";
12+
13+
export const canAccessSponsoredFamilies: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
14+
const freeFamiliesPolicyService = inject(FreeFamiliesPolicyService);
15+
const organizationService = inject(OrganizationService);
16+
const accountService = inject(AccountService);
17+
18+
const org = accountService.activeAccount$.pipe(
19+
getUserId,
20+
switchMap((userId) => organizationService.organizations$(userId)),
21+
getById(route.params.organizationId),
22+
filter((org): org is Organization => org !== undefined),
23+
);
24+
25+
return await firstValueFrom(freeFamiliesPolicyService.showSponsoredFamiliesDropdown$(org));
26+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<form>
2+
<bit-dialog>
3+
<span bitDialogTitle>{{ "addSponsorship" | i18n }}</span>
4+
5+
<div bitDialogContent>
6+
<form [formGroup]="sponsorshipForm">
7+
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
8+
<div class="tw-col-span-12">
9+
<bit-form-field>
10+
<bit-label>{{ "email" | i18n }}:</bit-label>
11+
<input
12+
bitInput
13+
inputmode="email"
14+
formControlName="sponsorshipEmail"
15+
[attr.aria-invalid]="sponsorshipEmailControl.invalid"
16+
appInputStripSpaces
17+
/>
18+
</bit-form-field>
19+
</div>
20+
<div class="tw-col-span-12">
21+
<bit-form-field>
22+
<bit-label>{{ "notes" | i18n }}:</bit-label>
23+
<input
24+
bitInput
25+
inputmode="text"
26+
formControlName="sponsorshipNote"
27+
[attr.aria-invalid]="sponsorshipNoteControl.invalid"
28+
appInputStripSpaces
29+
/>
30+
</bit-form-field>
31+
</div>
32+
</div>
33+
</form>
34+
</div>
35+
36+
<ng-container bitDialogFooter>
37+
<button bitButton bitFormButton type="button" buttonType="primary" (click)="save()">
38+
{{ "save" | i18n }}
39+
</button>
40+
<button bitButton type="button" buttonType="secondary" [bitDialogClose]="false">
41+
{{ "cancel" | i18n }}
42+
</button>
43+
</ng-container>
44+
</bit-dialog>
45+
</form>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { DialogRef } from "@angular/cdk/dialog";
2+
import { Component } from "@angular/core";
3+
import {
4+
AbstractControl,
5+
FormBuilder,
6+
FormControl,
7+
FormGroup,
8+
FormsModule,
9+
ReactiveFormsModule,
10+
ValidationErrors,
11+
Validators,
12+
} from "@angular/forms";
13+
import { firstValueFrom, map } from "rxjs";
14+
15+
import { JslibModule } from "@bitwarden/angular/jslib.module";
16+
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
17+
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
18+
import { ButtonModule, DialogModule, DialogService, FormFieldModule } from "@bitwarden/components";
19+
20+
interface RequestSponsorshipForm {
21+
sponsorshipEmail: FormControl<string | null>;
22+
sponsorshipNote: FormControl<string | null>;
23+
}
24+
25+
export interface AddSponsorshipDialogResult {
26+
action: AddSponsorshipDialogAction;
27+
value: Partial<AddSponsorshipFormValue> | null;
28+
}
29+
30+
interface AddSponsorshipFormValue {
31+
sponsorshipEmail: string;
32+
sponsorshipNote: string;
33+
status: string;
34+
}
35+
36+
enum AddSponsorshipDialogAction {
37+
Saved = "saved",
38+
Canceled = "canceled",
39+
}
40+
41+
@Component({
42+
templateUrl: "add-sponsorship-dialog.component.html",
43+
standalone: true,
44+
imports: [
45+
JslibModule,
46+
ButtonModule,
47+
DialogModule,
48+
FormsModule,
49+
ReactiveFormsModule,
50+
FormFieldModule,
51+
],
52+
})
53+
export class AddSponsorshipDialogComponent {
54+
sponsorshipForm: FormGroup<RequestSponsorshipForm>;
55+
loading = false;
56+
57+
constructor(
58+
private dialogRef: DialogRef<AddSponsorshipDialogResult>,
59+
private formBuilder: FormBuilder,
60+
private accountService: AccountService,
61+
private i18nService: I18nService,
62+
) {
63+
this.sponsorshipForm = this.formBuilder.group<RequestSponsorshipForm>({
64+
sponsorshipEmail: new FormControl<string | null>("", {
65+
validators: [Validators.email, Validators.required],
66+
asyncValidators: [this.validateNotCurrentUserEmail.bind(this)],
67+
updateOn: "change",
68+
}),
69+
sponsorshipNote: new FormControl<string | null>("", {}),
70+
});
71+
}
72+
73+
static open(dialogService: DialogService): DialogRef<AddSponsorshipDialogResult> {
74+
return dialogService.open<AddSponsorshipDialogResult>(AddSponsorshipDialogComponent);
75+
}
76+
77+
protected async save() {
78+
if (this.sponsorshipForm.invalid) {
79+
return;
80+
}
81+
82+
this.loading = true;
83+
// TODO: This is a mockup implementation - needs to be updated with actual API integration
84+
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
85+
86+
const formValue = this.sponsorshipForm.getRawValue();
87+
const dialogValue: Partial<AddSponsorshipFormValue> = {
88+
status: "Sent",
89+
sponsorshipEmail: formValue.sponsorshipEmail ?? "",
90+
sponsorshipNote: formValue.sponsorshipNote ?? "",
91+
};
92+
93+
this.dialogRef.close({
94+
action: AddSponsorshipDialogAction.Saved,
95+
value: dialogValue,
96+
});
97+
98+
this.loading = false;
99+
}
100+
101+
protected close = () => {
102+
this.dialogRef.close({ action: AddSponsorshipDialogAction.Canceled, value: null });
103+
};
104+
105+
get sponsorshipEmailControl() {
106+
return this.sponsorshipForm.controls.sponsorshipEmail;
107+
}
108+
109+
get sponsorshipNoteControl() {
110+
return this.sponsorshipForm.controls.sponsorshipNote;
111+
}
112+
113+
private async validateNotCurrentUserEmail(
114+
control: AbstractControl,
115+
): Promise<ValidationErrors | null> {
116+
const value = control.value;
117+
if (!value) {
118+
return null;
119+
}
120+
121+
const currentUserEmail = await firstValueFrom(
122+
this.accountService.activeAccount$.pipe(map((a) => a?.email ?? "")),
123+
);
124+
125+
if (!currentUserEmail) {
126+
return null;
127+
}
128+
129+
if (value.toLowerCase() === currentUserEmail.toLowerCase()) {
130+
return { currentUserEmail: true };
131+
}
132+
133+
return null;
134+
}
135+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<app-header>
2+
<button type="button" (click)="addSponsorship()" bitButton buttonType="primary">
3+
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
4+
{{ "addSponsorship" | i18n }}
5+
</button>
6+
</app-header>
7+
8+
<bit-tab-group [(selectedIndex)]="tabIndex">
9+
<bit-tab [label]="'sponsoredBitwardenFamilies' | i18n">
10+
<app-organization-sponsored-families
11+
[sponsoredFamilies]="sponsoredFamilies"
12+
(removeSponsorshipEvent)="removeSponsorhip($event)"
13+
></app-organization-sponsored-families>
14+
</bit-tab>
15+
16+
<bit-tab [label]="'memberFamilies' | i18n">
17+
<app-organization-member-families
18+
[memberFamilies]="sponsoredFamilies"
19+
></app-organization-member-families>
20+
</bit-tab>
21+
</bit-tab-group>
22+
23+
<p class="tw-px-4" bitTypography="body2">{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { DialogRef } from "@angular/cdk/dialog";
2+
import { Component, OnInit } from "@angular/core";
3+
import { Router } from "@angular/router";
4+
import { firstValueFrom } from "rxjs";
5+
6+
import { DialogService } from "@bitwarden/components";
7+
8+
import { FreeFamiliesPolicyService } from "../services/free-families-policy.service";
9+
10+
import {
11+
AddSponsorshipDialogComponent,
12+
AddSponsorshipDialogResult,
13+
} from "./add-sponsorship-dialog.component";
14+
import { SponsoredFamily } from "./types/sponsored-family";
15+
16+
@Component({
17+
selector: "app-free-bitwarden-families",
18+
templateUrl: "free-bitwarden-families.component.html",
19+
})
20+
export class FreeBitwardenFamiliesComponent implements OnInit {
21+
tabIndex = 0;
22+
sponsoredFamilies: SponsoredFamily[] = [];
23+
24+
constructor(
25+
private router: Router,
26+
private dialogService: DialogService,
27+
private freeFamiliesPolicyService: FreeFamiliesPolicyService,
28+
) {}
29+
30+
async ngOnInit() {
31+
await this.preventAccessToFreeFamiliesPage();
32+
}
33+
34+
async addSponsorship() {
35+
const addSponsorshipDialogRef: DialogRef<AddSponsorshipDialogResult> =
36+
AddSponsorshipDialogComponent.open(this.dialogService);
37+
38+
const dialogRef = await firstValueFrom(addSponsorshipDialogRef.closed);
39+
40+
if (dialogRef?.value) {
41+
this.sponsoredFamilies = [dialogRef.value, ...this.sponsoredFamilies];
42+
}
43+
}
44+
45+
removeSponsorhip(sponsorship: any) {
46+
const index = this.sponsoredFamilies.findIndex(
47+
(e) => e.sponsorshipEmail == sponsorship.sponsorshipEmail,
48+
);
49+
this.sponsoredFamilies.splice(index, 1);
50+
}
51+
52+
private async preventAccessToFreeFamiliesPage() {
53+
const showFreeFamiliesPage = await firstValueFrom(
54+
this.freeFamiliesPolicyService.showFreeFamilies$,
55+
);
56+
57+
if (!showFreeFamiliesPage) {
58+
await this.router.navigate(["/"]);
59+
return;
60+
}
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<bit-container>
2+
<ng-container>
3+
<p bitTypography="body1">
4+
{{ "membersWithSponsoredFamilies" | i18n }}
5+
</p>
6+
7+
<h2 bitTypography="h2" class="">{{ "memberFamilies" | i18n }}</h2>
8+
9+
@if (loading) {
10+
<ng-container>
11+
<i class="bwi bwi-spinner bwi-spin tw-text-muted" title="{{ 'loading' | i18n }}"></i>
12+
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
13+
</ng-container>
14+
}
15+
16+
@if (!loading && memberFamilies?.length > 0) {
17+
<ng-container>
18+
<bit-table>
19+
<ng-container header>
20+
<tr>
21+
<th bitCell>{{ "member" | i18n }}</th>
22+
<th bitCell>{{ "status" | i18n }}</th>
23+
<th bitCell></th>
24+
</tr>
25+
</ng-container>
26+
<ng-template body alignContent="middle">
27+
@for (o of memberFamilies; let i = $index; track i) {
28+
<ng-container>
29+
<tr bitRow>
30+
<td bitCell>{{ o.sponsorshipEmail }}</td>
31+
<td bitCell class="tw-text-success">{{ o.status }}</td>
32+
</tr>
33+
</ng-container>
34+
}
35+
</ng-template>
36+
</bit-table>
37+
<hr class="mt-0" />
38+
</ng-container>
39+
} @else {
40+
<div class="tw-my-5 tw-py-5 tw-flex tw-flex-col tw-items-center">
41+
<img class="tw-w-32" src="./../../../images/search.svg" alt="Search" />
42+
<h4 class="mt-3" bitTypography="h4">{{ "noMemberFamilies" | i18n }}</h4>
43+
<p bitTypography="body2">{{ "noMemberFamiliesDescription" | i18n }}</p>
44+
</div>
45+
}
46+
</ng-container>
47+
</bit-container>

0 commit comments

Comments
 (0)