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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 700 additions and 23 deletions

2
jslib

@ -1 +1 @@
Subproject commit a3e00cdc156e89d088b339afeb3af79615d6f496
Subproject commit b4f475251aa6817403117b71fb5a8836cdae9c75

View File

@ -55,6 +55,15 @@ export class LoginComponent extends BaseLoginComponent {
this.stateService.save('loginRedirect',
{ route: '/settings/create-organization', qParams: { plan: qParams.org } });
}
// Are they coming from an email for sponsoring a families organization
if (qParams.sponsorshipToken != null) {
// After logging in redirect them to setup the families sponsorship
this.stateService.save('loginRedirect', {
route: '/setup/families-for-enterprise',
qParams: { token: qParams.sponsorshipToken },
});
}
await super.ngOnInit();
});

View File

@ -68,6 +68,14 @@ export class RegisterComponent extends BaseRegisterComponent {
} else {
this.referenceData.id = ('; ' + document.cookie).split('; reference=').pop().split(';').shift();
}
// Are they coming from an email for sponsoring a families organization
if (qParams.sponsorshipToken != null) {
// After logging in redirect them to setup the families sponsorship
this.stateService.save('loginRedirect', {
route: '/setup/families-for-enterprise',
qParams: { token: qParams.sponsorshipToken },
});
}
if (this.referenceData.id === '') {
this.referenceData.id = null;
}

View File

@ -4,7 +4,10 @@ import {
ViewContainerRef,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
ActivatedRoute,
Router
} from '@angular/router';
import { ToasterService } from 'angular2-toaster';
import { ModalService } from 'jslib-angular/services/modal.service';
@ -51,7 +54,8 @@ export class AccountComponent {
private apiService: ApiService, private i18nService: I18nService,
private toasterService: ToasterService, private route: ActivatedRoute,
private syncService: SyncService, private platformUtilsService: PlatformUtilsService,
private cryptoService: CryptoService, private logService: LogService) { }
private cryptoService: CryptoService, private logService: LogService,
private router: Router) { }
async ngOnInit() {
this.selfHosted = this.platformUtilsService.isSelfHost();
@ -101,6 +105,9 @@ export class AccountComponent {
async deleteOrganization() {
await this.modalService.openViewRef(DeleteOrganizationComponent, this.deleteModalRef, comp => {
comp.organizationId = this.organizationId;
comp.onSuccess.subscribe(() => {
this.router.navigate(['/']);
});
});
}

View File

@ -8,7 +8,7 @@
</button>
</div>
<div class="modal-body">
<p>{{'deleteOrganizationDesc' | i18n}}</p>
<p>{{descriptionKey | i18n}}</p>
<app-callout type="warning">{{'deleteOrganizationWarning' | i18n}}</app-callout>
<app-verify-master-password [(ngModel)]="masterPassword" ngDefaultControl name="secret">
</app-verify-master-password>

View File

@ -1,5 +1,8 @@
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import {
Component,
EventEmitter,
Output,
} from '@angular/core';
import { ToasterService } from 'angular2-toaster';
@ -16,13 +19,15 @@ import { UserVerificationService } from 'jslib-common/abstractions/userVerificat
})
export class DeleteOrganizationComponent {
organizationId: string;
descriptionKey = 'deleteOrganizationDesc';
@Output() onSuccess: EventEmitter<any> = new EventEmitter();
masterPassword: Verification;
formPromise: Promise<any>;
constructor(private apiService: ApiService, private i18nService: I18nService,
private toasterService: ToasterService, private userVerificationService: UserVerificationService,
private router: Router, private logService: LogService) { }
private logService: LogService) { }
async submit() {
try {
@ -31,7 +36,7 @@ export class DeleteOrganizationComponent {
await this.formPromise;
this.toasterService.popAsync('success', this.i18nService.t('organizationDeleted'),
this.i18nService.t('organizationDeletedDesc'));
this.router.navigate(['/']);
this.onSuccess.emit();
} catch (e) {
this.logService.error(e);
}

View File

@ -86,6 +86,12 @@
</app-adjust-subscription>
</div>
</ng-container>
<button #removeSponsorshipBtn type="button" class="btn btn-outline-danger btn-submit" (click)="removeSponsorship()"
[appApiAction]="removeSponsorshipPromise" [disabled]="removeSponsorshipBtn.loading"
*ngIf="isSponsoredSubscription">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'removeSponsorship' | i18n}}</span>
</button>
<h2 class="spaced-header">{{'storage' | i18n}}</h2>
<p>{{'subscriptionStorage' | i18n : sub.maxStorageGb || 0 : sub.storageName || '0 MB'}}</p>
<div class="progress">

