diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 92e7930f..63a4d71e 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -106,6 +106,7 @@ import { DeauthorizeSessionsComponent } from './settings/deauthorize-sessions.co import { DeleteAccountComponent } from './settings/delete-account.component'; import { DomainRulesComponent } from './settings/domain-rules.component'; import { OptionsComponent } from './settings/options.component'; +import { OrganizationPlansComponent } from './settings/organization-plans.component'; import { OrganizationsComponent } from './settings/organizations.component'; import { PaymentComponent } from './settings/payment.component'; import { PremiumComponent } from './settings/premium.component'; @@ -284,6 +285,7 @@ registerLocaleData(localeZhTw, 'zh-TW'); OrgAddEditComponent, OrgApiKeyComponent, OrganizationBillingComponent, + OrganizationPlansComponent, OrganizationSubscriptionComponent, OrgAttachmentsComponent, OrgCiphersComponent, diff --git a/src/app/organizations/settings/change-plan.component.html b/src/app/organizations/settings/change-plan.component.html index 8aef7f81..4b6b78a5 100644 --- a/src/app/organizations/settings/change-plan.component.html +++ b/src/app/organizations/settings/change-plan.component.html @@ -1,14 +1,10 @@ -
+
-

{{'changeBillingPlan' | i18n}}

- - +

{{'changeBillingPlan' | i18n}}

+ +
- +
diff --git a/src/app/settings/create-organization.component.html b/src/app/settings/create-organization.component.html index 7b808d6a..d98249cd 100644 --- a/src/app/settings/create-organization.component.html +++ b/src/app/settings/create-organization.component.html @@ -2,214 +2,4 @@

{{'newOrganization' | i18n}}

{{'newOrganizationDesc' | i18n}}

- -

{{'uploadLicenseFileOrg' | i18n}}

-
-
- - - {{'licenseFileDesc' | i18n : 'bitwarden_organization_license.json'}} -
- -
-
-
-

{{'generalInformation' | i18n}}

-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
-
-

{{'chooseYourPlan' | i18n}}

-
- - -
-
- - -
-
- - -
-
- - -
- - -

{{'users' | i18n}}

-
-
- - - {{'userSeatsHowManyDesc' | i18n}} -
-
-
-

{{'addons' | i18n}}

-
-
- - - {{'userSeatsAdditionalDesc' | i18n : plans[plan].baseSeats : (plans[plan].seatPrice | currency:'$')}} -
-
-
-
- - - {{'additionalStorageIntervalDesc' | i18n : '1 GB' : (storageGb.price | currency:'$') : ('month' | i18n)}} -
-
-
-
-
- - -
- {{'premiumAccessDesc' | i18n : (3.33 | currency:'$') : ('month' | i18n)}} -
-
-

{{'summary' | i18n}}

-
- - -
-
- - -
-
-
- {{'total' | i18n}}: {{total | currency:'USD $'}} /{{interval | i18n}} -
- {{'paymentChargedWithTrial' | i18n : (interval | i18n) }} -

{{'paymentInformation' | i18n}}

