1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-17775] Allow admin to send f4 e sponsorship (#14390)

* 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

* add validations for email

* Add changes for the autoscale

* Changes to allow admin to send F4E sponsorship

* Fix the lint errors

* Resolve the lint errors

* Fix the revokeAccount message

* Fix the lint runtime error

* Resolve the lint issues

* Remove unused components

* Changes to add isadminInitiated

* remove the FIXME comment

* Resolve the failing test

* Fix the pr comments

* Resolve the orgkey and other comments

* Resolve the lint error

* Resolve the lint error

* resolve the spelling error

* refactor the getStatus method

* Remove the deprecated method

* Resolve the unusual type casting

* revert the change

---------

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-05-01 16:36:00 +01:00
committed by GitHub
parent 1b66f0f06b
commit a7d04dc212
17 changed files with 491 additions and 322 deletions

View File

@@ -34,7 +34,15 @@
</div>
<ng-container bitDialogFooter>
<button bitButton bitFormButton type="button" buttonType="primary" (click)="save()">
<button
bitButton
bitFormButton
type="button"
buttonType="primary"
[loading]="loading"
[disabled]="loading"
(click)="save()"
>
{{ "save" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" [bitDialogClose]="false">

View File

@@ -1,5 +1,5 @@
import { DialogRef } from "@angular/cdk/dialog";
import { Component } from "@angular/core";
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import {
AbstractControl,
FormBuilder,
@@ -10,32 +10,30 @@ import {
ValidationErrors,
Validators,
} from "@angular/forms";
import { firstValueFrom, map } from "rxjs";
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PlanSponsorshipType } from "@bitwarden/common/billing/enums";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, DialogModule, DialogService, FormFieldModule } from "@bitwarden/components";
import { OrgKey } from "@bitwarden/common/types/key";
import {
ButtonModule,
DialogModule,
DialogService,
FormFieldModule,
ToastService,
} 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",
interface AddSponsorshipDialogParams {
organizationId: string;
organizationKey: OrgKey;
}
@Component({
@@ -53,54 +51,82 @@ enum AddSponsorshipDialogAction {
export class AddSponsorshipDialogComponent {
sponsorshipForm: FormGroup<RequestSponsorshipForm>;
loading = false;
organizationId: string;
organizationKey: OrgKey;
constructor(
private dialogRef: DialogRef<AddSponsorshipDialogResult>,
private dialogRef: DialogRef,
private formBuilder: FormBuilder,
private accountService: AccountService,
private i18nService: I18nService,
private organizationUserApiService: OrganizationUserApiService,
private toastService: ToastService,
private apiService: ApiService,
private encryptService: EncryptService,
@Inject(DIALOG_DATA) protected dialogParams: AddSponsorshipDialogParams,
) {
this.organizationId = this.dialogParams?.organizationId;
this.organizationKey = this.dialogParams.organizationKey;
this.sponsorshipForm = this.formBuilder.group<RequestSponsorshipForm>({
sponsorshipEmail: new FormControl<string | null>("", {
validators: [Validators.email, Validators.required],
asyncValidators: [this.validateNotCurrentUserEmail.bind(this)],
asyncValidators: [this.isOrganizationMember.bind(this)],
updateOn: "change",
}),
sponsorshipNote: new FormControl<string | null>("", {}),
});
}
static open(dialogService: DialogService): DialogRef<AddSponsorshipDialogResult> {
return dialogService.open<AddSponsorshipDialogResult>(AddSponsorshipDialogComponent);
static open(dialogService: DialogService, config: DialogConfig<AddSponsorshipDialogParams>) {
return dialogService.open(AddSponsorshipDialogComponent, {
...config,
data: config.data,
} as unknown as DialogConfig<unknown, DialogRef>);
}
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 ?? "",
};
try {
const notes = this.sponsorshipForm.value.sponsorshipNote || "";
const email = this.sponsorshipForm.value.sponsorshipEmail || "";
this.dialogRef.close({
action: AddSponsorshipDialogAction.Saved,
value: dialogValue,
});
const encryptedNotes = await this.encryptService.encryptString(notes, this.organizationKey);
const isAdminInitiated = true;
await this.apiService.postCreateSponsorship(this.organizationId, {
sponsoredEmail: email,
planSponsorshipType: PlanSponsorshipType.FamiliesForEnterprise,
friendlyName: email,
isAdminInitiated,
notes: encryptedNotes.encryptedString,
});
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("sponsorshipCreated"),
});
await this.resetForm();
} catch (e: any) {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: e?.message || this.i18nService.t("unexpectedError"),
});
}
this.loading = false;
this.dialogRef.close();
}
protected close = () => {
this.dialogRef.close({ action: AddSponsorshipDialogAction.Canceled, value: null });
};
private async resetForm() {
this.sponsorshipForm.reset();
}
get sponsorshipEmailControl() {
return this.sponsorshipForm.controls.sponsorshipEmail;
@@ -110,24 +136,21 @@ export class AddSponsorshipDialogComponent {
return this.sponsorshipForm.controls.sponsorshipNote;
}
private async validateNotCurrentUserEmail(
control: AbstractControl,
): Promise<ValidationErrors | null> {
private async isOrganizationMember(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 ?? "")),
const users = await this.organizationUserApiService.getAllMiniUserDetails(this.organizationId);
const userExists = users.data.some(
(member) => member.email.toLowerCase() === value.toLowerCase(),
);
if (!currentUserEmail) {
return null;
}
if (value.toLowerCase() === currentUserEmail.toLowerCase()) {
return { currentUserEmail: true };
if (userExists) {
return {
isOrganizationMember: {
message: this.i18nService.t("organizationHasMemberMessage", value),
},
};
}
return null;

View File

@@ -5,19 +5,95 @@
</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-container>
<ng-container>
<p bitTypography="body1">
{{ "sponsorshipFreeBitwardenFamilies" | i18n }}
</p>
<div bitTypography="body1">
{{ "sponsoredFamiliesInclude" | i18n }}:
<ul class="tw-list-outside">
<li>{{ "sponsoredFamiliesPremiumAccess" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}</li>
</ul>
</div>
<bit-tab [label]="'memberFamilies' | i18n">
<app-organization-member-families
[memberFamilies]="sponsoredFamilies"
></app-organization-member-families>
</bit-tab>
</bit-tab-group>
<h2 bitTypography="h2" class="">{{ "sponsoredBitwardenFamilies" | i18n }}</h2>
<p class="tw-px-4" bitTypography="body2">{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}</p>
@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.friendlyName }}</td>
<td bitCell [class]="o.statusClass">{{ o.statusMessage }}</td>
<td bitCell>{{ o.notes }}</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"
(click)="resendEmail(o)"
>
<i aria-hidden="true" class="bwi bwi-envelope"></i>
{{ "resendInvitation" | i18n }}
</button>
<hr class="m-0" />
<button
type="button"
bitMenuItem
[attr.aria-label]="'revokeAccountMessage' | i18n"
(click)="removeSponsorship(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 if (!loading()) {
<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">{{ "noSponsoredFamiliesMessage" | i18n }}</h4>
<p bitTypography="body2">{{ "nosponsoredFamiliesDetails" | i18n }}</p>
</div>
}
@if (!loading() && sponsoredFamilies.length > 0) {
<p bitTypography="body2">{{ "sponsoredFamiliesRemoveActiveSponsorship" | i18n }}</p>
}
</ng-container>
</bit-container>

View File

@@ -1,62 +1,259 @@
import { DialogRef } from "@angular/cdk/dialog";
import { Component, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { formatDate } from "@angular/common";
import { Component, OnInit, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute } from "@angular/router";
import { firstValueFrom, map, Observable, switchMap } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { OrganizationSponsorshipInvitesResponse } from "@bitwarden/common/billing/models/response/organization-sponsorship-invites.response";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { StateProvider } from "@bitwarden/common/platform/state";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { DialogService, ToastService } from "@bitwarden/components";
import { KeyService } from "@bitwarden/key-management";
import { FreeFamiliesPolicyService } from "../services/free-families-policy.service";
import {
AddSponsorshipDialogComponent,
AddSponsorshipDialogResult,
} from "./add-sponsorship-dialog.component";
import { SponsoredFamily } from "./types/sponsored-family";
import { AddSponsorshipDialogComponent } from "./add-sponsorship-dialog.component";
@Component({
selector: "app-free-bitwarden-families",
templateUrl: "free-bitwarden-families.component.html",
})
export class FreeBitwardenFamiliesComponent implements OnInit {
loading = signal<boolean>(true);
tabIndex = 0;
sponsoredFamilies: SponsoredFamily[] = [];
sponsoredFamilies: OrganizationSponsorshipInvitesResponse[] = [];
organizationId = "";
organizationKey$: Observable<OrgKey>;
private locale: string = "";
constructor(
private router: Router,
private route: ActivatedRoute,
private dialogService: DialogService,
private freeFamiliesPolicyService: FreeFamiliesPolicyService,
) {}
private apiService: ApiService,
private encryptService: EncryptService,
private keyService: KeyService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private logService: LogService,
private toastService: ToastService,
private organizationSponsorshipApiService: OrganizationSponsorshipApiServiceAbstraction,
private stateProvider: StateProvider,
) {
this.organizationId = this.route.snapshot.params.organizationId || "";
this.organizationKey$ = this.stateProvider.activeUserId$.pipe(
switchMap(
(userId) =>
this.keyService.orgKeys$(userId as UserId) as Observable<Record<OrganizationId, OrgKey>>,
),
map((organizationKeysById) => organizationKeysById[this.organizationId as OrganizationId]),
takeUntilDestroyed(),
);
}
async ngOnInit() {
await this.preventAccessToFreeFamiliesPage();
this.locale = await firstValueFrom(this.i18nService.locale$);
await this.loadSponsorships();
this.loading.set(false);
}
async loadSponsorships() {
if (!this.organizationId) {
return;
}
const [response, orgKey] = await Promise.all([
this.organizationSponsorshipApiService.getOrganizationSponsorship(this.organizationId),
firstValueFrom(this.organizationKey$),
]);
if (!orgKey) {
this.logService.error("Organization key not found");
return;
}
const organizationFamilies = response.data;
this.sponsoredFamilies = await Promise.all(
organizationFamilies.map(async (family) => {
let decryptedNote = "";
try {
decryptedNote = await this.encryptService.decryptString(
new EncString(family.notes),
orgKey,
);
} catch (e) {
this.logService.error(e);
}
const { statusMessage, statusClass } = this.getStatus(
this.isSelfHosted,
family.toDelete,
family.validUntil,
family.lastSyncDate,
this.locale,
);
const newFamily = {
...family,
notes: decryptedNote,
statusMessage: statusMessage || "",
statusClass: statusClass || "tw-text-success",
status: statusMessage || "",
};
return new OrganizationSponsorshipInvitesResponse(newFamily);
}),
);
}
async addSponsorship() {
const addSponsorshipDialogRef: DialogRef<AddSponsorshipDialogResult> =
AddSponsorshipDialogComponent.open(this.dialogService);
const addSponsorshipDialogRef: DialogRef = AddSponsorshipDialogComponent.open(
this.dialogService,
{
data: {
organizationId: this.organizationId,
organizationKey: await firstValueFrom(this.organizationKey$),
},
},
);
const dialogRef = await firstValueFrom(addSponsorshipDialogRef.closed);
await firstValueFrom(addSponsorshipDialogRef.closed);
if (dialogRef?.value) {
this.sponsoredFamilies = [dialogRef.value, ...this.sponsoredFamilies];
await this.loadSponsorships();
}
async removeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) {
try {
await this.doRevokeSponsorship(sponsorship);
} catch (e) {
this.logService.error(e);
}
}
removeSponsorhip(sponsorship: any) {
const index = this.sponsoredFamilies.findIndex(
(e) => e.sponsorshipEmail == sponsorship.sponsorshipEmail,
);
this.sponsoredFamilies.splice(index, 1);
get isSelfHosted(): boolean {
return this.platformUtilsService.isSelfHost();
}
private async preventAccessToFreeFamiliesPage() {
const showFreeFamiliesPage = await firstValueFrom(
this.freeFamiliesPolicyService.showFreeFamilies$,
);
async resendEmail(sponsorship: OrganizationSponsorshipInvitesResponse) {
await this.apiService.postResendSponsorshipOffer(sponsorship.sponsoringOrganizationUserId);
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("emailSent"),
});
}
if (!showFreeFamiliesPage) {
await this.router.navigate(["/"]);
private async doRevokeSponsorship(sponsorship: OrganizationSponsorshipInvitesResponse) {
const content = sponsorship.validUntil
? this.i18nService.t(
"updatedRevokeSponsorshipConfirmationForAcceptedSponsorship",
sponsorship.friendlyName,
formatDate(sponsorship.validUntil, "MM/dd/yyyy", this.locale),
)
: this.i18nService.t(
"updatedRevokeSponsorshipConfirmationForSentSponsorship",
sponsorship.friendlyName,
);
const confirmed = await this.dialogService.openSimpleDialog({
title: `${this.i18nService.t("removeSponsorship")}?`,
content,
acceptButtonText: { key: "remove" },
type: "warning",
});
if (!confirmed) {
return;
}
await this.apiService.deleteRevokeSponsorship(sponsorship.sponsoringOrganizationUserId);
this.toastService.showToast({
variant: "success",
title: undefined,
message: this.i18nService.t("reclaimedFreePlan"),
});
await this.loadSponsorships();
}
private getStatus(
selfHosted: boolean,
toDelete?: boolean,
validUntil?: Date,
lastSyncDate?: Date,
locale: string = "",
): { statusMessage: string; statusClass: "tw-text-success" | "tw-text-danger" } {
/*
* Possible Statuses:
* Requested (self-hosted only)
* Sent
* Active
* RequestRevoke
* RevokeWhenExpired
*/
if (toDelete && validUntil) {
// They want to delete but there is a valid until date which means there is an active sponsorship
return {
statusMessage: this.i18nService.t(
"revokeWhenExpired",
formatDate(validUntil, "MM/dd/yyyy", locale),
),
statusClass: "tw-text-danger",
};
}
if (toDelete) {
// They want to delete and we don't have a valid until date so we can
// this should only happen on a self-hosted install
return {
statusMessage: this.i18nService.t("requestRemoved"),
statusClass: "tw-text-danger",
};
}
if (validUntil) {
// They don't want to delete and they have a valid until date
// that means they are actively sponsoring someone
return {
statusMessage: this.i18nService.t("active"),
statusClass: "tw-text-success",
};
}
if (selfHosted && lastSyncDate) {
// We are on a self-hosted install and it has been synced but we have not gotten
// a valid until date so we can't know if they are actively sponsoring someone
return {
statusMessage: this.i18nService.t("sent"),
statusClass: "tw-text-success",
};
}
if (!selfHosted) {
// We are in cloud and all other status checks have been false therefore we have
// sent the request but it hasn't been accepted yet
return {
statusMessage: this.i18nService.t("sent"),
statusClass: "tw-text-success",
};
}
// We are on a self-hosted install and we have not synced yet
return {
statusMessage: this.i18nService.t("requested"),
statusClass: "tw-text-success",
};
}
}

View File

@@ -1,47 +0,0 @@
<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

@@ -1,34 +0,0 @@
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

@@ -1,87 +0,0 @@
<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

@@ -1,39 +0,0 @@
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

@@ -13,7 +13,7 @@
{{ "sponsoredFamiliesInclude" | i18n }}:
<ul class="tw-list-outside">
<li>{{ "sponsoredFamiliesPremiumAccess" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollections" | i18n }}</li>
<li>{{ "sponsoredFamiliesSharedCollectionsMessage" | i18n }}</li>
</ul>
</div>
<form [formGroup]="sponsorshipForm" [bitSubmit]="submit" *ngIf="anyOrgsAvailable$ | async">

View File

@@ -32,7 +32,7 @@
type="button"
bitMenuItem
(click)="revokeSponsorship()"
[attr.aria-label]="'revokeAccount' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
[attr.aria-label]="'revokeAccountMessage' | i18n: sponsoringOrg.familySponsorshipFriendlyName"
>
<span class="tw-text-danger">{{ "remove" | i18n }}</span>
</button>

View File

@@ -62,8 +62,6 @@ 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,8 +126,6 @@ import { SharedModule } from "./shared.module";
SelectableAvatarComponent,
SetPasswordComponent,
SponsoredFamiliesComponent,
OrganizationSponsoredFamiliesComponent,
OrganizationMemberFamiliesComponent,
FreeBitwardenFamiliesComponent,
SponsoringOrgRowComponent,
UpdatePasswordComponent,
@@ -176,8 +172,6 @@ import { SharedModule } from "./shared.module";
SelectableAvatarComponent,
SetPasswordComponent,
SponsoredFamiliesComponent,
OrganizationSponsoredFamiliesComponent,
OrganizationMemberFamiliesComponent,
FreeBitwardenFamiliesComponent,
SponsoringOrgRowComponent,
UpdateTempPasswordComponent,

View File

@@ -6303,13 +6303,13 @@
"sponsoredBitwardenFamilies": {
"message": "Sponsored families"
},
"noSponsoredFamilies": {
"noSponsoredFamiliesMessage": {
"message": "No sponsored families"
},
"noSponsoredFamiliesDescription": {
"nosponsoredFamiliesDetails": {
"message": "Sponsored non-member families plans will display here"
},
"sponsorFreeBitwardenFamilies": {
"sponsorshipFreeBitwardenFamilies": {
"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": {
@@ -6327,8 +6327,8 @@
"sponsoredFamiliesPremiumAccess": {
"message": "Premium access for up to 6 users"
},
"sponsoredFamiliesSharedCollections": {
"message": "Shared collections for Family secrets"
"sponsoredFamiliesSharedCollectionsMessage": {
"message": "Shared collections for family members"
},
"memberFamilies": {
"message": "Member families"
@@ -6342,6 +6342,15 @@
"membersWithSponsoredFamilies": {
"message": "Members of your organization are eligible for Free Bitwarden Families. Here you can see members who have sponsored a Families organization."
},
"organizationHasMemberMessage": {
"message": "A sponsorship cannot be sent to $EMAIL$ because they are a member of your organization.",
"placeholders": {
"email": {
"content": "$1",
"example": "mail@example.com"
}
}
},
"badToken": {
"message": "The link is no longer valid. Please have the sponsor resend the offer."
},
@@ -6393,7 +6402,7 @@
"redeemedAccount": {
"message": "Account redeemed"
},
"revokeAccount": {
"revokeAccountMessage": {
"message": "Revoke account $NAME$",
"placeholders": {
"name": {

View File

@@ -136,11 +136,13 @@ import {
import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { TaxServiceAbstraction } from "@bitwarden/common/billing/abstractions/tax.service.abstraction";
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service";
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
import { TaxService } from "@bitwarden/common/billing/services/tax.service";
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
@@ -1063,6 +1065,11 @@ const safeProviders: SafeProvider[] = [
// subscribes to sync notifications and will update itself based on that.
deps: [ApiServiceAbstraction, SyncService],
}),
safeProvider({
provide: OrganizationSponsorshipApiServiceAbstraction,
useClass: OrganizationSponsorshipApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: OrganizationBillingApiServiceAbstraction,
useClass: OrganizationBillingApiService,

View File

@@ -6,5 +6,6 @@ export class OrganizationSponsorshipCreateRequest {
sponsoredEmail: string;
planSponsorshipType: PlanSponsorshipType;
friendlyName: string;
isAdminInitiated?: boolean;
notes?: string;
}

View File

@@ -0,0 +1,8 @@
import { ListResponse } from "../../../models/response/list.response";
import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response";
export abstract class OrganizationSponsorshipApiServiceAbstraction {
abstract getOrganizationSponsorship(
sponsoredOrgId: string,
): Promise<ListResponse<OrganizationSponsorshipInvitesResponse>>;
}

View File

@@ -0,0 +1,31 @@
import { BaseResponse } from "../../../models/response/base.response";
import { PlanSponsorshipType } from "../../enums";
export class OrganizationSponsorshipInvitesResponse extends BaseResponse {
sponsoringOrganizationUserId: string;
friendlyName: string;
offeredToEmail: string;
planSponsorshipType: PlanSponsorshipType;
lastSyncDate?: Date;
validUntil?: Date;
toDelete = false;
isAdminInitiated: boolean;
notes: string;
statusMessage?: string;
statusClass?: string;
constructor(response: any) {
super(response);
this.sponsoringOrganizationUserId = this.getResponseProperty("SponsoringOrganizationUserId");
this.friendlyName = this.getResponseProperty("FriendlyName");
this.offeredToEmail = this.getResponseProperty("OfferedToEmail");
this.planSponsorshipType = this.getResponseProperty("PlanSponsorshipType");
this.lastSyncDate = this.getResponseProperty("LastSyncDate");
this.validUntil = this.getResponseProperty("ValidUntil");
this.toDelete = this.getResponseProperty("ToDelete") ?? false;
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
this.notes = this.getResponseProperty("Notes");
this.statusMessage = this.getResponseProperty("StatusMessage");
this.statusClass = this.getResponseProperty("StatusClass");
}
}

View File

@@ -0,0 +1,22 @@
import { ApiService } from "../../../abstractions/api.service";
import { ListResponse } from "../../../models/response/list.response";
import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response";
export class OrganizationSponsorshipApiService
implements OrganizationSponsorshipApiServiceAbstraction
{
constructor(private apiService: ApiService) {}
async getOrganizationSponsorship(
sponsoredOrgId: string,
): Promise<ListResponse<OrganizationSponsorshipInvitesResponse>> {
const r = await this.apiService.send(
"GET",
"/organization/sponsorship/" + sponsoredOrgId + "/sponsored",
null,
true,
true,
);
return new ListResponse(r, OrganizationSponsorshipInvitesResponse);
}
}