View File

@ -38,6 +38,7 @@ export class OrganizationSubscriptionComponent implements OnInit {
userOrg: Organization;
removeSponsorshipPromise: Promise<any>;
cancelPromise: Promise<any>;
reinstatePromise: Promise<any>;
@ -156,6 +157,26 @@ export class OrganizationSubscriptionComponent implements OnInit {
}
}
async removeSponsorship() {
const isConfirmed = await this.platformUtilsService.showDialog(
this.i18nService.t('removeSponsorshipConfirmation'),
this.i18nService.t('removeSponsorship'),
this.i18nService.t('remove'), this.i18nService.t('cancel'), 'warning');
if (!isConfirmed) {
return;
}
try {
this.removeSponsorshipPromise = this.apiService.deleteRemoveSponsorship(this.organizationId);
await this.removeSponsorshipPromise;
this.toasterService.popAsync('success', null, this.i18nService.t('removeSponsorshipSuccess'));
await this.load();
} catch (e) {
this.logService.error(e);
}
}
get isExpired() {
return this.sub != null && this.sub.expiration != null &&
new Date(this.sub.expiration) < new Date();
@ -207,6 +228,10 @@ export class OrganizationSubscriptionComponent implements OnInit {
return this.sub.plan.hasAdditionalSeatsOption;
}
get isSponsoredSubscription(): boolean {
return this.sub.subscription?.items.some(i => i.sponsoredSubscriptionItem);
}
get canDownloadLicense() {
return (this.sub.planType !== PlanType.Free && this.subscription == null) ||
(this.subscription != null && !this.subscription.cancelled);
@ -216,7 +241,11 @@ export class OrganizationSubscriptionComponent implements OnInit {
if (this.sub.planType === PlanType.Free) {
return this.i18nService.t('subscriptionFreePlan', this.sub.seats.toString());
} else if (this.sub.planType === PlanType.FamiliesAnnually || this.sub.planType === PlanType.FamiliesAnnually2019) {
return this.i18nService.t('subscriptionFamiliesPlan', this.sub.seats.toString());
if (this.isSponsoredSubscription) {
return this.i18nService.t('subscriptionSponsoredFamiliesPlan', this.sub.seats.toString());
} else {
return this.i18nService.t('subscriptionFamiliesPlan', this.sub.seats.toString());
}
} else if (this.sub.maxAutoscaleSeats === this.sub.seats && this.sub.seats != null) {
return this.i18nService.t('subscriptionMaxReached', this.sub.seats.toString());
} else if (this.sub.maxAutoscaleSeats == null) {

View File

@ -0,0 +1,33 @@
<div class="container page-content">
<div class="page-header">
<h1>{{'sponsoredFamiliesOffer' | i18n}}</h1>
</div>
<div *ngIf="loading" class="mt-5 d-flex justify-content-center">
<i class="fa fa-spinner fa-spin fa-2x text-muted" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span class="sr-only">{{'loading' | i18n}}</span>
</div>
<form #form (ngSubmit)="submit()" [appApiAction]="formPromise" ngNativeValidate *ngIf="!loading">
<p>
<span>{{'acceptBitwardenFamiliesHelp' | i18n}}</span>
</p>
<div class="form-group col-6">
<label for="availableSponsorshipOrg">{{ 'sponsoredFamiliesSelectOffer' | i18n}}</label>
<select id="availableSponsorshipOrg" name="Available Sponsorship Organization"
[(ngModel)]="selectedFamilyOrganizationId" class="form-control" required>
<option value="" disabled>-- {{'select' | i18n}} --</option>
<option value="createNew">{{'newFamiliesOrganization' | i18n}}</option>
<option *ngFor="let o of existingFamilyOrganizations" [ngValue]="o.id">{{o.name}}</option>
</select>
</div>
<div *ngIf="showNewOrganization" class="col-12">
<app-organization-plans></app-organization-plans>
</div>
<div class="form-group col-6" *ngIf="!showNewOrganization">
<button class="btn btn-primary mt-2 btn-submit" [disabled]="form.loading" type="submit">
<i class="fa fa-spinner fa-spin" title="{{'loading' | i18n}}" aria-hidden="true"></i>
<span>{{'acceptOffer' | i18n}}</span>
</button>
</div>
</form>
</div>
<ng-template #deleteOrganizationTemplate></ng-template>

View File

@ -0,0 +1,147 @@
import {
Component,
OnInit,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import {
ActivatedRoute,
Router,
} from '@angular/router';
import {
Toast,
ToasterService,
} from 'angular2-toaster';
import { first } from 'rxjs/operators';
import { ModalService } from 'jslib-angular/services/modal.service';
import { ValidationService } from 'jslib-angular/services/validation.service';
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 { PlanType } from 'jslib-common/enums/planType';
import { ProductType } from 'jslib-common/enums/productType';
import { Organization } from 'jslib-common/models/domain/organization';
import { OrganizationSponsorshipRedeemRequest } from 'jslib-common/models/request/organization/organizationSponsorshipRedeemRequest';
import { DeleteOrganizationComponent } from 'src/app/organizations/settings/delete-organization.component';
import { OrganizationPlansComponent } from 'src/app/settings/organization-plans.component';
@Component({
selector: 'families-for-enterprise-setup',
templateUrl: 'families-for-enterprise-setup.component.html',
})
export class FamiliesForEnterpriseSetupComponent implements OnInit {
@ViewChild(OrganizationPlansComponent, { static: false })
set organizationPlansComponent(value: OrganizationPlansComponent) {
if (!value) {
return;
}
value.plan = PlanType.FamiliesAnnually;
value.product = ProductType.Families;
value.acceptingSponsorship = true;
value.onSuccess.subscribe(this.onOrganizationCreateSuccess.bind(this));
}
@ViewChild('deleteOrganizationTemplate', { read: ViewContainerRef, static: true }) deleteModalRef: ViewContainerRef;
loading = true;
formPromise: Promise<any>;
token: string;
existingFamilyOrganizations: Organization[];
showNewOrganization: boolean = false;
_organizationPlansComponent: OrganizationPlansComponent;
_selectedFamilyOrganizationId: string = '';
constructor(private router: Router, private toasterService: ToasterService,
private i18nService: I18nService, private route: ActivatedRoute,
private apiService: ApiService, private syncService: SyncService,
private validationService: ValidationService, private userService: UserService,
private modalService: ModalService) { }
async ngOnInit() {
document.body.classList.remove('layout_frontend');
this.route.queryParams.pipe(first()).subscribe(async qParams => {
const error = qParams.token == null;
if (error) {
const toast: Toast = {
type: 'error',
title: null,
body: this.i18nService.t('sponsoredFamiliesAcceptFailed'),
timeout: 10000,
};
this.toasterService.popAsync(toast);
this.router.navigate(['/']);
return;
}
this.token = qParams.token;
await this.syncService.fullSync(true);
this.loading = false;
this.existingFamilyOrganizations = (await this.userService.getAllOrganizations())
.filter(o => o.planProductType === ProductType.Families);
if (this.existingFamilyOrganizations.length === 0) {
this.selectedFamilyOrganizationId = 'createNew';
}
});
}
async submit() {
this.formPromise = this.doSubmit(this._selectedFamilyOrganizationId);
await this.formPromise;
this.formPromise = null;
}
get selectedFamilyOrganizationId() {
return this._selectedFamilyOrganizationId;
}
set selectedFamilyOrganizationId(value: string) {
this._selectedFamilyOrganizationId = value;
this.showNewOrganization = value === 'createNew';
}
private async doSubmit(organizationId: string) {
try {
const request = new OrganizationSponsorshipRedeemRequest();
request.planSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise;
request.sponsoredOrganizationId = organizationId;
await this.apiService.postRedeemSponsorship(this.token, request);
this.toasterService.popAsync('success', null, this.i18nService.t('sponsoredFamiliesOfferRedeemed'));
await this.syncService.fullSync(true);
this.router.navigate(['/']);
} catch (e) {
if (this.showNewOrganization) {
await this.modalService.openViewRef(DeleteOrganizationComponent, this.deleteModalRef, comp => {
comp.organizationId = organizationId;
comp.descriptionKey = 'orgCreatedSponsorshipInvalid';
comp.onSuccess.subscribe(() => {
this.router.navigate(['/']);
});
});
}
this.validationService.showError(this.i18nService.t('sponsorshipTokenHasExpired'));
}
}
private async onOrganizationCreateSuccess(value: any) {
// Use newly created organization id
await this.doSubmit(value.organizationId);
}
}

View File

@ -39,6 +39,7 @@ import {
TwoFactorSetupComponent as OrgTwoFactorSetupComponent,
} from './organizations/settings/two-factor-setup.component';
import { FamiliesForEnterpriseSetupComponent } from './organizations/sponsorships/families-for-enterprise-setup.component';
import { ExportComponent as OrgExportComponent } from './organizations/tools/export.component';
import {
ExposedPasswordsReportComponent as OrgExposedPasswordsReportComponent,
@ -98,6 +99,7 @@ import { Permissions } from 'jslib-common/enums/permissions';
import { EmergencyAccessViewComponent } from './settings/emergency-access-view.component';
import { EmergencyAccessComponent } from './settings/emergency-access.component';
import { SponsoredFamiliesComponent } from './settings/sponsored-families.component';
const routes: Routes = [
{
@ -223,6 +225,11 @@ const routes: Routes = [
},
],
},
{
path: 'sponsored-families',
component: SponsoredFamiliesComponent,
data: { titleId: 'sponsoredFamilies' },
},
],
},
{
@ -266,6 +273,7 @@ const routes: Routes = [
},
],
},
{ path: 'setup/families-for-enterprise', component: FamiliesForEnterpriseSetupComponent },
],
},
{

View File

@ -90,6 +90,7 @@ import {
WeakPasswordsReportComponent as OrgWeakPasswordsReportComponent,
} from './organizations/tools/weak-passwords-report.component';
import { FamiliesForEnterpriseSetupComponent } from './organizations/sponsorships/families-for-enterprise-setup.component';
import { AddEditComponent as OrgAddEditComponent } from './organizations/vault/add-edit.component';
import { AttachmentsComponent as OrgAttachmentsComponent } from './organizations/vault/attachments.component';
import { CiphersComponent as OrgCiphersComponent } from './organizations/vault/ciphers.component';
@ -130,6 +131,8 @@ import { PremiumComponent } from './settings/premium.component';
import { ProfileComponent } from './settings/profile.component';
import { PurgeVaultComponent } from './settings/purge-vault.component';
import { SettingsComponent } from './settings/settings.component';
import { SponsoredFamiliesComponent } from './settings/sponsored-families.component';
import { SponsoringOrgRowComponent } from './settings/sponsoring-org-row.component';
import { TaxInfoComponent } from './settings/tax-info.component';
import { TwoFactorAuthenticatorComponent } from './settings/two-factor-authenticator.component';
import { TwoFactorDuoComponent } from './settings/two-factor-duo.component';
@ -305,6 +308,7 @@ registerLocaleData(localeZhTw, 'zh-TW');
SetPasswordComponent,
AddCreditComponent,
AddEditComponent,
AddEditCustomFieldsComponent,
AdjustPaymentComponent,
AdjustSubscription,
AdjustStorageComponent,
@ -345,6 +349,7 @@ registerLocaleData(localeZhTw, 'zh-TW');
ExportComponent,
ExposedPasswordsReportComponent,
FallbackSrcDirective,
FamiliesForEnterpriseSetupComponent,
FolderAddEditComponent,
FooterComponent,
FrontendLayoutComponent,
@ -421,6 +426,8 @@ registerLocaleData(localeZhTw, 'zh-TW');
SendComponent,
SettingsComponent,
ShareComponent,
SponsoredFamiliesComponent,
SponsoringOrgRowComponent,
SsoComponent,
StopClickDirective,
StopPropDirective,

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);
}
}