- -
-
- -
-
+ diff --git a/src/app/settings/create-organization.component.ts b/src/app/settings/create-organization.component.ts index f25662d3..79d649df 100644 --- a/src/app/settings/create-organization.component.ts +++ b/src/app/settings/create-organization.component.ts @@ -3,243 +3,27 @@ import { OnInit, ViewChild, } from '@angular/core'; -import { - ActivatedRoute, - Router, -} from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; -import { ToasterService } from 'angular2-toaster'; -import { Angulartics2 } from 'angulartics2'; - -import { ApiService } from 'jslib/abstractions/api.service'; -import { CryptoService } from 'jslib/abstractions/crypto.service'; -import { I18nService } from 'jslib/abstractions/i18n.service'; -import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; -import { SyncService } from 'jslib/abstractions/sync.service'; - -import { PaymentComponent } from './payment.component'; - -import { PlanType } from 'jslib/enums/planType'; -import { OrganizationCreateRequest } from 'jslib/models/request/organizationCreateRequest'; +import { OrganizationPlansComponent } from './organization-plans.component'; @Component({ selector: 'app-create-organization', templateUrl: 'create-organization.component.html', }) export class CreateOrganizationComponent implements OnInit { - @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; + @ViewChild(OrganizationPlansComponent) orgPlansComponent: OrganizationPlansComponent; - selfHosted = false; - ownedBusiness = false; - premiumAccessAddon = false; - storageGbPriceMonthly = 0.33; - additionalStorage = 0; - additionalSeats = 0; - plan = 'free'; - interval = 'year'; - name: string; - billingEmail: string; - businessName: string; - - storageGb: any = { - price: 0.33, - monthlyPrice: 0.50, - yearlyPrice: 4, - }; - - plans: any = { - free: { - basePrice: 0, - noAdditionalSeats: true, - noPayment: true, - }, - families: { - basePrice: 1, - annualBasePrice: 12, - baseSeats: 5, - noAdditionalSeats: true, - annualPlanType: PlanType.FamiliesAnnually, - canBuyPremiumAccessAddon: true, - }, - teams: { - basePrice: 5, - annualBasePrice: 60, - monthlyBasePrice: 8, - baseSeats: 5, - seatPrice: 2, - annualSeatPrice: 24, - monthlySeatPrice: 2.5, - monthPlanType: PlanType.TeamsMonthly, - annualPlanType: PlanType.TeamsAnnually, - }, - enterprise: { - seatPrice: 3, - annualSeatPrice: 36, - monthlySeatPrice: 4, - monthPlanType: PlanType.EnterpriseMonthly, - annualPlanType: PlanType.EnterpriseAnnually, - }, - }; - - formPromise: Promise; - - constructor(private apiService: ApiService, private i18nService: I18nService, - private analytics: Angulartics2, private toasterService: ToasterService, - platformUtilsService: PlatformUtilsService, private cryptoService: CryptoService, - private router: Router, private syncService: SyncService, - private route: ActivatedRoute) { - this.selfHosted = platformUtilsService.isSelfHost(); - } + constructor(private route: ActivatedRoute) { } ngOnInit() { const queryParamsSub = this.route.queryParams.subscribe(async (qParams) => { if (qParams.plan === 'families' || qParams.plan === 'teams' || qParams.plan === 'enterprise') { - this.plan = qParams.plan; + this.orgPlansComponent.plan = qParams.plan; } if (queryParamsSub != null) { queryParamsSub.unsubscribe(); } }); } - - async submit() { - let files: FileList = null; - if (this.selfHosted) { - const fileEl = document.getElementById('file') as HTMLInputElement; - files = fileEl.files; - if (files == null || files.length === 0) { - this.toasterService.popAsync('error', this.i18nService.t('errorOccurred'), - this.i18nService.t('selectFile')); - return; - } - } - - let key: string = null; - let collectionCt: string = null; - - try { - this.formPromise = this.cryptoService.makeShareKey().then((shareKey) => { - key = shareKey[0].encryptedString; - return this.cryptoService.encrypt(this.i18nService.t('defaultCollection'), shareKey[1]); - }).then((collection) => { - collectionCt = collection.encryptedString; - if (this.selfHosted || this.plan === 'free') { - return null; - } else { - return this.paymentComponent.createPaymentToken(); - } - }).then((tokenResult) => { - if (this.selfHosted) { - const fd = new FormData(); - fd.append('license', files[0]); - fd.append('key', key); - fd.append('collectionName', collectionCt); - return this.apiService.postOrganizationLicense(fd); - } else { - const request = new OrganizationCreateRequest(); - request.key = key; - request.collectionName = collectionCt; - request.name = this.name; - request.billingEmail = this.billingEmail; - - if (this.plan === 'free') { - request.planType = PlanType.Free; - } else { - request.paymentToken = tokenResult[0]; - request.paymentMethodType = tokenResult[1]; - request.businessName = this.ownedBusiness ? this.businessName : null; - request.additionalSeats = this.additionalSeats; - request.additionalStorageGb = this.additionalStorage; - request.premiumAccessAddon = this.plans[this.plan].canBuyPremiumAccessAddon && - this.premiumAccessAddon; - if (this.interval === 'month') { - request.planType = this.plans[this.plan].monthPlanType; - } else { - request.planType = this.plans[this.plan].annualPlanType; - } - } - return this.apiService.postOrganization(request); - } - }).then((response) => { - return this.finalize(response.id); - }); - await this.formPromise; - } catch { } - } - - async finalize(orgId: string) { - await this.apiService.refreshIdentityToken(); - await this.syncService.fullSync(true); - this.analytics.eventTrack.next({ action: 'Created Organization' }); - this.toasterService.popAsync('success', this.i18nService.t('organizationCreated'), - this.i18nService.t('organizationReadyToGo')); - this.router.navigate(['/organizations/' + orgId]); - } - - changedPlan() { - if (!this.plans[this.plan].canBuyPremiumAccessAddon) { - this.premiumAccessAddon = false; - } - - if (this.plans[this.plan].monthPlanType == null) { - this.interval = 'year'; - } - - if (this.plans[this.plan].noAdditionalSeats) { - this.additionalSeats = 0; - } else if (!this.additionalSeats && !this.plans[this.plan].baseSeats && - !this.plans[this.plan].noAdditionalSeats) { - this.additionalSeats = 1; - } - } - - changedOwnedBusiness() { - if (!this.ownedBusiness || this.plan === 'teams' || this.plan === 'enterprise') { - return; - } - this.plan = 'teams'; - } - - additionalStorageTotal(annual: boolean): number { - if (annual) { - return (this.additionalStorage || 0) * this.storageGb.yearlyPrice; - } else { - return (this.additionalStorage || 0) * this.storageGb.monthlyPrice; - } - } - - seatTotal(annual: boolean): number { - if (this.plans[this.plan].noAdditionalSeats) { - return 0; - } - - if (annual) { - return this.plans[this.plan].annualSeatPrice * (this.additionalSeats || 0); - } else { - return this.plans[this.plan].monthlySeatPrice * (this.additionalSeats || 0); - } - } - - baseTotal(annual: boolean): number { - if (annual) { - return (this.plans[this.plan].annualBasePrice || 0); - } else { - return (this.plans[this.plan].monthlyBasePrice || 0); - } - } - - premiumAccessTotal(annual: boolean): number { - if (this.plans[this.plan].canBuyPremiumAccessAddon && this.premiumAccessAddon) { - if (annual) { - return 40; - } - } - return 0; - } - - get total(): number { - const annual = this.interval === 'year'; - return this.baseTotal(annual) + this.seatTotal(annual) + this.additionalStorageTotal(annual) + - this.premiumAccessTotal(annual); - } } diff --git a/src/app/settings/organization-plans.component.html b/src/app/settings/organization-plans.component.html new file mode 100644 index 00000000..e45f4b0d --- /dev/null +++ b/src/app/settings/organization-plans.component.html @@ -0,0 +1,215 @@ + +

