diff --git a/website/api/controllers/account/logout.js b/website/api/controllers/account/logout.js index bdb1d2ba48..8119faa6ef 100644 --- a/website/api/controllers/account/logout.js +++ b/website/api/controllers/account/logout.js @@ -41,7 +41,7 @@ actually logged in. (If they weren't, then this action is just a no-op.)`, // > Under the covers, this persists the now-logged-out session back // > to the underlying session store. if (!this.req.wantsJSON) { - throw {redirect: '/customers/login'}; + throw {redirect: '/'}; } } diff --git a/website/api/controllers/articles/view-articles.js b/website/api/controllers/articles/view-articles.js index 695b04e7e9..583064987e 100644 --- a/website/api/controllers/articles/view-articles.js +++ b/website/api/controllers/articles/view-articles.js @@ -6,11 +6,11 @@ module.exports = { description: 'Display "Articles" page.', + inputs: { category: { type: 'string', - required: false, - description: 'The category of article to display', + description: 'The category of article to display.', defaultsTo: '', } }, diff --git a/website/api/controllers/deliver-contact-form-message.js b/website/api/controllers/deliver-contact-form-message.js index a129cc4c79..e355b08647 100644 --- a/website/api/controllers/deliver-contact-form-message.js +++ b/website/api/controllers/deliver-contact-form-message.js @@ -38,7 +38,6 @@ module.exports = { }, message: { - required: false, type: 'string', description: 'The custom message, in plain text.' } diff --git a/website/api/controllers/download-sitemap.js b/website/api/controllers/download-sitemap.js index bf6400f2cf..93be7f4aa0 100644 --- a/website/api/controllers/download-sitemap.js +++ b/website/api/controllers/download-sitemap.js @@ -44,7 +44,7 @@ module.exports = { // ╩ ╩╩ ╩╝╚╝═╩╝ ╚═╝╚═╝═╩╝╚═╝═╩╝ ╩ ╩ ╩╚═╝╚═╝╚═╝ let HAND_CODED_HTML_PAGES = [ '/', - '/get-started', + '/fleetctl-preview', '/company/contact', '/queries', '/platform', diff --git a/website/api/controllers/entrance/signup.js b/website/api/controllers/entrance/signup.js index c9a2e93b7b..6d07c8559b 100644 --- a/website/api/controllers/entrance/signup.js +++ b/website/api/controllers/entrance/signup.js @@ -32,11 +32,10 @@ the account verification message.)`, type: 'string', maxLength: 200, example: 'passwordlol', - description: 'The unencrypted password to use for the new account.' + description: 'The unhashed (plain text) password to use for the new account.' }, organization: { - required: true, type: 'string', maxLength: 120, example: 'The Sails company', @@ -84,13 +83,75 @@ the account verification message.)`, description: 'The provided email address is already in use.', }, - }, + }, fn: async function ({emailAddress, password, firstName, lastName, organization, signupReason}) { + if(!sails.config.custom.cloudProvisionerSecret){ + throw new Error('The authorization token for the cloud provisioner API (sails.config.custom.cloudProvisionerSecret) is missing! If you just want to test aspects of fleetdm.com locally, and are OK with the cloud provisioner failing if you try to use it, you can set a fake secret when starting a local server by lifting the server with "sails_custom__cloudProvisionerSecret=test sails lift"'); + } + 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'; + } + + // Provisioning a Fleet sandbox instance for the new user. Note: Because this is the only place where we provision Sandbox instances, We'll provision a Sandbox instance BEFORE + // creating the new User record. This way, if this fails, we won't save the new record to the database, and the user will see an error on the signup form asking them to try again. + + // Creating an expiration JS timestamp for the Fleet sandbox instance. NOTE: We send this value to the cloud provisioner API as an ISO 8601 string. + let fleetSandboxExpiresAt = Date.now() + (24*60*60*1000); + + // Creating a fleetSandboxDemoKey, this will be used for the user's password when we log them into their Sandbox instance. + let fleetSandboxDemoKey = await sails.helpers.strings.uuid(); + + // Send a POST request to the cloud provisioner API + let cloudProvisionerResponseData = await sails.helpers.http.post( + 'https://sandbox.fleetdm.com/new', + { // Request body + 'name': firstName + ' ' + lastName, + 'email': newEmailAddress, + 'password': fleetSandboxDemoKey, //« this provisioner API was originally designed to accept passwords, but rather than specifying the real plaintext password, since users always access Fleet Sandbox from their fleetdm.com account anyway, this generated demo key is used instead to avoid any confusion + 'sandbox_expiration': new Date(fleetSandboxExpiresAt).toISOString(), // sending expiration_timestamp as an ISO string. + }, + { // Request headers + 'Authorization':sails.config.custom.cloudProvisionerSecret + } + ) + .timeout(5000) + .intercept(['requestFailed', 'non200Response'], (err)=>{ + // If we recieved a non-200 response from the cloud provisioner API, we'll throw a 500 error. + return new Error('When attempting to provision a new user who just signed up ('+emailAddress+'), the cloud provisioner gave a non 200 response. The incomplete user record has not been saved in the database, and the user will be asked to try signing up again. Raw response received from provisioner: '+err.stack); + }); + + if(!cloudProvisionerResponseData.URL) { + // If we didn't receive a URL in the response from the cloud provisioner API, we'll throwing an error before we save the new user record and the user will need to try to sign up again. + throw new Error( + `When provisioning a Fleet Sandbox instance for a new user who just signed up (${emailAddress}), the response data from the cloud provisioner API was malformed. It did not contain a valid Fleet Sandbox instance URL in its expected "URL" property. + The incomplete user record has not been saved in the database, and the user will be asked to try signing up again. + Here is the malformed response data (parsed response body) from the cloud provisioner API: ${cloudProvisionerResponseData}` + ); + } + + // If "Try Fleet Sandbox" was provided as the signupReason, we'll send a request to Zapier to add this user to our CRM and make sure their Sandbox instance is live before we continue. + if(signupReason === 'Try Fleet Sandbox') { + // Start polling the /healthz endpoint of the created Fleet Sandbox instance, once it returns a 200 response, we'll continue. + await sails.helpers.flow.until( async()=>{ + let healthCheckResponse = await sails.helpers.http.sendHttpRequest('GET', cloudProvisionerResponseData.URL+'/healthz') + .timeout(5000) + .tolerate('non200Response') + .tolerate('requestFailed'); + if(healthCheckResponse) { + return true; + } + }, 10000).intercept('tookTooLong', ()=>{ + return new Error('This newly provisioned Fleet Sandbox instance (for '+emailAddress+') is taking too long to respond with a 2xx status code, even after repeatedly polling the health check endpoint. Note that failed requests and non-2xx responses from the health check endpoint were ignored during polling. Search for a bit of non-dynamic text from this error message in the fleetdm.com source code for more info on exactly how this polling works.'); + }); + } + // Build up data for the new user record and save it to the database. // (Also use `fetch` to retrieve the new ID so that we can use it below.) var newUserRecord = await User.create(_.extend({ @@ -99,6 +160,9 @@ the account verification message.)`, organization, emailAddress: newEmailAddress, password: await sails.helpers.passwords.hashPassword(password), + fleetSandboxURL: cloudProvisionerResponseData.URL, + fleetSandboxExpiresAt, + fleetSandboxDemoKey, tosAcceptedByIp: this.req.ip }, sails.config.custom.verifyEmailAddresses? { emailProofToken: await sails.helpers.strings.random('url-friendly'), @@ -109,17 +173,6 @@ the account verification message.)`, .intercept({name: 'UsageError'}, 'invalid') .fetch(); - // If billing feaures are enabled, save a new customer entry in the Stripe API. - // Then persist the Stripe customer id in the database. - if (sails.config.custom.enableBillingFeatures) { - let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ - emailAddress: newEmailAddress - }).timeout(5000).retry(); - await User.updateOne({id: newUserRecord.id}) - .set({ - stripeCustomerId - }); - } // Send a POST request to Zapier await sails.helpers.http.post( 'https://hooks.zapier.com/hooks/catch/3627242/bqsf4rj/', @@ -138,6 +191,17 @@ the account verification message.)`, sails.log.warn(`When a new user signed up, a lead/contact could not be verified in the CRM for this email address: ${newEmailAddress}. Raw error: ${err}`); return; }); + // If billing feaures are enabled, save a new customer entry in the Stripe API. + // Then persist the Stripe customer id in the database. + if (sails.config.custom.enableBillingFeatures) { + let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({ + emailAddress: newEmailAddress + }).timeout(5000).retry(); + await User.updateOne({id: newUserRecord.id}) + .set({ + stripeCustomerId + }); + } // Store the user's new id in their session. this.req.session.userId = newUserRecord.id; diff --git a/website/api/controllers/entrance/view-login.js b/website/api/controllers/entrance/view-login.js index ebe2053935..e3646d6e67 100644 --- a/website/api/controllers/entrance/view-login.js +++ b/website/api/controllers/entrance/view-login.js @@ -24,7 +24,11 @@ module.exports = { fn: async function () { if (this.req.me) { - throw {redirect: '/customers/new-license'}; + if(this.req.me.hasBillingCard){ + throw {redirect: '/customers/new-license'}; + } else { + throw {redirect: '/try-fleet/sandbox'}; + } } return {}; diff --git a/website/api/controllers/try-fleet/view-register.js b/website/api/controllers/try-fleet/view-register.js new file mode 100644 index 0000000000..f6b4bede80 --- /dev/null +++ b/website/api/controllers/try-fleet/view-register.js @@ -0,0 +1,37 @@ +module.exports = { + + + friendlyName: 'View register', + + + description: 'Display "Register" page. Note: This page is the "signup" page skinned for Fleet Sandbox.', + + + exits: { + + success: { + viewTemplatePath: 'pages/try-fleet/register' + }, + + redirect: { + description: 'The requesting user is already logged in.', + responseType: 'redirect' + } + + }, + + + fn: async function () { + + // If the user is logged in, redirect them to the Fleet sandbox page. + if (this.req.me) { + throw {redirect: '/try-fleet/sandbox'}; + } + + // Respond with view. + return {}; + + } + + +}; diff --git a/website/api/controllers/try-fleet/view-sandbox-expired.js b/website/api/controllers/try-fleet/view-sandbox-expired.js new file mode 100644 index 0000000000..3ae3085810 --- /dev/null +++ b/website/api/controllers/try-fleet/view-sandbox-expired.js @@ -0,0 +1,27 @@ +module.exports = { + + + friendlyName: 'View sandbox expired', + + + description: 'Display "Sandbox expired" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/try-fleet/sandbox-expired' + } + + }, + + + fn: async function () { + + // Respond with view. + return {}; + + } + + +}; diff --git a/website/api/controllers/try-fleet/view-sandbox-login.js b/website/api/controllers/try-fleet/view-sandbox-login.js new file mode 100644 index 0000000000..91a4e67989 --- /dev/null +++ b/website/api/controllers/try-fleet/view-sandbox-login.js @@ -0,0 +1,38 @@ +module.exports = { + + + friendlyName: 'View Sandbox login', + + + description: 'Display the "Sandbox Login" page. Note: This page is the "login" page skinned for Fleet Sandbox.', + + + exits: { + + success: { + viewTemplatePath: 'pages/try-fleet/sandbox-login' + }, + + redirect: { + description: 'The requesting user is already logged in.', + responseType: 'redirect' + } + + + }, + + + fn: async function () { + + // If the user is logged in, redirect them to the Fleet sandbox page. + if (this.req.me) { + throw {redirect: '/try-fleet/sandbox'}; + } + + // Respond with view. + return {}; + + } + + +}; diff --git a/website/api/controllers/try-fleet/view-sandbox-teleporter-or-redirect-because-expired.js b/website/api/controllers/try-fleet/view-sandbox-teleporter-or-redirect-because-expired.js new file mode 100644 index 0000000000..f69314b851 --- /dev/null +++ b/website/api/controllers/try-fleet/view-sandbox-teleporter-or-redirect-because-expired.js @@ -0,0 +1,61 @@ +module.exports = { + + + friendlyName: 'View sandbox teleporter or redirect because sandbox expired', + + description: + `Display "Sandbox teleporter" page (an auto-submitting interstitial HTML form used as a hack to grab a bit of HTML + from the Fleet Sandbox instance, which sets browser localstorage to consider this user logged in and "teleports" them, + magically authenticated, into their Fleet Sandbox instance running on a different domain), or redirect the user to + a page about their sandbox instance being expired.`, + + moreInfoUrl: 'https://github.com/fleetdm/fleet/pull/6380', + + + exits: { + + success: { + viewTemplatePath: 'pages/try-fleet/sandbox-teleporter', + description: 'This user is being logged into their Fleet Sandbox instance.' + }, + + redirect: { + description: 'This user does not have a valid Fleet Sandbox instance and is being redirected.', + responseType: 'redirect' + }, + + }, + + + fn: async function () { + + if(!this.req.me) { + throw {redirect: '/try-fleet/login' }; + } + + if(!this.req.me.fleetSandboxURL) { + throw new Error(`Consistency violation: The logged-in user's (${this.req.me.emailAddress}) fleetSandboxURL has somehow gone missing!`); + } + + if(!this.req.me.fleetSandboxExpiresAt) { + throw new Error(`Consistency violation: The logged-in user's (${this.req.me.emailAddress}) fleetSandboxExpiresAt has somehow gone missing!`); + } + + if(!this.req.me.fleetSandboxDemoKey) { + throw new Error(`Consistency violation: The logged-in user's (${this.req.me.emailAddress}) fleetSandboxDemoKey has somehow gone missing!`); + } + + // If this user's Fleet Sandbox instance is expired, we'll redirect them to the sandbox-expired page + if(this.req.me.fleetSandboxExpiresAt < Date.now()){ + throw {redirect: '/try-fleet/sandbox-expired' }; + } + + // Respond with view. + return { + hideHeaderOnThisPage: true, + }; + + } + + +}; diff --git a/website/api/models/User.js b/website/api/models/User.js index 7a23e61838..b38eb34898 100644 --- a/website/api/models/User.js +++ b/website/api/models/User.js @@ -66,7 +66,6 @@ module.exports = { organization: { type: 'string', - required: true, description: 'The organization the user works for.', maxLength: 120, example: 'The Sails Company', @@ -171,6 +170,23 @@ without necessarily having a billing card.` example: 1502844074211 }, + fleetSandboxURL: { + type: 'string', + description: 'The URL of the Fleet sandbox instance that was provisioned for this user', + example: 'https://billybobcat.sandbox.fleetdm.com', + }, + + fleetSandboxExpiresAt: { + type: 'number', + description: 'An JS timestamp (epoch ms) representing when this user\'s fleet sandbox instance will expire', + example: '1502844074211', + }, + + fleetSandboxDemoKey: { + type: 'string', + description: 'The UUID that is used as the password of this user\'s Fleet Sandbox instance that is generated when the user signs up. Only used to log the user into their Fleet Sandbox instance while it is still live.', + } + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ // ║╣ ║║║╠╩╗║╣ ║║╚═╗ // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ diff --git a/website/api/responses/unauthorized.js b/website/api/responses/unauthorized.js index cef128246a..650cb992f2 100644 --- a/website/api/responses/unauthorized.js +++ b/website/api/responses/unauthorized.js @@ -37,7 +37,7 @@ module.exports = function unauthorized() { delete req.session.userId; } - return res.redirect('/customers/login'); + return res.redirect('/login'); } }; diff --git a/website/assets/images/arrow-right-blue-18x10@2x.png b/website/assets/images/arrow-right-blue-18x10@2x.png new file mode 100644 index 0000000000..819219e05e Binary files /dev/null and b/website/assets/images/arrow-right-blue-18x10@2x.png differ diff --git a/website/assets/images/fleet-sandbox-300x244@2x.png b/website/assets/images/fleet-sandbox-300x244@2x.png new file mode 100644 index 0000000000..7ca07d6dae Binary files /dev/null and b/website/assets/images/fleet-sandbox-300x244@2x.png differ diff --git a/website/assets/js/cloud.setup.js b/website/assets/js/cloud.setup.js index d3ea1578cd..0aa63ebc37 100644 --- a/website/assets/js/cloud.setup.js +++ b/website/assets/js/cloud.setup.js @@ -13,7 +13,7 @@ Cloud.setup({ /* eslint-disable */ - methods: {"downloadSitemap":{"verb":"GET","url":"/sitemap.xml","args":[]},"receiveUsageAnalytics":{"verb":"POST","url":"/api/v1/webhooks/receive-usage-analytics","args":["anonymousIdentifier","fleetVersion","licenseTier","numHostsEnrolled","numUsers","numTeams","numPolicies","numLabels","softwareInventoryEnabled","vulnDetectionEnabled","systemUsersEnabled","hostStatusWebhookEnabled","numWeeklyActiveUsers","hostsEnrolledByOperatingSystem","storedErrors","numHostsNotResponding","organization"]},"receiveFromGithub":{"verb":"GET","url":"/api/v1/webhooks/github","args":["botSignature","action","sender","repository","changes","issue","comment","pull_request","label"]},"deliverContactFormMessage":{"verb":"POST","url":"/api/v1/deliver-contact-form-message","args":["emailAddress","topic","firstName","lastName","message"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"signup":{"verb":"POST","url":"/api/v1/customers/signup","args":["emailAddress","password","organization","firstName","lastName"]},"updateProfile":{"verb":"POST","url":"/api/v1/account/update-profile","args":["firstName","lastName","organization","emailAddress"]},"updatePassword":{"verb":"POST","url":"/api/v1/account/update-password","args":["oldPassword","newPassword"]},"updateBillingCard":{"verb":"POST","url":"/api/v1/account/update-billing-card","args":["stripeToken","billingCardLast4","billingCardBrand","billingCardExpMonth","billingCardExpYear"]},"login":{"verb":"POST","url":"/api/v1/customers/login","args":["emailAddress","password","rememberMe"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"createQuote":{"verb":"POST","url":"/api/v1/customers/create-quote","args":["numberOfHosts"]},"saveBillingInfoAndSubscribe":{"verb":"POST","url":"/api/v1/customers/save-billing-info-and-subscribe","args":["quoteId","paymentSource"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deliverDemoSignup":{"verb":"POST","url":"/api/v1/deliver-demo-signup","args":["emailAddress"]}} + methods: {"downloadSitemap":{"verb":"GET","url":"/sitemap.xml","args":[]},"receiveUsageAnalytics":{"verb":"POST","url":"/api/v1/webhooks/receive-usage-analytics","args":["anonymousIdentifier","fleetVersion","licenseTier","numHostsEnrolled","numUsers","numTeams","numPolicies","numLabels","softwareInventoryEnabled","vulnDetectionEnabled","systemUsersEnabled","hostStatusWebhookEnabled","numWeeklyActiveUsers","hostsEnrolledByOperatingSystem","storedErrors","numHostsNotResponding","organization"]},"receiveFromGithub":{"verb":"GET","url":"/api/v1/webhooks/github","args":["botSignature","action","sender","repository","changes","issue","comment","pull_request","label"]},"deliverContactFormMessage":{"verb":"POST","url":"/api/v1/deliver-contact-form-message","args":["emailAddress","topic","firstName","lastName","message"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"signup":{"verb":"POST","url":"/api/v1/customers/signup","args":["emailAddress","password","organization","firstName","lastName","signupReason"]},"updateProfile":{"verb":"POST","url":"/api/v1/account/update-profile","args":["firstName","lastName","organization","emailAddress"]},"updatePassword":{"verb":"POST","url":"/api/v1/account/update-password","args":["oldPassword","newPassword"]},"updateBillingCard":{"verb":"POST","url":"/api/v1/account/update-billing-card","args":["stripeToken","billingCardLast4","billingCardBrand","billingCardExpMonth","billingCardExpYear"]},"login":{"verb":"POST","url":"/api/v1/customers/login","args":["emailAddress","password","rememberMe"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"createQuote":{"verb":"POST","url":"/api/v1/customers/create-quote","args":["numberOfHosts"]},"saveBillingInfoAndSubscribe":{"verb":"POST","url":"/api/v1/customers/save-billing-info-and-subscribe","args":["quoteId","paymentSource"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deliverDemoSignup":{"verb":"POST","url":"/api/v1/deliver-demo-signup","args":["emailAddress"]}} /* eslint-enable */ }); diff --git a/website/assets/js/pages/get-started.page.js b/website/assets/js/pages/get-started.page.js index 2cde1d82c3..cfa1251495 100644 --- a/website/assets/js/pages/get-started.page.js +++ b/website/assets/js/pages/get-started.page.js @@ -14,7 +14,7 @@ parasails.registerPage('get-started', { // If the user navigated to this page from the 'try it now' button, we'll strip the '?tryitnow' from the url. if(window.location.search){ // https://caniuse.com/mdn-api_history_replacestate - window.history.replaceState({}, document.title, '/get-started' ); + window.history.replaceState({}, document.title, '/fleetctl-preview' ); } }, mounted: async function() { diff --git a/website/assets/js/pages/try-fleet/register.page.js b/website/assets/js/pages/try-fleet/register.page.js new file mode 100644 index 0000000000..673933d1a3 --- /dev/null +++ b/website/assets/js/pages/try-fleet/register.page.js @@ -0,0 +1,55 @@ +parasails.registerPage('register', { + // ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗ + // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ + // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ + data: { + formData: { /* … */ }, + // For tracking client-side validation errors in our form. + // > Has property set to `true` for each invalid property in `formData`. + formErrors: { /* … */ }, + + // Form rules + formRules: { + emailAddress: {required: true, isEmail: true}, + password: {required: true, minLength: 8}, + }, + // Syncing / loading state + syncing: false, + // Server error state + cloudError: '', + }, + + // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ + // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ + // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ + beforeMount: function() { + // If the user navigated to this page from the 'try it now' button, we'll strip the '?tryitnow' from the url. + if(window.location.search){ + // https://caniuse.com/mdn-api_history_replacestate + window.history.replaceState({}, document.title, '/try-fleet/register' ); + } + }, + mounted: async function() { + //… + }, + + // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ + // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ + methods: { + + // Using handle-submitting to add firstName, and lastName values to our formData before sending it to signup.js + handleSubmittingRegisterForm: async function(argins) { + argins.firstName = argins.emailAddress.split('@')[0]; + argins.lastName = argins.emailAddress.split('@')[1]; + argins.signupReason = 'Try Fleet Sandbox'; + return await Cloud.signup.with(argins); + }, + + // After the form is submitted, we'll redirect the user to their Fleet sandbox instance. + submittedRegisterForm: async function() { + this.syncing = true; + window.location = '/try-fleet/sandbox'; + } + } +}); diff --git a/website/assets/js/pages/try-fleet/sandbox-expired.page.js b/website/assets/js/pages/try-fleet/sandbox-expired.page.js new file mode 100644 index 0000000000..f8a376c6ea --- /dev/null +++ b/website/assets/js/pages/try-fleet/sandbox-expired.page.js @@ -0,0 +1,25 @@ +parasails.registerPage('sandbox-expired', { + // ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗ + // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ + // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ + data: { + //… + }, + + // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ + // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ + // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ + beforeMount: function() { + //… + }, + mounted: async function() { + //… + }, + + // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ + // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ + methods: { + //… + } +}); diff --git a/website/assets/js/pages/try-fleet/sandbox-login.page.js b/website/assets/js/pages/try-fleet/sandbox-login.page.js new file mode 100644 index 0000000000..62e195dcb9 --- /dev/null +++ b/website/assets/js/pages/try-fleet/sandbox-login.page.js @@ -0,0 +1,47 @@ +parasails.registerPage('sandbox-login', { + // ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗ + // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ + // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ + data: { + // Main syncing/loading state for this page. + syncing: false, + + // Form data + formData: { }, + + // For tracking client-side validation errors in our form. + // > Has property set to `true` for each invalid property in `formData`. + formErrors: { /* … */ }, + + // A set of validation rules for our form. + // > The form will not be submitted if these are invalid. + formRules: { + emailAddress: { required: true, isEmail: true }, + password: { required: true }, + }, + + // Server error state for the form + cloudError: '', + }, + + // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ + // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ + // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ + beforeMount: function() { + //… + }, + mounted: async function() { + //… + }, + + // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ + // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ + methods: { + + submittedLoginForm: async function() { + this.syncing = true; + window.location = '/try-fleet/sandbox'; + } + } +}); diff --git a/website/assets/js/pages/try-fleet/sandbox-teleporter.page.js b/website/assets/js/pages/try-fleet/sandbox-teleporter.page.js new file mode 100644 index 0000000000..730a9b3869 --- /dev/null +++ b/website/assets/js/pages/try-fleet/sandbox-teleporter.page.js @@ -0,0 +1,38 @@ +parasails.registerPage('sandbox-teleporter', { + // ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗ + // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ + // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ + data: { + // Main syncing/loading state for this page. + syncing: false, + + }, + + // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ + // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ + // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ + beforeMount: function() { + //… + }, + mounted: async function() { + + // Replacing this page with the fleetdm.com homepage in the user's browser history, so when users click the back button from their Sandbox instance, they won't be redirected to their Sandbox instance. + window.history.replaceState({}, '', '/'); + + // Binding an event handler to 'onpageshow', if a user navigates to a locally cached version of this page (e.g., A Safari user clicking the back button from their Fleet Sandbox), they will be taken to the fleetdm.com homepage. + window.onpageshow = function(event) { + if(event.persisted) { + window.location = '/'; + } + }; + // Confused? Understandable, this approach is a bit unusual. See this page's view action for more info on what this code is doing and why, as well as a link where you can read more information. + document.forms['demologin'].submit(); + }, + + // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ + // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ + methods: { + //… + } +}); diff --git a/website/assets/styles/importer.less b/website/assets/styles/importer.less index d222b2b1f8..4a63552fde 100644 --- a/website/assets/styles/importer.less +++ b/website/assets/styles/importer.less @@ -49,6 +49,10 @@ @import 'pages/customers/new-license.less'; @import 'pages/customers/dashboard.less'; @import 'pages/landing.less'; +@import 'pages/try-fleet/sandbox-login.less'; +@import 'pages/try-fleet/register.less'; +@import 'pages/try-fleet/sandbox-teleporter.less'; +@import 'pages/try-fleet/sandbox-expired.less'; @import 'pages/sales-one-pager.less'; @import 'pages/query-detail.less'; @import 'pages/query-library.less'; diff --git a/website/assets/styles/pages/try-fleet/register.less b/website/assets/styles/pages/try-fleet/register.less new file mode 100644 index 0000000000..67e7c887dd --- /dev/null +++ b/website/assets/styles/pages/try-fleet/register.less @@ -0,0 +1,53 @@ +#register { + padding-top: 120px; + background-color: #FFF; + p { + font-size: 14px; + line-height: 20px; + } + h2 { + font-size: 24px; + line-height: 24px; + font-weight: 800; + } + a { + color: @core-vibrant-blue; + cursor: pointer; + } + input { + padding: 16px; + border-radius: 12px; + } + input:focus-visible { + border: 1px solid @core-vibrant-blue; + outline: none; + } + input::placeholder { + color: #8B8FA2; + } + [purpose='sandbox-image'] { + width: 300px; + margin-right: 65px; + } + [purpose='error-message'] { + font-size: 16px; + } + [parasails-component='cloud-error'] { + padding: 16px 10px 16px 16px; + } + [parasails-component='ajax-button'] { + border-radius: 12px; + font-size: 16px; + line-height: 18px; + padding-top: 17.5px; + padding-bottom: 17.5px; + } + @media (max-width: 768px) { + padding-top: 0px; + [purpose='sandbox-image'] { + margin-right: 0px; + margin-bottom: 16px; + width: 250px; + } + } +} diff --git a/website/assets/styles/pages/try-fleet/sandbox-expired.less b/website/assets/styles/pages/try-fleet/sandbox-expired.less new file mode 100644 index 0000000000..8d16d96569 --- /dev/null +++ b/website/assets/styles/pages/try-fleet/sandbox-expired.less @@ -0,0 +1,69 @@ +#sandbox-expired { + + padding-top: 120px; + background-color: #FFF; + p { + font-size: 14px; + line-height: 20px; + } + h2 { + font-size: 24px; + line-height: 24px; + font-weight: 800; + } + a { + color: @core-vibrant-blue; + cursor: pointer; + } + input { + padding: 16px; + border-radius: 12px; + } + input:focus-visible { + border: 1px solid @core-vibrant-blue; + outline: none; + } + input::placeholder { + color: #8B8FA2; + } + [purpose='sandbox-image'] { + width: 300px; + margin-right: 65px; + } + [purpose='error-message'] { + font-size: 16px; + } + [purpose='next-steps-button'] { + font-size: 16px; + font-weight: 700; + line-height: 24px; + padding: 16px; + img { + display: inline; + height: 24px; + width: auto; + padding-top: 0px; + padding-bottom: 0px; + margin: 0px; + } + } + [parasails-component='cloud-error'] { + padding: 16px 10px 16px 16px; + } + [parasails-component='ajax-button'] { + border-radius: 12px; + font-size: 16px; + line-height: 18px; + padding-top: 17.5px; + padding-bottom: 17.5px; + } + @media (max-width: 768px) { + padding-top: 0px; + [purpose='sandbox-image'] { + margin-right: 0px; + margin-bottom: 16px; + width: 250px; + } + } + +} diff --git a/website/assets/styles/pages/try-fleet/sandbox-login.less b/website/assets/styles/pages/try-fleet/sandbox-login.less new file mode 100644 index 0000000000..c411d8cc0c --- /dev/null +++ b/website/assets/styles/pages/try-fleet/sandbox-login.less @@ -0,0 +1,53 @@ +#sandbox-login { + padding-top: 120px; + background-color: #FFF; + p { + font-size: 14px; + line-height: 20px; + } + h2 { + font-size: 24px; + line-height: 24px; + font-weight: 800; + } + a { + color: @core-vibrant-blue; + cursor: pointer; + } + input { + padding: 16px; + border-radius: 12px; + } + input:focus-visible { + border: 1px solid @core-vibrant-blue; + outline: none; + } + input::placeholder { + color: #8B8FA2; + } + [purpose='sandbox-image'] { + width: 300px; + margin-right: 65px; + } + [purpose='error-message'] { + font-size: 14px; + } + [parasails-component='cloud-error'] { + padding: 16px 10px 16px 16px; + } + [parasails-component='ajax-button'] { + border-radius: 12px; + font-size: 16px; + line-height: 18px; + padding-top: 17.5px; + padding-bottom: 17.5px; + } + @media (max-width: 768px) { + padding-top: 0px; + [purpose='sandbox-image'] { + margin-right: 0px; + margin-bottom: 16px; + width: 250px; + } + } +} diff --git a/website/assets/styles/pages/try-fleet/sandbox-teleporter.less b/website/assets/styles/pages/try-fleet/sandbox-teleporter.less new file mode 100644 index 0000000000..b49b854520 --- /dev/null +++ b/website/assets/styles/pages/try-fleet/sandbox-teleporter.less @@ -0,0 +1,17 @@ +#sandbox-teleporter { + padding-top: 30vh; + [purpose='loading-spinner'] { + width: 80px; + height: 80px; + border: 3px solid @core-vibrant-blue; + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; + } + @keyframes rotation { + 0% {transform: rotate(0deg);} + 100% {transform: rotate(360deg);} + } +} diff --git a/website/config/bootstrap.js b/website/config/bootstrap.js index 964efed13f..037060cf82 100644 --- a/website/config/bootstrap.js +++ b/website/config/bootstrap.js @@ -15,7 +15,7 @@ module.exports.bootstrap = async function() { var path = require('path'); // This bootstrap version indicates what version of fake data we're dealing with here. - var HARD_CODED_DATA_VERSION = 0; + var HARD_CODED_DATA_VERSION = 1; // This path indicates where to store/look for the JSON file that tracks the "last run bootstrap info" // locally on this development computer (if we happen to be on a development computer). @@ -65,6 +65,9 @@ module.exports.bootstrap = async function() { lastName: 'Dahl', organization: 'Golaith Industries', isSuperAdmin: true, + fleetSandboxURL: 'http://example.com', + fleetSandboxExpiresAt: 1, + fleetSandboxDemoKey: await sails.helpers.strings.uuid(), password: await sails.helpers.passwords.hashPassword('abc123') }).fetch(); diff --git a/website/config/policies.js b/website/config/policies.js index 3b7b3498f4..e97d7aa2a9 100644 --- a/website/config/policies.js +++ b/website/config/policies.js @@ -37,5 +37,8 @@ module.exports.policies = { 'articles/*': true, 'reports/*': true, 'view-sales-one-pager': true, + 'try-fleet/view-register': true, + 'try-fleet/view-sandbox-login': true, + 'try-fleet/view-sandbox-teleporter-or-redirect-because-expired': true, }; diff --git a/website/config/routes.js b/website/config/routes.js index 34827bad45..e2d165ab1e 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -26,12 +26,12 @@ module.exports.routes = { } }, - 'GET /get-started': { - action: 'view-get-started' , + 'GET /fleetctl-preview': { + action: 'view-get-started', locals: { currentPage: 'get started', - pageTitleForMeta: 'Get started | Fleet for osquery', - pageDescriptionForMeta: 'Learn about getting started with Fleet.' + pageTitleForMeta: 'fleetctl preview | Fleet for osquery', + pageDescriptionForMeta: 'Learn about getting started with Fleet using fleetctl.' } }, @@ -180,6 +180,33 @@ module.exports.routes = { }, }, + 'GET /try-fleet/register': { + action: 'try-fleet/view-register', + locals: { + layout: 'layouts/layout-sandbox', + } + }, + + 'GET /try-fleet/login': { + action: 'try-fleet/view-sandbox-login', + locals: { + layout: 'layouts/layout-sandbox', + } + }, + + 'GET /try-fleet/sandbox': { + action: 'try-fleet/view-sandbox-teleporter-or-redirect-because-expired', + locals: { + layout: 'layouts/layout-sandbox', + }, + }, + + 'GET /try-fleet/sandbox-expired': { + action: 'try-fleet/view-sandbox-expired', + locals: { + layout: 'layouts/layout-sandbox', + }, + }, // ╦ ╔═╗╔═╗╔═╗╔═╗╦ ╦ ╦═╗╔═╗╔╦╗╦╦═╗╔═╗╔═╗╔╦╗╔═╗ @@ -250,7 +277,9 @@ module.exports.routes = { 'GET /docs/using-fleet/updating-fleet': '/docs/deploying/upgrading-fleet', 'GET /blog': '/articles', 'GET /brand': '/logos', + 'GET /get-started': '/fleetctl-preview', 'GET /g': (req,res)=> { let originalQueryStringWithAmp = req.url.match(/\?(.+)$/) ? '&'+req.url.match(/\?(.+)$/)[1] : ''; return res.redirect(301, sails.config.custom.baseUrl+'/?meet-fleet'+originalQueryStringWithAmp); }, + 'GET /test-fleet-sandbox': '/try-fleet/register', // Sitemap // ============================================================================================================= @@ -299,5 +328,4 @@ module.exports.routes = { 'POST /api/v1/customers/save-billing-info-and-subscribe': { action: 'customers/save-billing-info-and-subscribe' }, 'POST /api/v1/entrance/update-password-and-login': { action: 'entrance/update-password-and-login' }, 'POST /api/v1/deliver-demo-signup': { action: 'deliver-demo-signup' }, - }; diff --git a/website/views/layouts/layout-customer.ejs b/website/views/layouts/layout-customer.ejs index e0f85ef5d7..dd21004671 100644 --- a/website/views/layouts/layout-customer.ejs +++ b/website/views/layouts/layout-customer.ejs @@ -220,6 +220,10 @@ + + + + <% /* Display an overlay if the current browser is not supported. diff --git a/website/views/layouts/layout-landing.ejs b/website/views/layouts/layout-landing.ejs index 16af5ea2b6..931ef6e806 100644 --- a/website/views/layouts/layout-landing.ejs +++ b/website/views/layouts/layout-landing.ejs @@ -227,6 +227,10 @@ + + + + <% /* Display an overlay if the current browser is not supported. diff --git a/website/views/layouts/layout-sandbox.ejs b/website/views/layouts/layout-sandbox.ejs new file mode 100644 index 0000000000..1cb8c11085 --- /dev/null +++ b/website/views/layouts/layout-sandbox.ejs @@ -0,0 +1,430 @@ +<% + // In case we're displaying the 404 or 500 page and relevant code in the "custom" hook was not able to run, + // we make sure certain view locals exist that are commonly used in this layout.ejs file. This ensures we + // don't have to do `typeof` checks below. + var me; + var hideHeaderOnThisPage; +%> + + + <%= typeof pageTitleForMeta !== 'undefined' ? pageTitleForMeta : 'Fleet for osquery | Open source device management' %> + + + <% /* Viewport tag for sensible mobile support */ %> + + + + + + + <% /* Script tags should normally be included further down the page- but any + scripts that load fonts (e.g. Fontawesome ≥v5) are special exceptions to the + rule. (Include them up here along with any hard-coded «link» tags for Typekit, + Google Fonts, etc. - above the «body» to prevent the page flickering when fonts + load.) */ %> + + <% /* Certain scripts, normally analytics tools like Google Tag Manager and + Google Analytics, should only be included in production: */ + if (sails.config.environment === 'production') { %> + <% /* Rollbar */%> + + <% /* Google Analytics, Google Tag Manager, etc. */ %> + + + + <%/* Meta pixel code */%> + + + <%/* Snitcher analytics code */%> + + <% } + /* Otherwise, any such scripts are excluded, and we instead inject a + robots/noindex meta tag to help prevent any unwanted visits from search engines. */ + else { %> + + <% } %> + <% /* + Stylesheets + ======================== + + Stylesheets can be hard-coded as «link» tags, automatically injected + by the asset pipeline between "STYLES" and "STYLES END", or both. + (https://sailsjs.com/docs/concepts/assets/task-automation) + */ %> + + <% /* Auto-injected «link» tags: */ %> + + + + + + + + +
+
+
+ + Fleet logo + +
+ +
+ <%/* Mobile Navigation menu */%> + + <%/* Desktop Navigation bar */%> + +
+
+ + <%- body %> + +
+
+
+ Creative Commons Licence CC BY-SA 4.0 +
+ © 2022 Fleet Device Management Inc. + Privacy +
+
+
+
+ +
+ + <% /* + Client-side JavaScript + ======================== + + Scripts can be hard-coded as «script» tags, automatically injected + by the asset pipeline between "SCRIPTS" and "SCRIPTS END", or both. + (https://sailsjs.com/docs/concepts/assets/task-automation) + */ %> + <% /* Cookie consent banner */ %> + + <% /* Chat (Papercups) */ %> + + + + <%/* Stripe.js */%> + + + <%/* Linkedin Ads Insight tag */%> + + + + <% /* Delete the global `self` to help avoid client-side bugs. + (see https://developer.mozilla.org/en-US/docs/Web/API/Window/self) */ %> + + + <%/* bowser.js (for browser detection) -- included inline to avoid issues with minification that could affect the unsupported browser overlay */%> + + + <% /* Auto-injected «script» tags: */ %> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <% /* Display an overlay if the current browser is not supported. + (Relies on `bowser`, which is loaded inline above.) */ %> + + + <% /* Keep footer hidden until the document is ready (prevents flicker that is especially unattractive on mobile) */ %> + + + + diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index c3b50a23f3..17a2027865 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -189,8 +189,12 @@
- Pricing - Try it out + Pricing + <% if(_.has(me, 'id')) {%> +
+ Log out + <% }%> + Try it out <%/* Desktop Navigation bar */%> @@ -240,7 +244,10 @@ - Try it out + Try it out + <% if(_.has(me, 'id')) {%> + Log out + <% }%> @@ -452,6 +459,10 @@ + + + + <% /* Display an overlay if the current browser is not supported. diff --git a/website/views/pages/entrance/forgot-password.ejs b/website/views/pages/entrance/forgot-password.ejs index 190b4a2a4f..fbe4b95ea9 100644 --- a/website/views/pages/entrance/forgot-password.ejs +++ b/website/views/pages/entrance/forgot-password.ejs @@ -22,7 +22,7 @@

If the email you entered is associated with a Fleet account, you should receive a recovery email shortly. If the email doesn’t arrive, please try again, or contact support.

-

Back to login

+

Back to homepage

diff --git a/website/views/pages/entrance/new-password.ejs b/website/views/pages/entrance/new-password.ejs index 1c12a231f5..a5894dfbc6 100644 --- a/website/views/pages/entrance/new-password.ejs +++ b/website/views/pages/entrance/new-password.ejs @@ -6,14 +6,14 @@
- +
Please enter a password.
Password too short.

Minimum length is 8 characters

- +
Password too short.
Your new password and confirmation do not match.
diff --git a/website/views/pages/try-fleet/register.ejs b/website/views/pages/try-fleet/register.ejs new file mode 100644 index 0000000000..4d9178332b --- /dev/null +++ b/website/views/pages/try-fleet/register.ejs @@ -0,0 +1,41 @@ +
+
+
+ Play in Fleet sandbox +
+
+

Play in Fleet Sandbox

+

+ Fleet Sandbox is designed for testing Fleet features only.
+ Click here for production-ready deployments. +

+
+ +
+ +
This doesn’t appear to be a valid email address
+
+
+ +
Password too short.
+
Please enter a password.
+
+ Sign up +
+ +

This email is already linked to a Fleet account.

+
+ Sign in with existing account A blue arrow pointing right + Try again +
+ +
+
+
+ I have an account +

By continuing you agree to the
terms of service and privacy policy.

+
+
+
+
+<%- /* Expose server-rendered data as window.SAILS_LOCALS :: */ exposeLocalsToBrowser() %> diff --git a/website/views/pages/try-fleet/sandbox-expired.ejs b/website/views/pages/try-fleet/sandbox-expired.ejs new file mode 100644 index 0000000000..4d43880f81 --- /dev/null +++ b/website/views/pages/try-fleet/sandbox-expired.ejs @@ -0,0 +1,20 @@ +
+
+
+ Play in Fleet sandbox +
+
+

