1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-05 18:13:26 +00:00

Feature/families for enterprise (#1300)

* Added manual routing

* Families for enterprise/account settings (#1290)

* Added sponsored families page

* Revert "Added manual routing"

This reverts commit a970ba78ffa98545176b636630e48115efcf51cc.

* Add messages to page

* Remove stages and simplify design

* Switch to new figma design

* Add screen reader

* Add calls to server

* Reorder methods

* Used to organization filters

* Connected page to server

* Add preliminary text to subscription page

* Sponsor existing family organization flow

* Update jslib

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>

* Add revoke sponsorship flow

* Add spinner to send offer button

* Determine if subscription has sponsored items

* Work on subscription button

* Add  message for new family organization

* Families for enterprise/subscription page (#1292)

* Work on subscription button

* Determine if subscription has sponsored items

* Work on subscriptions page

* Add  message for new family organization

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>

* Families for enterprise/redeem card (#1295)

* Add toast localization message

* Use helpers to property display sponsorship items

* Split table rows into component so buttons load (#1296)

* Split table rows into component so buttons load

* Update jslib

* Families for enterprise/localizations (#1299)

* Add more localizations

* Remove unneeded comments

* Fix help article

* Run linting

* Do not show redeem button if no orgs exist to redeem

* Implement new process for accepting sponsorships

* Hide business checkbox

* Update jslib

* Removed commented code

* Remove commented html

* Cleaned up imports

* Use proper message

* Remove merge conflict message

* Remove confusing comment

* Listened to PR feedback

* Remove unused property

* Update help text

* Fix aria labels

* Add try catch

* Made toast before emit

* Minor copy changes

* Update jslib

* Remove unneeded loading

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
Justin Baur
2021-11-22 08:41:40 -05:00
committed by GitHub
parent 0ce00a15e7
commit a6abb74810
25 changed files with 700 additions and 23 deletions

View File

@@ -36,7 +36,7 @@
<small class="text-muted">{{'clientOwnerDesc' | i18n : '20'}}</small>
</div>
</div>
<div *ngIf="!providerId">
<div *ngIf="!providerId && !acceptingSponsorship">
<div class="form-group form-check">
<input id="ownedBusiness" class="form-check-input" type="checkbox" name="OwnedBusiness"
[(ngModel)]="ownedBusiness" (change)="changedOwnedBusiness()">
@@ -219,14 +219,9 @@
<hr class="my-3">
<h2 class="spaced-header mb-4">{{ (createOrganization ? 'paymentInformation' : 'billingInformation') | i18n}}
</h2>
<small class="text-muted font-italic mb-3 d-block" *ngIf="freeTrial && createOrganization; else paymentChargedImmediately">
{{'paymentChargedWithTrial' | i18n}}
<small class="text-muted font-italic mb-3 d-block">
{{paymentDesc}}
</small>
<ng-template #paymentChargedImmediately>
<small class="text-muted font-italic mb-3 d-block">
{{'paymentCharged' | i18n : (selectedPlanInterval | i18n) }}
</small>
</ng-template>
<app-payment *ngIf="createOrganization" [hideCredit]="true"></app-payment>
<app-tax-info (onCountryChanged)="changedCountry()"></app-tax-info>
<div id="price" class="my-4">

View File

@@ -48,6 +48,7 @@ export class OrganizationPlansComponent implements OnInit {
@Input() organizationId: string;
@Input() showFree = true;
@Input() showCancel = false;
@Input() acceptingSponsorship = false;
@Input() product: ProductType = ProductType.Free;
@Input() plan: PlanType = PlanType.Free;
@Input() providerId: string;
@@ -119,6 +120,10 @@ export class OrganizationPlansComponent implements OnInit {
validPlans = validPlans.filter(plan => plan.product !== ProductType.Free);
}
if (this.acceptingSponsorship) {
validPlans = validPlans.filter(plan => plan.product === ProductType.Families);
}
validPlans = validPlans
.filter(plan => !plan.legacyYear
&& !plan.disabled
@@ -189,6 +194,16 @@ export class OrganizationPlansComponent implements OnInit {
return (this.subtotal + this.taxCharges) || 0;
}
get paymentDesc() {
if (this.acceptingSponsorship) {
return this.i18nService.t('paymentSponsored');
} else if (this.freeTrial && this.createOrganization) {
return this.i18nService.t('paymentChargedWithTrial');
} else {
return this.i18nService.t('paymentCharged', this.i18nService.t(this.selectedPlanInterval));
}
}
changedProduct() {
this.plan = this.selectablePlans[0].type;
if (!this.selectedPlan.hasPremiumAccessOption) {
@@ -235,7 +250,7 @@ export class OrganizationPlansComponent implements OnInit {
}
try {
const doSubmit = async () => {
const doSubmit = async (): Promise<string> => {
let orgId: string = null;
if (this.createOrganization) {
const shareKey = await this.cryptoService.makeShareKey();
@@ -259,12 +274,16 @@ export class OrganizationPlansComponent implements OnInit {
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
this.router.navigate(['/organizations/' + orgId]);
if (!this.acceptingSponsorship) {
this.router.navigate(['/organizations/' + orgId]);
}
return orgId;
};
this.formPromise = doSubmit();
await this.formPromise;
this.onSuccess.emit();
const orgId = await this.formPromise;
this.onSuccess.emit({ organizationId: orgId });
} catch (e) {
this.logService.error(e);
}

View File

@@ -31,6 +31,9 @@
<a routerLink="emergency-access" class="list-group-item" routerLinkActive="active">
{{'emergencyAccess' | i18n}}
</a>
<a routerLink="sponsored-families" class="list-group-item" routerLinkActive="active" *ngIf="hasFamilySponsorshipAvailable">
{{'sponsoredFamilies' | i18n}}
</a>
</div>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import {
import { PlatformUtilsService } from 'jslib-common/abstractions/platformUtils.service';
import { TokenService } from 'jslib-common/abstractions/token.service';
import { UserService } from 'jslib-common/abstractions/user.service';
import { BroadcasterService } from 'jslib-angular/services/broadcaster.service';
@@ -19,9 +20,11 @@ const BroadcasterSubscriptionId = 'SettingsComponent';
export class SettingsComponent implements OnInit, OnDestroy {
premium: boolean;
selfHosted: boolean;
hasFamilySponsorshipAvailable: boolean;
constructor(private tokenService: TokenService, private broadcasterService: BroadcasterService,
private ngZone: NgZone, private platformUtilsService: PlatformUtilsService) { }
private ngZone: NgZone, private platformUtilsService: PlatformUtilsService,
private userService: UserService) { }
async ngOnInit() {
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
@@ -45,5 +48,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
async load() {
this.premium = await this.tokenService.getPremium();
this.hasFamilySponsorshipAvailable = await this.userService.canManageSponsorships();
}
}

View File

@@ -0,0 +1,59 @@
<div class="page-header">
<h1>{{'sponsoredFamilies' | i18n}}</h1>
</div>
<ng-container *ngIf="loading">
<i class="fa fa-spinner fa-spin text-muted" title="{{'loading' | i18n}}"></i>
<span class="sr-only">{{'loading' | i18n}}</span>
</ng-container>
<ng-container *ngIf="!loading">
<p>
{{'sponsoredFamiliesEligible' | i18n}}
</p>
<div>
{{'sponsoredFamiliesInclude' | i18n}}:
<ul class="inset-list">
<li>{{'sponsoredFamiliesPremiumAccess' | i18n}}</li>
<li>{{'sponsoredFamiliesSharedCollections' | i18n}}</li>
</ul>
</div>
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate *ngIf="anyOrgsAvailable">
<div *ngIf="moreThanOneOrgAvailable" class="form-group col-6">
<label for="availableSponsorshipOrg">{{ 'sponsoredFamiliesSelectOffer' | i18n}}</label>
<select id="availableSponsorshipOrg" name="Available Sponsorship Organization"
[(ngModel)]="selectedSponsorshipOrgId" class="form-control" required>
<option value="">-- {{'select' | i18n}} --</option>
<option *ngFor="let o of availableSponsorshipOrgs" [ngValue]="o.id">{{o.name}}</option>
</select>
<small>{{'sponsoredFamiliesLeaveCopy' | i18n}}</small>
</div>
<div class="form-group col-6">
<label for="accountEmail">{{'sponsoredFamiliesEmail' | i18n}}:</label>
<input id="accountEmail" class="form-control" inputmode="email" [(ngModel)]="sponsorshipEmail"
name="sponsorshipEmail" required>
</div>
<div class="form-group col-6">
<label for="friendlyName">{{'friendlyName' | i18n}}:</label>
<input id="friendlyName" class="form-control" [(ngModel)]="friendlyName" name="friendlyName" required>
<button class="btn btn-primary btn-submit mt-4" type="submit" [disabled]="form.loading">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'redeem' | i18n}}</span>
</button>
</div>
</form>
<div *ngIf="anyActiveSponsorships">
<table class="table table-hover table-list">
<thead>
<tr>
<th>{{'friendlyName' | i18n}}</th>
<th>{{'sponsoringOrg' | i18n}}</th>
<th></th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let o of activeSponsorshipOrgs">
<tr sponsoring-org-row [sponsoringOrg]="o" (sponsorshipRemoved)="load(true)"></tr>
</ng-container>
</tbody>
</table>
</div>
</ng-container>

View File

@@ -0,0 +1,90 @@
import {
Component,
OnInit,
} from '@angular/core';
import { ToasterService } from 'angular2-toaster';
import { ApiService } from 'jslib-common/abstractions/api.service';
import { I18nService } from 'jslib-common/abstractions/i18n.service';
import { SyncService } from 'jslib-common/abstractions/sync.service';
import { UserService } from 'jslib-common/abstractions/user.service';
import { PlanSponsorshipType } from 'jslib-common/enums/planSponsorshipType';
import { Organization } from 'jslib-common/models/domain/organization';
@Component({
selector: 'app-sponsored-families',
templateUrl: 'sponsored-families.component.html',
})
export class SponsoredFamiliesComponent implements OnInit {
loading = false;
availableSponsorshipOrgs: Organization[] = [];
activeSponsorshipOrgs: Organization[] = [];
selectedSponsorshipOrgId: string = '';
sponsorshipEmail: string = '';
friendlyName: string = '';
// Conditional display properties
formPromise: Promise<any>;
constructor(private userService: UserService, private apiService: ApiService,
private i18nService: I18nService, private toasterService: ToasterService,
private syncService: SyncService) { }
async ngOnInit() {
await this.load();
}
async submit() {
this.formPromise = this.apiService.postCreateSponsorship(this.selectedSponsorshipOrgId, {
sponsoredEmail: this.sponsorshipEmail,
planSponsorshipType: PlanSponsorshipType.FamiliesForEnterprise,
friendlyName: this.friendlyName,
});
await this.formPromise;
this.toasterService.popAsync('success', null, this.i18nService.t('sponsorshipCreated'));
this.formPromise = null;
this.resetForm();
await this.load(true);
}
async load(forceReload: boolean = false) {
if (this.loading) {
return;
}
this.loading = true;
if (forceReload) {
await this.syncService.fullSync(true);
}
const allOrgs = await this.userService.getAllOrganizations();
this.availableSponsorshipOrgs = allOrgs.filter(org => org.familySponsorshipAvailable);
this.activeSponsorshipOrgs = allOrgs.filter(org => org.familySponsorshipFriendlyName !== null);
if (this.availableSponsorshipOrgs.length === 1) {
this.selectedSponsorshipOrgId = this.availableSponsorshipOrgs[0].id;
}
this.loading = false;
}
private async resetForm() {
this.sponsorshipEmail = '';
this.friendlyName = '';
this.selectedSponsorshipOrgId = '';
}
get anyActiveSponsorships(): boolean {
return this.activeSponsorshipOrgs.length > 0;
}
get anyOrgsAvailable(): boolean {
return this.availableSponsorshipOrgs.length > 0;
}
get moreThanOneOrgAvailable(): boolean {
return this.availableSponsorshipOrgs.length > 1;
}
}

View File

@@ -0,0 +1,18 @@
<td>
{{sponsoringOrg.familySponsorshipFriendlyName}}
</td>
<td>{{sponsoringOrg.name}}</td>
<td class="table-action-right">
<button #resendEmailBtn [appApiAction]="resendEmailPromise" class="btn btn-outline-primary btn-submit"
[disabled]="resendEmailBtn.loading" (click)="resendEmail()"
[attr.aria-label]="'resendEmailLabel' | i18n : sponsoringOrg.familySponsorshipFriendlyName">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'resendEmail' | i18n }}</span>
</button>
<button #revokeSponsorshipBtn [appApiAction]="revokeSponsorshipPromise" class="btn btn-outline-danger btn-submit"
[disabled]="revokeSponsorshipBtn.loading" (click)="revokeSponsorship()"
[attr.aria-label]="'revokeAccount' | i18n : sponsoringOrg.familySponsorshipFriendlyName">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'remove' | i18n}}</span>
</button>
</td>

View File

@@ -0,0 +1,63 @@
import {
Component,
EventEmitter,
Input,
Output,
} from '@angular/core';
import { ToasterService } from 'angular2-toaster';
import { ApiService } from 'jslib-common/abstractions/api.service';
import { I18nService } from 'jslib-common/abstractions/i18n.service';
import { LogService } from 'jslib-common/abstractions/log.service';
import { PlatformUtilsService } from 'jslib-common/abstractions/platformUtils.service';
import { Organization } from 'jslib-common/models/domain/organization';
@Component({
selector: '[sponsoring-org-row]',
templateUrl: 'sponsoring-org-row.component.html',
})
export class SponsoringOrgRowComponent {
@Input() sponsoringOrg: Organization = null;
@Output() sponsorshipRemoved = new EventEmitter();
revokeSponsorshipPromise: Promise<any>;
resendEmailPromise: Promise<any>;
constructor(private toasterService: ToasterService, private apiService: ApiService,
private i18nService: I18nService, private logService: LogService,
private platformUtilsService: PlatformUtilsService) { }
async revokeSponsorship() {
try {
this.revokeSponsorshipPromise = this.doRevokeSponsorship();
await this.revokeSponsorshipPromise;
this.toasterService.popAsync('success', null, this.i18nService.t('reclaimedFreePlan'));
this.sponsorshipRemoved.emit();
} catch (e) {
this.logService.error(e);
}
this.revokeSponsorshipPromise = null;
}
async resendEmail() {
this.resendEmailPromise = this.apiService.postResendSponsorshipOffer(this.sponsoringOrg.id);
await this.resendEmailPromise;
this.toasterService.popAsync('success', null, this.i18nService.t('emailSent'));
this.resendEmailPromise = null;
}
private async doRevokeSponsorship() {
const isConfirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('revokeSponsorshipConfirmation'),
`${this.i18nService.t('remove')} ${this.sponsoringOrg.familySponsorshipFriendlyName}?`,
this.i18nService.t('remove'), this.i18nService.t('cancel'), 'warning');
if (!isConfirmed) {
return;
}
await this.apiService.deleteRevokeSponsorship(this.sponsoringOrg.id);
}
}