View File

@ -73,7 +73,7 @@
</a>
</div>
</div>
<div class="card">
<div class="card mb-4">
<div class="card-header d-flex">
{{'organizations' | i18n}}
<a class="ml-auto" href="https://help.bitwarden.com/article/what-is-an-organization/"
@ -85,6 +85,17 @@
<app-organizations [vault]="true"></app-organizations>
</div>
</div>
<div class="card border-success mb-4" *ngIf="showRedeemSponsorship">
<div class="card-header bg-success text-white">
{{'freeFamiliesPlan' | i18n}}
</div>
<div class="card-body">
<p>{{'sponsoredFamiliesEligible' | i18n}}</p>
<a class="btn btn-block btn-outline-secondary" routerLink="/settings/sponsored-families">
{{'redeemNow' | i18n}}
</a>
</div>
</div>
<div class="card mt-4" *ngIf="showProviders">
<div class="card-header d-flex">
{{'providers' | i18n}}

View File

@ -64,10 +64,12 @@ export class VaultComponent implements OnInit, OnDestroy {
showBrowserOutdated = false;
showUpdateKey = false;
showPremiumCallout = false;
showRedeemSponsorship = false;
showProviders = false;
deleted: boolean = false;
trashCleanupWarning: string = null;
constructor(private syncService: SyncService, private route: ActivatedRoute,
private router: Router, private changeDetectorRef: ChangeDetectorRef,
private i18nService: I18nService, private modalService: ModalService,
@ -93,6 +95,9 @@ export class VaultComponent implements OnInit, OnDestroy {
this.showProviders = (await this.userService.getAllProviders()).length > 0;
const allOrgs = await this.userService.getAllOrganizations();
this.showRedeemSponsorship = allOrgs.some(o => o.familySponsorshipAvailable) && !allOrgs.some(o => o.familySponsorshipFriendlyName != null);
await Promise.all([
this.groupingsComponent.load(),
this.organizationsComponent.load(),

View File

@ -2659,6 +2659,9 @@
"resendInvitation": {
"message": "Resend Invitation"
},
"resendEmail": {
"message": "Resend Email"
},
"hasBeenReinvited": {
"message": "$USER$ has been reinvited.",
"placeholders": {
@ -2953,6 +2956,15 @@
}
}
},
"subscriptionSponsoredFamiliesPlan": {
"message": "Your subscription allows for a total of $COUNT$ users. Your plan is sponsored and billed to an external organization.",
"placeholders": {
"count": {
"content": "$1",
"example": "6"
}
}
},
"subscriptionMaxReached": {
"message": "Adjustments to your subscription will result in prorated changes to your billing totals. You cannot invite more than $COUNT$ users without increasing your subscription seats.",
"placeholders": {
@ -4476,6 +4488,117 @@
"ssoSettingsSaved": {
"message": "Single Sign-On configuration was saved."
},
"sponsoredFamilies": {
"message": "Free Bitwarden Families"
},
"sponsoredFamiliesEligible": {
"message": "You and your family are eligible for Free Bitwarden Families. Redeem with your personal email to keep your data secure even when you are not at work."
},
"sponsoredFamiliesEligibleCard": {
"message": "Redeem your Free Bitwarden for Families plan today to keep your data secure even when you are not at work."
},
"sponsoredFamiliesInclude": {
"message": "The Bitwarden for Families plan include"
},
"sponsoredFamiliesPremiumAccess": {
"message": "Premium access for up to 6 users"
},
"sponsoredFamiliesSharedCollections": {
"message": "Shared collections for Family secrets"
},
"reclaimedFreePlan": {
"message": "Reclaimed free plan"
},
"redeem": {
"message": "Redeem"
},
"sponsoredFamiliesSelectOffer": {
"message": "Select the organization you would like sponsored"
},
"sponsoredFamiliesEmail": {
"message": "Enter your personal email to redeem Bitwarden Families"
},
"sponsoredFamiliesLeaveCopy": {
"message": "If you leave or are removed from this organization, your Families plan will expire at the end of the billing period."
},
"acceptBitwardenFamiliesHelp": {
"message": "Accept offer for an existing organization or create a new Families organization."
},
"setupSponsoredFamiliesLoginDesc": {
"message": "You've been offered a free Bitwarden Families Plan Organization. To continue, you need to log in to the account that received the offer."
},
"sponsoredFamiliesAcceptFailed": {
"message": "Unable to accept offer. Please resend the offer email from your enterprise account and try again."
},
"sponsoredFamiliesAcceptFailedShort": {
"message": "Unable to accept offer. $DESCRIPTION$",
"placeholders": {
"description": {
"content": "$1",
"example": "You must have at least one existing Families Organization."
}
}
},
"sponsoredFamiliesOffer": {
"message": "Redeem Free Bitwarden Families Organization Offer"
},
"sponsoredFamiliesOfferRedeemed": {
"message": "Free Bitwarden Families offer successfully redeemed"
},
"redeemed": {
"message": "Redeemed"
},
"redeemedAccount": {
"message": "Redeemed Account"
},
"revokeAccount": {
"message": "Revoke account $NAME$",
"placeholders": {
"name": {
"content": "$1",
"example": "My Sponsorship Name"
}
}
},
"resendEmailLabel": {
"message": "Resend Sponsorship email to $NAME$ sponsorship",
"placeholders": {
"name": {
"content": "$1",
"example": "My Sponsorship Name"
}
}
},
"freeFamiliesPlan": {
"message": "Free Families Plan"
},
"redeemNow": {
"message": "Redeem Now"
},
"friendlyName": {
"message": "Friendly Name"
},
"removeSponsorship": {
"message": "Remove Sponsorship"
},
"removeSponsorshipConfirmation": {
"message": "After removing a sponsorship, you will be responsible for this subscription and related invoices. Are you sure you want to continue?"
},
"sponsorshipCreated": {
"message": "Sponsorhip Created"
},
"revoke": {
"message": "Revoke"
},
"emailSent": {
"message": "Email Sent"
},
"revokeSponsorshipConfirmation": {
"message": "After removing this account, the Families organization owner will be responsible for this subscription and related invoices. Are you sure you want to continue?"
},
"removeSponsorshipSuccess": {
"message": "Sponsorship Removed"
},
"ssoKeyConnectorUnavailable": {
"message": "Unable to reach the Key Connector, try again later."
},
@ -4575,6 +4698,21 @@
"migratedKeyConnector": {
"message": "Migrated to Key Connector"
},
"paymentSponsored": {
"message": "Please provide a payment method to associate with the organization. Don't worry, we won't charge you anything unless you select additional features or your sponsorship expires. "
},
"orgCreatedSponsorshipInvalid": {
"message": "The sponsorship offer has expired you may delete the organization you created to avoid a charge at the end of your 7 day trial. Otherwise you may close this prompt to keep the organization and assume billing responsibility."
},
"newFamiliesOrganization": {
"message": "New Families Organization"
},
"acceptOffer": {
"message": "Accept Offer"
},
"sponsoringOrg": {
"message": "Sponsoring Organization"
},
"keyConnectorTest": {
"message": "Test"
},
@ -4583,5 +4721,8 @@
},
"keyConnectorTestFail": {
"message": "Cannot reach Key Connector. Check URL."
},
"sponsorshipTokenHasExpired": {
"message": "The sponsorship offer has expired."
}
}

View File

@ -159,6 +159,12 @@ app-user-billing {
}
}
app-sponsored-families {
.inset-list {
padding-left: 1.5rem;
}
}
/* Register Layout Page */
.layout {

View File

@ -68,6 +68,10 @@
}
}
td.table-action-right {
text-align: right;
}
tr:not(:hover) td.table-list-options {
> .dropdown:not(.show) button:not(:focus):not(:active), > button:not(:focus):not(:active) {
@extend .sr-only;