From a6abb74810580c2cd1d0b720e9e0b5baba3d5b91 Mon Sep 17 00:00:00 2001 From: Justin Baur Date: Mon, 22 Nov 2021 08:41:40 -0500 Subject: [PATCH] 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 * 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 * 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 --- jslib | 2 +- src/app/accounts/login.component.ts | 9 ++ src/app/accounts/register.component.ts | 8 + .../settings/account.component.ts | 11 +- .../delete-organization.component.html | 2 +- .../settings/delete-organization.component.ts | 13 +- .../organization-subscription.component.html | 6 + .../organization-subscription.component.ts | 31 +++- ...milies-for-enterprise-setup.component.html | 33 ++++ ...families-for-enterprise-setup.component.ts | 147 ++++++++++++++++++ src/app/oss-routing.module.ts | 8 + src/app/oss.module.ts | 7 + .../organization-plans.component.html | 11 +- .../settings/organization-plans.component.ts | 27 +++- src/app/settings/settings.component.html | 3 + src/app/settings/settings.component.ts | 6 +- .../sponsored-families.component.html | 59 +++++++ .../settings/sponsored-families.component.ts | 90 +++++++++++ .../sponsoring-org-row.component.html | 18 +++ .../settings/sponsoring-org-row.component.ts | 63 ++++++++ src/app/vault/vault.component.html | 13 +- src/app/vault/vault.component.ts | 5 + src/locales/en/messages.json | 141 +++++++++++++++++ src/scss/pages.scss | 6 + src/scss/tables.scss | 4 + 25 files changed, 700 insertions(+), 23 deletions(-) create mode 100644 src/app/organizations/sponsorships/families-for-enterprise-setup.component.html create mode 100644 src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts create mode 100644 src/app/settings/sponsored-families.component.html create mode 100644 src/app/settings/sponsored-families.component.ts create mode 100644 src/app/settings/sponsoring-org-row.component.html create mode 100644 src/app/settings/sponsoring-org-row.component.ts diff --git a/jslib b/jslib index a3e00cdc15..b4f475251a 160000 --- a/jslib +++ b/jslib @@ -1 +1 @@ -Subproject commit a3e00cdc156e89d088b339afeb3af79615d6f496 +Subproject commit b4f475251aa6817403117b71fb5a8836cdae9c75 diff --git a/src/app/accounts/login.component.ts b/src/app/accounts/login.component.ts index 00cbc485cd..44a16baaf1 100644 --- a/src/app/accounts/login.component.ts +++ b/src/app/accounts/login.component.ts @@ -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(); }); diff --git a/src/app/accounts/register.component.ts b/src/app/accounts/register.component.ts index 98eae878aa..29d2ec4032 100644 --- a/src/app/accounts/register.component.ts +++ b/src/app/accounts/register.component.ts @@ -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; } diff --git a/src/app/organizations/settings/account.component.ts b/src/app/organizations/settings/account.component.ts index fe1c4348bb..84caf39dd3 100644 --- a/src/app/organizations/settings/account.component.ts +++ b/src/app/organizations/settings/account.component.ts @@ -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(['/']); + }); }); } diff --git a/src/app/organizations/settings/delete-organization.component.html b/src/app/organizations/settings/delete-organization.component.html index 18cd32c23a..06e744c3cf 100644 --- a/src/app/organizations/settings/delete-organization.component.html +++ b/src/app/organizations/settings/delete-organization.component.html @@ -8,7 +8,7 @@ +

{{'storage' | i18n}}

{{'subscriptionStorage' | i18n : sub.maxStorageGb || 0 : sub.storageName || '0 MB'}}

