Website: update signup forms and license dispenser (#17572)

Closes: #16697

Changes:
- Updated the managed cloud feature in `pricing-features-table.yml` to
note that is only available for >700 hosts
- Updated `signup.js` to accept one new input `primaryBuyingSituation`
and to throw an error if someone signs up with a personal email address.
- Updated the /customers/register page to ask users signing up what they
will be using Fleet for, and to display an error if a user signs up with
a personal email address.
- Updated the /try-fleet/register page to show an error if a user signs
up with a personal email address
- Updated the /customers/new-license page to:
- only show the quoted price for users creating a quote for >700 hosts
- Add a checkbox to the billing form for users to confirm that they
understand they are buying a license for self-hosted Fleet Premium
This commit is contained in:
Eric 2024-03-14 12:28:35 -05:00 committed by GitHub
parent 3365fd736f
commit b1e73387ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 214 additions and 64 deletions

View file

@ -529,7 +529,7 @@
tier: Free
productCategories: [Endpoint operations]
pricingTableCategories: [Deployment]
- industryName: Managed Cloud
- industryName: Managed Cloud (700+ hosts)
description: Have Fleet host it for you (currently only available for customers with 700+ hosts. PS. Wish we could host for you? We're working on it! Please let us know if you know of a good partner. In the meantime, join fleetdm.com/support and we're happy to help you deploy Fleet yourself.)
pricingTableCategories: [Deployment]
productCategories: [Endpoint operations,Device management,Vulnerability management]

View file

@ -61,6 +61,18 @@ the account verification message.)`,
type: 'string',
isIn: ['Buy a license', 'Try Fleet'],
defaultsTo: 'Buy a license',
},
primaryBuyingSituation: {
type: 'string',
description: 'What the user will be using Fleet for.',
required: true,
isIn: [
'endpoint-ops-security',
'endpoint-ops-it',
'device-management',
'vulnerability-management'
],
}
},
@ -84,18 +96,41 @@ the account verification message.)`,
description: 'The provided email address is already in use.',
},
invalidEmailDomain: {
description: 'This email address is on a denylist of domains and cannot be used to signup for a fleetdm.com account.',
responseType: 'badRequest'
},
},
fn: async function ({emailAddress, password, firstName, lastName, organization, signupReason}) {
fn: async function ({emailAddress, password, firstName, lastName, organization, signupReason, primaryBuyingSituation}) {
// Note: in Oct. 2023, the Fleet Sandbox related code was removed from this action. For more details, see https://github.com/fleetdm/fleet/pull/14638/files
var newEmailAddress = emailAddress.toLowerCase();
// Checking if a user with this email address exists in our database before we send a request to the cloud provisioner.
if(await User.findOne({emailAddress: newEmailAddress})) {
throw 'emailAlreadyInUse';
}
// Check the user's email address and return an 'invalidEmailDomain' response if the domain is in the bannedEmailDomainsForSignup array.
let emailDomain = newEmailAddress.split('@')[1];
let bannedEmailDomainsForSignup = [
'gmail.com',
'yahoo.com',
'yahoo.co.uk',
'hotmail.com',
'hotmail.co.uk',
'outlook.com',
'icloud.com',
'proton.me',
'live.com',
'yandex.ru',
'ymail.com',
];
if(_.includes(bannedEmailDomainsForSignup, emailDomain)){
throw 'invalidEmailDomain';
}
if (!sails.config.custom.enableBillingFeatures) {
throw new Error('The Stripe configuration variables (sails.config.custom.stripePublishableKey and sails.config.custom.stripeSecret) are missing!');
@ -137,6 +172,7 @@ the account verification message.)`,
lastName,
organization,
signupReason,
primaryBuyingSituation,
webhookSecret: sails.config.custom.zapierSandboxWebhookSecret,
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

View file

@ -16,6 +16,7 @@ parasails.registerPage('new-license', {
billingFormRules: {
paymentSource: {required: true},
selfHostedAcknowledgment: {required: true, is: true},
},
// Syncing / loading state
@ -73,13 +74,19 @@ parasails.registerPage('new-license', {
this.showQuotedPrice = true;
this.quotedPrice = quote.quotedPrice;
this.numberOfHostsQuoted = quote.numberOfHosts;
if(quote.numberOfHosts <= 700) {
if(quote.numberOfHosts < 700) {
this.formData.quoteId = quote.id;
this.showBillingForm = true;
}
await this.forceRender();
},
clickClearOneFormError: async function(field) {
if(this.formErrors[field]){
this.formErrors = _.omit(this.formErrors, field);
}
},
clickScheduleDemo: async function() {
this.syncing = true;
// Note: we keep loading spinner present indefinitely so that it is apparent that a new page is loading

View file

@ -17,6 +17,7 @@ parasails.registerPage('signup', {
organization: {required: true},
emailAddress: {required: true, isEmail: true},
password: {required: true, minLength: 8},
primaryBuyingSituation: {required: true},
},
// Syncing / loading state
syncing: false,

View file

@ -1,5 +1,5 @@
#dashboard {
padding-top: 40px;
padding-top: 80px;
h1 {
font-size: 28px;
@ -7,7 +7,8 @@
}
h3 {
padding-bottom: 16px;
font-size: 24px;
font-size: 16px;
line-height: 120%;
margin-bottom: 0px;
}
a {
@ -93,42 +94,60 @@
cursor: pointer;
}
[purpose='slack-button'] {
font-size: 16px;
[purpose='animated-arrow-button-red'] {
display: flex;
flex-direction: row;
align-items: center;
line-height: 24px;
padding-right: 24px;
cursor: pointer;
position: relative;
width: fit-content;
font-weight: bold;
user-select: none;
transition: 0.2s ease-in-out;
-o-transition: 0.2s ease-in-out;
-ms-transition: 0.2s ease-in-out;
-moz-transition: 0.2s ease-in-out;
-webkit-transition: 0.2s ease-in-out;
color: @core-fleet-black;
background-color: #fff;
border: 1px solid #C5C7D1;
border-radius: 4px;
padding: 8px 40px;
line-height: 20px;
&:hover {
background: #fff;
color: @core-fleet-black;
}
&:focus {
box-shadow: none;
}
img {
display: inline;
height: 20px;
width: auto;
padding-right: 4px;
margin-bottom: 1px;
}
a {
color: @core-fleet-black;
}
text-decoration: none;
&:hover {
color: #000;
}
&:after {
content: url('/images/arrow-right-red-16x16@2x.png');
transform: scale(0.5);
position: absolute;
top: -5px;
left: 75%; // <--- here
transition: 0.2s ease-in-out;
-o-transition: 0.2s ease-in-out;
-ms-transition: 0.2s ease-in-out;
-moz-transition: 0.2s ease-in-out;
-webkit-transition: 0.2s ease-in-out;
}
&:hover:after {
left: 82%; // <--- here
transition: 0.2s ease-in-out;
-o-transition: 0.2s ease-in-out;
-ms-transition: 0.2s ease-in-out;
-moz-transition: 0.2s ease-in-out;
-webkit-transition: 0.2s ease-in-out;
}
}
[purpose='deploy-button'] {
font-size: 16px;
line-height: 20px;
padding: 8px 40px;
border-radius: 4px;
color: white;
height: 48px;
padding: 16px 32px;
border-radius: 8px;
color: #FFF;
text-decoration: none;
font-size: 16px;
font-weight: 700;
line-height: 16px;
margin-right: 32px;
&:hover {
color: white;
color: #FFF;
}
}
@ -243,7 +262,7 @@
}
@media (max-width: 768px) {
padding-top: 60px;
padding-top: 64px;
[purpose='modal-content'] {
margin-top: 50px;
@ -262,6 +281,9 @@
[purpose='billing-card'] {
padding: 40px 20px;
}
[purpose='deploy-button'] {
margin-right: unset;
}
}

View file

@ -25,6 +25,52 @@
input[type='number'] {
-moz-appearance: textfield;
}
[purpose='self-hosted-checkbox'] {
[purpose='self-hosted-note'] {
font-size: 14px;
line-height: 150%;
margin-bottom: 0px;
margin-botom: 24px;
}
input {
display: none;
}
input + label {
position: relative;
padding-left: 24px;
cursor: pointer;
}
input + label::before {
content: '';
position: absolute;
left: 0;
top: 2px;
width: 16px;
height: 16px;
background-color: #fff;
border: 2px solid #192147;
border-radius: 4px;
}
input:checked + label::before {
background-color: #192147;
}
input:checked + label::after {
content: url('/images/icon-checkmark-white-10x9@2x.png');
position: absolute;
left: -1px;
top: 2px;
transform: scale(0.5);
width: 16px;
height: 16px;
}
label.is-invalid::before {
border: 2px solid #FF5C83;
}
label.is-invalid + .invalid-feedback {
display: block;
}
}
[purpose='quote-input'] {
max-width: 200px;
}

View file

@ -28,6 +28,24 @@
.card-body {
padding: 2em;
}
.selectbox {
position: relative;
}
.selectbox::after {
content: url('/images/chevron-12x8@2x.png');
right: 14px;
transform: scale(0.5);
top: 14px;
position: absolute;
pointer-events: none;
}
.selectbox select {
border-radius: 6px;
height: 48px;
appearance: none;
-webkit-appearance: none;
}
}
[purpose='submit-button'] {

View file

@ -42,7 +42,7 @@
<p><js-timestamp :at="thisSubscription.nextBillingAt" always-show-year format="billing"></js-timestamp></p>
</div>
<div class="col-12 col-lg-4 pt-3 pt-lg-0">
<strong>License key</strong><img class="d-inline-block ml-2" style="width: 15px; height: 16px; cursor: pointer;" src="/images/icon-copy-15x16@2x.png" alt="click here to copy your license key" @click="clickCopyLicenseKey()"><span purpose="copied-notification">License key copied!</span>
<strong>License key</strong><img class="d-inline-block ml-2" style="height: 14px; cursor: pointer;" src="/images/icon-copy-14x14@2x.png" alt="click here to copy your license key" @click="clickCopyLicenseKey()"><span purpose="copied-notification">License key copied!</span>
<p><span purpose="license-key" @click="clickExpandLicenseKey()">{{thisSubscription.fleetLicenseKey}}</span></p>
</div>
</div>
@ -56,28 +56,28 @@
<div class="row">
<div class="col-sm-4 col-12 pb-2 pb-sm-0">Organization:</div>
<div class="col-sm-8 col-12 text-left text-sm-right">
<strong>{{me.organization}}</strong><img purpose="edit-button" src="/images/icon-pencil-24x24@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickEditButton()">
<strong>{{me.organization}}</strong><img purpose="edit-button" src="/images/icon-pencil-12x12@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickEditButton()">
</div>
</div>
<hr/>
<div class="row">
<div class="col-sm-3 col-12 pb-2 pb-sm-0">Name:</div>
<div class="col-sm-9 col-12 text-left text-sm-right">
<strong>{{me.firstName}} {{me.lastName}}</strong><img purpose="edit-button" src="/images/icon-pencil-24x24@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickEditButton()">
<strong>{{me.firstName}} {{me.lastName}}</strong><img purpose="edit-button" src="/images/icon-pencil-12x12@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickEditButton()">
</div>
</div>
<hr/>
<div class="row">
<div class="col-sm-3 col-12 pb-2 pb-sm-0">Email:</div>
<div class="col-sm-9 col-12 text-left text-sm-right">
<strong :class="[me.emailStatus === 'unconfirmed' || me.emailStatus === 'change-requested' ? 'text-muted' : '']">{{me.emailChangeCandidate ? me.emailChangeCandidate : me.emailAddress}}</strong><img purpose="edit-button" src="/images/icon-pencil-24x24@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickEditButton()">
<strong :class="[me.emailStatus === 'unconfirmed' || me.emailStatus === 'change-requested' ? 'text-muted' : '']">{{me.emailChangeCandidate ? me.emailChangeCandidate : me.emailAddress}}</strong><img purpose="edit-button" src="/images/icon-pencil-12x12@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickEditButton()">
</div>
</div>
<hr/>
<div class="row">
<div class="col-sm-4 col-12 pb-2 pb-sm-0">Password:</div>
<div class="col-sm-8 col-12 text-left text-sm-right">
<strong>••••••••</strong><img purpose="edit-button" src="/images/icon-pencil-24x24@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickChangePassword()">
<strong>••••••••</strong><img purpose="edit-button" src="/images/icon-pencil-12x12@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickChangePassword()">
</div>
</div>
</div>
@ -89,7 +89,7 @@
<div class="row pb-3 mx-0">
<div style="max-width: 16px;" class="col-1 px-0"><img style="margin-top: 5px; height: 12px; width: 16px;" src="/images/icon-card-32x24@2x.png" alt="A credit card Icon"></div>
<div class="col pl-3">
<p>{{me.billingCardBrand}} ending in <strong>{{me.billingCardLast4}}</strong><img purpose="edit-button" src="/images/icon-pencil-24x24@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickUpdateBillingCardButton()"></p>
<p>{{me.billingCardBrand}} ending in <strong>{{me.billingCardLast4}}</strong><img purpose="edit-button" src="/images/icon-pencil-12x12@2x.png" alt="A pencil icon indicating that this information can be editted" @click="clickUpdateBillingCardButton()"></p>
</div>
</div>
<div class="row pb-3 mx-0">
@ -106,7 +106,7 @@
<img style="display: inline-block; height: 16px; width: 16px; margin-top: -3px;" src="/images/info-16x16@2x.png" alt="An icon indicating that this section has important information">
</div>
<div class="col ml-1 pl-1 small">
<p class="small"><a href="/contact" target="_blank">Contact us</a> to change your number of devices, or to <strong>cancel</strong> your subscription.</p>
<p class="small"><a href="/contact" target="_blank">Contact us</a> to change your number of devices, or to cancel your subscription.</p>
</div>
</div>
</div>
@ -114,13 +114,11 @@
</div>
</div>
<div class="d-flex flex-md-row flex-column pt-3">
<a class="btn btn-info btn-sm btn-md-block mr-md-3 mb-3 mb-md-0" purpose="deploy-button" href="/docs/deploying/introduction">
<div class="d-flex flex-md-row flex-column pt-3 align-items-center justify-content-md-start justify-content-center">
<a class="btn btn-primary btn-sm btn-md-block mb-3 mb-md-0" purpose="deploy-button" href="/docs/deploying/introduction">
How to deploy Fleet
</a>
<a class="btn btn-outline-secondary btn-sm btn-md-block" purpose="slack-button" href="/slack" target="_blank">
<img alt="Slack logo" src="/images/logo-slack-24x24@2x.png"/>
Ask for help on Slack
<a purpose="animated-arrow-button-red" href="/support" target="_blank">Support
</a>
</div>

View file

@ -3,7 +3,7 @@
<div style="max-width: 560px;" class="container-fluid pb-5 px-lg-0 px-3" v-if="!showSuccessMessage">
<div purpose="page-heading">
<h1>Welcome to Fleet Premium</h1>
<p class="pb-2">We just need a few details in order to get you a self-hosted Fleet Premium license key.</p>
<p class="pb-2">We just need a few details in order to get started.</p>
</div>
<div purpose="customer-portal-form" class="card card-body">
<ajax-form action="createQuote" :syncing.sync="syncing" :cloud-error.sync="cloudError" :form-errors.sync="formErrors" :form-data="formData" :form-rules="quoteFormRules" @submitted="submittedQuoteForm($event)">
@ -17,27 +17,27 @@
<div style="color: #515774;" class="order-last text-left text-sm-right col-12 col-sm-6 pr-0 pl-sm-4 pl-0 pt-sm-4">
<p class="small">
<strong class="pr-1" style="font-size: 18px; color: #192147">$7.00</strong>/month/device<br>(Billed annually at $84/device)
<strong class="pr-1" style="font-size: 18px; color: #192147">$7.00</strong>/ host / month<br>(Billed annually)
</p>
</div>
</div>
<cloud-error purpose="cloud-error" v-if="cloudError && !showBillingForm"></cloud-error>
<div class="mt-2 pt-3 pb-3 border-top d-flex flex-column" v-if="showQuotedPrice && formData.numberOfHosts && numberOfHostsQuoted">
<div class="mt-2 pt-3 pb-4 border-top d-flex flex-column" v-if="showQuotedPrice && formData.numberOfHosts && numberOfHostsQuoted">
<div class="pb-2">
<strong>Order total</strong>
<strong>Order summary</strong>
</div>
<div class="d-flex flex-row justify-content-between">
<p class="mb-0">Fleet Premium <br purpose="order-form-line-break"><span purpose="over-1000-linebreak"><br :class="[numberOfHostsQuoted >= 1000 ? 'd-block' : 'd-none']"></span>(self-hosted<span v-if="numberOfHostsQuoted >= 1000" > or managed cloud</span>)</p>
<div class="ml-auto text-right">
<p class="mb-0">Fleet Premium (self-hosted<span v-if="numberOfHostsQuoted > 699" > or managed cloud</span>)</p>
<div class="ml-auto text-right" v-if="numberOfHostsQuoted < 700">
<strong>${{(!showQuotedPrice || _.isNaN(formData.numberOfHosts * 7.00 * 12)) ? quotedPrice : formData.numberOfHosts * 7.00 * 12}}.00 <br purpose="order-form-line-break">/year</strong>
</div>
</div>
</div>
<div :class="[showBillingForm ? 'pt-2' : '' ]" v-if="!showBillingForm">
<ajax-button purpose="submit-button" spinner="true" type="submit" :syncing="syncing" class="btn btn-block btn-lg btn-primary" v-if="!numberOfHostsQuoted">Continue</ajax-button>
<ajax-button spinner="true" purpose="submit-button" :syncing="syncing" class="btn btn-block btn-lg btn-primary" @click="clickScheduleDemo" v-if="showQuotedPrice && numberOfHostsQuoted > 700">Talk to us</ajax-button>
<ajax-button spinner="true" purpose="submit-button" :syncing="syncing" class="btn btn-block btn-lg btn-primary" v-if="showQuotedPrice && numberOfHostsQuoted <= 700">Continue</ajax-button>
<ajax-button spinner="true" purpose="submit-button" :syncing="syncing" class="btn btn-block btn-lg btn-primary" @click="clickScheduleDemo" v-if="showQuotedPrice && numberOfHostsQuoted > 699">Talk to us</ajax-button>
<ajax-button spinner="true" purpose="submit-button" :syncing="syncing" class="btn btn-block btn-lg btn-primary" v-if="showQuotedPrice && numberOfHostsQuoted < 700">Continue</ajax-button>
</div>
</ajax-form>
</div>
@ -72,6 +72,12 @@
</div>
</div>
</div>
<div class="form-group" purpose="self-hosted-checkbox">
<input type="checkbox" id="self-hosted-acknowledgment" v-model.trim="formData.selfHostedAcknowledgment" @input="clickClearOneFormError('selfHostedAcknowledgment')">
<label purpose="self-hosted-note" :class="[formErrors.selfHostedAcknowledgment ? 'is-invalid' : '']" for="self-hosted-acknowledgment">I understand that managed cloud hosting is not available for less than 700 hosts. I will host Fleet myself.</label>
<div class="invalid-feedback" v-if="formErrors.selfHostedAcknowledgment"><p>Please confirm that you will be hosting Fleet yourself.</p></div>
</div>
<cloud-error purpose="cloud-error" v-if="cloudError === 'couldNotSaveBillingInfo'">
<p>The billing card provided could not be used. Please use another card or <a href="/contact" target="_blank">contact support</a>.</p>
</cloud-error>

View file

@ -2,7 +2,7 @@
<div :purpose="[showCustomerLogin ? 'customer-login-container' : 'login-container']" class="container-fluid pb-5 px-lg-0 px-3">
<div purpose="page-heading" v-if="showCustomerLogin">
<h1>Welcome to Premium</h1>
<p class="pb-2">Sign in to manage your Fleet Premium subscription.</p>
<p class="pb-2">We just need a few details in order to get started.</p>
</div>
<div purpose="page-heading" v-else>
<h1>Welcome to Fleet</h1>

View file

@ -7,44 +7,60 @@
<div purpose="customer-portal-form" class="card card-body">
<ajax-form action="signup" class="self-service-register" :syncing.sync="syncing" :cloud-error.sync="cloudError" :form-errors.sync="formErrors" :form-data="formData" :form-rules="formRules" @submitted="submittedSignUpForm()">
<div class="form-group">
<label for="email-address">Email</label>
<span style="float: right" class="text-right small"><a :href="loginSlug">I have an account</a></span>
<label for="email-address">Work email *</label>
<span style="float: right" class="text-right small"><a href="/customers/login">I have an account</a></span>
<input class="form-control" id="email-address" :class="[formErrors.emailAddress ? 'is-invalid' : '']" v-model.trim="formData.emailAddress" @input="typeClearOneFormError('emailAddress')">
<div class="invalid-feedback" v-if="formErrors.emailAddress" focus-first>This doesnt appear to be a valid email address</div>
</div>
<div v-show="formData.emailAddress || showFullForm">
<div class="form-group">
<label for="password">Choose a password</label>
<label for="password">Choose a password *</label>
<input class="form-control" id="password" type="password" :class="[formErrors.password ? 'is-invalid' : '']" v-model.trim="formData.password" autocomplete="new-password" @input="typeClearOneFormError('password')">
<div class="invalid-feedback" v-if="formErrors.password === 'minLength'">Password too short.</div>
<div class="invalid-feedback" v-if="formErrors.password === 'required'">Please enter a password.</div>
<p class="mt-2"> Minimum length is 8 characters</p>
</div>
<div class="form-group">
<label for="organization">Organization</label>
<label for="organization">Organization *</label>
<input class="form-control" id="organization" type="text" :class="[formErrors.organization ? 'is-invalid' : '']" v-model.trim="formData.organization" @input="typeClearOneFormError('organization')">
<div class="invalid-feedback" v-if="formErrors.organization">Please enter the name of your organization.</div>
</div>
<div class="row">
<div class="col-12 col-sm-6 pr-sm-2">
<div class="form-group">
<label for="first-name">First name</label>
<label for="first-name">First name *</label>
<input class="form-control" id="first-name" type="text" :class="[formErrors.firstName ? 'is-invalid' : '']" v-model.trim="formData.firstName" autocomplete="first-name" @input="typeClearOneFormError('firstName')">
<div class="invalid-feedback" v-if="formErrors.firstName">Please enter your first name.</div>
</div>
</div>
<div class="col-12 col-sm-6 pl-sm-2">
<div class="form-group">
<label for="last-name">Last name</label>
<label for="last-name">Last name *</label>
<input class="form-control" id="last-name" type="text" :class="[formErrors.lastName ? 'is-invalid' : '']" v-model.trim="formData.lastName" autocomplete="last-name" @input="typeClearOneFormError('lastName')">
<div class="invalid-feedback" v-if="formErrors.lastName">Please enter your last name.</div>
</div>
</div>
</div>
<div class="form-group">
<label for="primaryBuyingSituation">What will you be using Fleet for? *</label>
<div class="selectbox">
<select class="form-control" id="primaryBuyingSituation" name="primaryBuyingSituation" :class="[formErrors.primaryBuyingSituation ? 'is-invalid' : '']" v-model="formData.primaryBuyingSituation" @input="typeClearOneFormError('primaryBuyingSituation')">
<option disabled hidden value="undefined">Choose an option</option>
<option value="endpoint-ops-security">Endpoint operations for security engineers</option>
<option value="endpoint-ops-it">Endpoint operations for IT admins</option>
<option value="device-management">Device management</option>
<option value="vulnerability-management">Vulnerability management</option>
</select>
</div>
<div class="d-block invalid-feedback" v-if="formErrors.primaryBuyingSituation">Please select an option.</div>
</div>
</div>
<cloud-error v-if="cloudError==='emailAlreadyInUse'">
<p>This email is already linked to a Fleet account.<br> Please <a href="/customers/login">sign in</a> with your email and password.</p>
</cloud-error>
<cloud-error v-else-if="cloudError === 'invalidEmailDomain'">
<p>Please enter a valid work email address</p>
</cloud-error>
<cloud-error purpose="cloud-error" v-else-if="cloudError"></cloud-error>
<p class="small">By signing up you agree to our <a href="/legal/privacy">privacy policy</a> and <a href="/terms">terms of service</a>.</p>
<ajax-button purpose="submit-button" spinner="true" type="submit" :syncing="syncing" class="btn btn-block btn-lg btn-primary mt-4" v-if="!cloudError">Agree and continue</ajax-button>