{{'uploadLicenseFileOrg' | i18n}}

+
+
+ + + {{'licenseFileDesc' | i18n : 'bitwarden_organization_license.json'}} +
+ +
+
+
+

{{'generalInformation' | i18n}}

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

{{'chooseYourPlan' | i18n}}

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +

{{'users' | i18n}}

+
+
+ + + {{'userSeatsHowManyDesc' | i18n}} +
+
+
+

{{'addons' | i18n}}

+
+
+ + + {{'userSeatsAdditionalDesc' | i18n : plans[plan].baseSeats : (plans[plan].seatPrice | currency:'$')}} +
+
+
+
+ + + {{'additionalStorageIntervalDesc' | i18n : '1 GB' : (storageGb.price | currency:'$') : ('month' | i18n)}} +
+
+
+
+
+ + +
+ {{'premiumAccessDesc' | i18n : (3.33 | currency:'$') : ('month' | i18n)}} +
+
+

{{'summary' | i18n}}

+
+ + +
+
+ + +
+
+
+ {{'total' | i18n}}: {{total | currency:'USD $'}} /{{interval | i18n}} +
+ {{'paymentChargedWithTrial' | i18n : (interval | i18n) }} +

{{'paymentInformation' | i18n}}

+ +
+
+ + +
+
diff --git a/src/app/settings/organization-plans.component.ts b/src/app/settings/organization-plans.component.ts new file mode 100644 index 00000000..8da6ce3d --- /dev/null +++ b/src/app/settings/organization-plans.component.ts @@ -0,0 +1,257 @@ +import { + Component, + EventEmitter, + Input, + Output, + ViewChild, +} from '@angular/core'; +import { Router } from '@angular/router'; + +import { ToasterService } from 'angular2-toaster'; +import { Angulartics2 } from 'angulartics2'; + +import { PaymentMethodType } from 'jslib/enums/paymentMethodType'; + +import { ApiService } from 'jslib/abstractions/api.service'; +import { CryptoService } from 'jslib/abstractions/crypto.service'; +import { I18nService } from 'jslib/abstractions/i18n.service'; +import { PlatformUtilsService } from 'jslib/abstractions/platformUtils.service'; +import { SyncService } from 'jslib/abstractions/sync.service'; + +import { PaymentComponent } from './payment.component'; + +import { PlanType } from 'jslib/enums/planType'; +import { OrganizationCreateRequest } from 'jslib/models/request/organizationCreateRequest'; + +@Component({ + selector: 'app-organization-plans', + templateUrl: 'organization-plans.component.html', +}) +export class OrganizationPlansComponent { + @ViewChild(PaymentComponent) paymentComponent: PaymentComponent; + + @Input() organizationId: string; + @Input() showFree = true; + @Input() showCancel = false; + @Input() plan = 'free'; + @Output() onSuccess = new EventEmitter(); + @Output() onCanceled = new EventEmitter(); + + selfHosted = false; + ownedBusiness = false; + premiumAccessAddon = false; + storageGbPriceMonthly = 0.33; + additionalStorage = 0; + additionalSeats = 0; + interval = 'year'; + name: string; + billingEmail: string; + businessName: string; + + storageGb: any = { + price: 0.33, + monthlyPrice: 0.50, + yearlyPrice: 4, + }; + + plans: any = { + free: { + basePrice: 0, + noAdditionalSeats: true, + noPayment: true, + }, + families: { + basePrice: 1, + annualBasePrice: 12, + baseSeats: 5, + noAdditionalSeats: true, + annualPlanType: PlanType.FamiliesAnnually, + canBuyPremiumAccessAddon: true, + }, + teams: { + basePrice: 5, + annualBasePrice: 60, + monthlyBasePrice: 8, + baseSeats: 5, + seatPrice: 2, + annualSeatPrice: 24, + monthlySeatPrice: 2.5, + monthPlanType: PlanType.TeamsMonthly, + annualPlanType: PlanType.TeamsAnnually, + }, + enterprise: { + seatPrice: 3, + annualSeatPrice: 36, + monthlySeatPrice: 4, + monthPlanType: PlanType.EnterpriseMonthly, + annualPlanType: PlanType.EnterpriseAnnually, + }, + }; + + formPromise: Promise; + + constructor(private apiService: ApiService, private i18nService: I18nService, + private analytics: Angulartics2, private toasterService: ToasterService, + platformUtilsService: PlatformUtilsService, private cryptoService: CryptoService, + private router: Router, private syncService: SyncService) { + this.selfHosted = platformUtilsService.isSelfHost(); + } + + async submit() { + let files: FileList = null; + if (this.createOrganization && this.selfHosted) { + const fileEl = document.getElementById('file') as HTMLInputElement; + files = fileEl.files; + if (files == null || files.length === 0) { + this.toasterService.popAsync('error', this.i18nService.t('errorOccurred'), + this.i18nService.t('selectFile')); + return; + } + } + + try { + this.formPromise = this.doSubmit(files); + await this.formPromise; + this.onSuccess.emit(); + } catch { } + } + + cancel() { + this.onCanceled.emit(); + } + + changedPlan() { + if (!this.plans[this.plan].canBuyPremiumAccessAddon) { + this.premiumAccessAddon = false; + } + + if (this.plans[this.plan].monthPlanType == null) { + this.interval = 'year'; + } + + if (this.plans[this.plan].noAdditionalSeats) { + this.additionalSeats = 0; + } else if (!this.additionalSeats && !this.plans[this.plan].baseSeats && + !this.plans[this.plan].noAdditionalSeats) { + this.additionalSeats = 1; + } + } + + changedOwnedBusiness() { + if (!this.ownedBusiness || this.plan === 'teams' || this.plan === 'enterprise') { + return; + } + this.plan = 'teams'; + } + + additionalStorageTotal(annual: boolean): number { + if (annual) { + return Math.abs(this.additionalStorage || 0) * this.storageGb.yearlyPrice; + } else { + return Math.abs(this.additionalStorage || 0) * this.storageGb.monthlyPrice; + } + } + + seatTotal(annual: boolean): number { + if (this.plans[this.plan].noAdditionalSeats) { + return 0; + } + + if (annual) { + return this.plans[this.plan].annualSeatPrice * Math.abs(this.additionalSeats || 0); + } else { + return this.plans[this.plan].monthlySeatPrice * Math.abs(this.additionalSeats || 0); + } + } + + baseTotal(annual: boolean): number { + if (annual) { + return Math.abs(this.plans[this.plan].annualBasePrice || 0); + } else { + return Math.abs(this.plans[this.plan].monthlyBasePrice || 0); + } + } + + premiumAccessTotal(annual: boolean): number { + if (this.plans[this.plan].canBuyPremiumAccessAddon && this.premiumAccessAddon) { + if (annual) { + return 40; + } + } + return 0; + } + + get total(): number { + const annual = this.interval === 'year'; + return this.baseTotal(annual) + this.seatTotal(annual) + this.additionalStorageTotal(annual) + + this.premiumAccessTotal(annual); + } + + get createOrganization() { + return this.organizationId == null; + } + + private async doSubmit(files: FileList) { + let tokenResult: [string, PaymentMethodType] = null; + if (!this.selfHosted && this.plan !== 'free') { + tokenResult = await this.paymentComponent.createPaymentToken(); + } + + let orgId: string = null; + if (this.createOrganization) { + const shareKey = await this.cryptoService.makeShareKey(); + const key = shareKey[0].encryptedString; + const collection = await this.cryptoService.encrypt(this.i18nService.t('defaultCollection'), shareKey[1]); + const collectionCt = collection.encryptedString; + + if (this.selfHosted) { + const fd = new FormData(); + fd.append('license', files[0]); + fd.append('key', key); + fd.append('collectionName', collectionCt); + const response = await this.apiService.postOrganizationLicense(fd); + orgId = response.id; + } else { + const request = new OrganizationCreateRequest(); + request.key = key; + request.collectionName = collectionCt; + request.name = this.name; + request.billingEmail = this.billingEmail; + + if (this.plan === 'free') { + request.planType = PlanType.Free; + } else { + request.paymentToken = tokenResult[0]; + request.paymentMethodType = tokenResult[1]; + request.businessName = this.ownedBusiness ? this.businessName : null; + request.additionalSeats = this.additionalSeats; + request.additionalStorageGb = this.additionalStorage; + request.premiumAccessAddon = this.plans[this.plan].canBuyPremiumAccessAddon && + this.premiumAccessAddon; + if (this.interval === 'month') { + request.planType = this.plans[this.plan].monthPlanType; + } else { + request.planType = this.plans[this.plan].annualPlanType; + } + } + const response = await this.apiService.postOrganization(request); + orgId = response.id; + } + } else { + // TODO + orgId = this.organizationId; + } + + if (orgId != null) { + await this.apiService.refreshIdentityToken(); + await this.syncService.fullSync(true); + this.analytics.eventTrack.next({ + action: (this.createOrganization ? 'Created' : 'Upgraded') + ' Organization', + }); + this.toasterService.popAsync('success', + this.i18nService.t(this.createOrganization ? 'organizationCreated' : ''), // TODO + this.i18nService.t('organizationReadyToGo')); + this.router.navigate(['/organizations/' + orgId]); + } + } +} diff --git a/src/app/settings/premium.component.ts b/src/app/settings/premium.component.ts index 9db76776..310d937c 100644 --- a/src/app/settings/premium.component.ts +++ b/src/app/settings/premium.component.ts @@ -102,7 +102,7 @@ export class PremiumComponent implements OnInit { } get additionalStorageTotal(): number { - return this.storageGbPrice * this.additionalStorage; + return this.storageGbPrice * Math.abs(this.additionalStorage || 0); } get total(): number { diff --git a/src/scss/styles.scss b/src/scss/styles.scss index 70cb5b3f..65b18595 100644 --- a/src/scss/styles.scss +++ b/src/scss/styles.scss @@ -28,7 +28,7 @@ $h5-font-size: 1rem; $h6-font-size: 1rem; $small-font-size: 90%; -$font-size-lg: 1.18rem; +$font-size-lg: 1.15rem; $code-font-size: 100%; $navbar-padding-y: .75rem; @@ -185,7 +185,7 @@ input, select, textarea { } .card-body-header { - font-size: $h3-font-size * 1.12; + font-size: $font-size-lg; @extend .mb-4 } @@ -216,6 +216,12 @@ input, select, textarea { } } +.card-org-plans { + h2 { + font-size: $font-size-lg; + } +} + .modal-dialog { width: $modal-md; }