Thanks for trying Fleet Sandbox

+

+ Your trial period for Fleet Sandbox has expired.
+

+ Schedule a demo + + Slack logo + Join the community on Slack + +

Got questions? Contact support.

+
+
+
+<%- /* Expose server-rendered data as window.SAILS_LOCALS :: */ exposeLocalsToBrowser() %> diff --git a/website/views/pages/try-fleet/sandbox-login.ejs b/website/views/pages/try-fleet/sandbox-login.ejs new file mode 100644 index 0000000000..ddfe648d78 --- /dev/null +++ b/website/views/pages/try-fleet/sandbox-login.ejs @@ -0,0 +1,42 @@ +
+
+
+ Play in Fleet sandbox +
+
+

Play in Fleet Sandbox

+

+ Fleet Sandbox is designed for testing Fleet features only.
+ Click here for production-ready deployments. +

+
+ +
+ +
This doesn’t appear to be a valid email address
+
+
+ +
Password too short.
+
Please enter a password.
+
+ +

Something's not right with your email or password

+
+ + Sign in +
+
+
+ +

By continuing you agree to the
terms of service and privacy policy.

+
+
+
+
+<%- /* Expose server-rendered data as window.SAILS_LOCALS :: */ exposeLocalsToBrowser() %> + diff --git a/website/views/pages/try-fleet/sandbox-teleporter.ejs b/website/views/pages/try-fleet/sandbox-teleporter.ejs new file mode 100644 index 0000000000..a07bce19b4 --- /dev/null +++ b/website/views/pages/try-fleet/sandbox-teleporter.ejs @@ -0,0 +1,13 @@ +
+ +
+ + + +
+
+
+
+
+
+<%- /* Expose server-rendered data as window.SAILS_LOCALS :: */ exposeLocalsToBrowser() %>