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:
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
59
src/app/settings/sponsored-families.component.html
Normal file
59
src/app/settings/sponsored-families.component.html
Normal 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>
|
||||
90
src/app/settings/sponsored-families.component.ts
Normal file
90
src/app/settings/sponsored-families.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/app/settings/sponsoring-org-row.component.html
Normal file
18
src/app/settings/sponsoring-org-row.component.html
Normal 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>
|
||||
63
src/app/settings/sponsoring-org-row.component.ts
Normal file
63
src/app/settings/sponsoring-org-row.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user