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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,5 +6,6 @@ export class OrganizationSponsorshipCreateRequest {
|
||||
sponsoredEmail: string;
|
||||
planSponsorshipType: PlanSponsorshipType;
|
||||
friendlyName: string;
|
||||
isAdminInitiated?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user