diff --git a/src/app/organizations/settings/organization-subscription.component.ts b/src/app/organizations/settings/organization-subscription.component.ts index b78529b746..f8a15e62d6 100644 --- a/src/app/organizations/settings/organization-subscription.component.ts +++ b/src/app/organizations/settings/organization-subscription.component.ts @@ -38,6 +38,7 @@ export class OrganizationSubscriptionComponent implements OnInit { userOrg: Organization; + removeSponsorshipPromise: Promise; cancelPromise: Promise; reinstatePromise: Promise; @@ -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) { diff --git a/src/app/organizations/sponsorships/families-for-enterprise-setup.component.html b/src/app/organizations/sponsorships/families-for-enterprise-setup.component.html new file mode 100644 index 0000000000..82f590da5b --- /dev/null +++ b/src/app/organizations/sponsorships/families-for-enterprise-setup.component.html @@ -0,0 +1,33 @@ +
+ +
+ + {{'loading' | i18n}} +
+
+

+ {{'acceptBitwardenFamiliesHelp' | i18n}} +

+
+ + +
+
+ +
+
+ +
+
+
+ diff --git a/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts b/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts new file mode 100644 index 0000000000..5d558090a8 --- /dev/null +++ b/src/app/organizations/sponsorships/families-for-enterprise-setup.component.ts @@ -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; + + 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); + } +} diff --git a/src/app/oss-routing.module.ts b/src/app/oss-routing.module.ts index ad6ae2cf67..84a056e492 100644 --- a/src/app/oss-routing.module.ts +++ b/src/app/oss-routing.module.ts @@ -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 }, ], }, { diff --git a/src/app/oss.module.ts b/src/app/oss.module.ts index 0b046ebe26..88790771fe 100644 --- a/src/app/oss.module.ts +++ b/src/app/oss.module.ts @@ -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, diff --git a/src/app/settings/organization-plans.component.html b/src/app/settings/organization-plans.component.html index e6bb91af07..83506b783b 100644 --- a/src/app/settings/organization-plans.component.html +++ b/src/app/settings/organization-plans.component.html @@ -36,7 +36,7 @@ {{'clientOwnerDesc' | i18n : '20'}}
-
+
@@ -219,14 +219,9 @@

{{ (createOrganization ? 'paymentInformation' : 'billingInformation') | i18n}}

- - {{'paymentChargedWithTrial' | i18n}} + + {{paymentDesc}} - - - {{'paymentCharged' | i18n : (selectedPlanInterval | i18n) }} - -
diff --git a/src/app/settings/organization-plans.component.ts b/src/app/settings/organization-plans.component.ts index d2659d20f4..8d8d9183c7 100644 --- a/src/app/settings/organization-plans.component.ts +++ b/src/app/settings/organization-plans.component.ts @@ -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 => { 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); } diff --git a/src/app/settings/settings.component.html b/src/app/settings/settings.component.html index c177552963..3e8daa2f3c 100644 --- a/src/app/settings/settings.component.html +++ b/src/app/settings/settings.component.html @@ -31,6 +31,9 @@ {{'emergencyAccess' | i18n}} + + {{'sponsoredFamilies' | i18n}} +
diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index 9c934dfdab..4f89fe9aad 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -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(); } } diff --git a/src/app/settings/sponsored-families.component.html b/src/app/settings/sponsored-families.component.html new file mode 100644 index 0000000000..3de7130654 --- /dev/null +++ b/src/app/settings/sponsored-families.component.html @@ -0,0 +1,59 @@ + + + + {{'loading' | i18n}} + + +

+ {{'sponsoredFamiliesEligible' | i18n}} +

+
+ {{'sponsoredFamiliesInclude' | i18n}}: +
    +
  • {{'sponsoredFamiliesPremiumAccess' | i18n}}
  • +
  • {{'sponsoredFamiliesSharedCollections' | i18n}}
  • +
+
+
+
+ + + {{'sponsoredFamiliesLeaveCopy' | i18n}} +
+
+ + +
+
+ + + +
+
+
+ + + + + + + + + + + + + +
{{'friendlyName' | i18n}}{{'sponsoringOrg' | i18n}}
+
+
diff --git a/src/app/settings/sponsored-families.component.ts b/src/app/settings/sponsored-families.component.ts new file mode 100644 index 0000000000..08added1da --- /dev/null +++ b/src/app/settings/sponsored-families.component.ts @@ -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; + + 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; + } +} diff --git a/src/app/settings/sponsoring-org-row.component.html b/src/app/settings/sponsoring-org-row.component.html new file mode 100644 index 0000000000..497abe5c58 --- /dev/null +++ b/src/app/settings/sponsoring-org-row.component.html @@ -0,0 +1,18 @@ + + {{sponsoringOrg.familySponsorshipFriendlyName}} + +{{sponsoringOrg.name}} + + + + diff --git a/src/app/settings/sponsoring-org-row.component.ts b/src/app/settings/sponsoring-org-row.component.ts new file mode 100644 index 0000000000..5a94e7ce23 --- /dev/null +++ b/src/app/settings/sponsoring-org-row.component.ts @@ -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; + resendEmailPromise: Promise; + + 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); + } +} diff --git a/src/app/vault/vault.component.html b/src/app/vault/vault.component.html index 666bcd1576..743c1ee4de 100644 --- a/src/app/vault/vault.component.html +++ b/src/app/vault/vault.component.html @@ -73,7 +73,7 @@
-
+
{{'organizations' | i18n}}
+
{{'providers' | i18n}} diff --git a/src/app/vault/vault.component.ts b/src/app/vault/vault.component.ts index 0391bb8177..d91211eb6a 100644 --- a/src/app/vault/vault.component.ts +++ b/src/app/vault/vault.component.ts @@ -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(), diff --git a/src/locales/en/messages.json b/src/locales/en/messages.json index 731704c198..cb591aaed0 100644 --- a/src/locales/en/messages.json +++ b/src/locales/en/messages.json @@ -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." } } diff --git a/src/scss/pages.scss b/src/scss/pages.scss index 610cc6e404..f997a65116 100644 --- a/src/scss/pages.scss +++ b/src/scss/pages.scss @@ -159,6 +159,12 @@ app-user-billing { } } +app-sponsored-families { + .inset-list { + padding-left: 1.5rem; + } +} + /* Register Layout Page */ .layout { diff --git a/src/scss/tables.scss b/src/scss/tables.scss index 6b9cf59d6f..f42433cf2f 100644 --- a/src/scss/tables.scss +++ b/src/scss/tables.scss @@ -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;