From 46802ee56a98e297371e3c62e66c7ae2d1345eee Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 4 Aug 2023 18:32:00 -0500 Subject: [PATCH] Website: Add admin page to manage the Fleet Sandbox waitlist (#13111) Closes: #12954 Changes: - Added an admin page that displays a table containing all of the users that are currently on the Fleet Sandbox waitlist where admins can approve waitlisted users. - Added a new email template that tells users that their Fleet Sandbox instance is ready. - Added a new action: `admin/provision-sandbox-instance-and-deliver-email.js`, an action that provisions a Fleet sandbox instance for a single user and sends them an email telling them that their Fleet Sandbox Instance is ready. - Added a script that provisions a Fleet Sandbox instance for the user who has been on the waitlist the longest and sends them an email telling them that their Sandbox instance is ready. --- ...sion-sandbox-instance-and-deliver-email.js | 66 ++++++++++ .../admin/view-email-template-preview.js | 4 + .../admin/view-sandbox-waitlist.js | 31 +++++ website/api/controllers/entrance/signup.js | 72 +++-------- .../provision-new-fleet-sandbox-instance.js | 117 ++++++++++++++++++ website/assets/js/cloud.setup.js | 2 +- .../js/pages/admin/sandbox-waitlist.page.js | 32 +++++ website/assets/styles/importer.less | 1 + website/assets/styles/layout.less | 16 +++ .../styles/pages/admin/sandbox-waitlist.less | 64 ++++++++++ website/config/routes.js | 8 ++ ...instance-for-one-user-and-deliver-email.js | 62 ++++++++++ .../emails/email-sandbox-ready-approved.ejs | 9 ++ website/views/layouts/layout-customer.ejs | 13 ++ website/views/layouts/layout-email.ejs | 20 +-- website/views/layouts/layout-landing.ejs | 1 + website/views/layouts/layout-sandbox.ejs | 1 + website/views/layouts/layout.ejs | 1 + .../views/pages/admin/sandbox-waitlist.ejs | 27 ++++ website/views/pages/try-fleet/register.ejs | 2 +- 20 files changed, 480 insertions(+), 69 deletions(-) create mode 100644 website/api/controllers/admin/provision-sandbox-instance-and-deliver-email.js create mode 100644 website/api/controllers/admin/view-sandbox-waitlist.js create mode 100644 website/api/helpers/fleet-sandbox-cloud-provisioner/provision-new-fleet-sandbox-instance.js create mode 100644 website/assets/js/pages/admin/sandbox-waitlist.page.js create mode 100644 website/assets/styles/pages/admin/sandbox-waitlist.less create mode 100644 website/scripts/provision-sandbox-instance-for-one-user-and-deliver-email.js create mode 100644 website/views/emails/email-sandbox-ready-approved.ejs create mode 100644 website/views/pages/admin/sandbox-waitlist.ejs diff --git a/website/api/controllers/admin/provision-sandbox-instance-and-deliver-email.js b/website/api/controllers/admin/provision-sandbox-instance-and-deliver-email.js new file mode 100644 index 000000000000..0222a2c1ee55 --- /dev/null +++ b/website/api/controllers/admin/provision-sandbox-instance-and-deliver-email.js @@ -0,0 +1,66 @@ +module.exports = { + + + friendlyName: 'Provision sandbox instance and deliver email', + + + description: 'Provisions a Fleet sandbox for a user and delivers an email to a user letting them know their Fleet Sandbox instance is ready.', + + + inputs: { + userId: { + type: 'number', + description: 'The database ID of the user who is currently on the Fleet Sandbox waitlist', + required: true + } + }, + + + exits: { + success: { + description: 'A user was successfully removed from the Fleet Sandbox waitlist.' + }, + }, + + + fn: async function ({userId}) { + + let userToRemoveFromSandboxWaitlist = await User.findOne({id: userId}); + + if(!userToRemoveFromSandboxWaitlist.inSandboxWaitlist) { + throw new Error(`When attempting to provision a Fleet Sandbox instance for a user (id:${userId}) who is on the waitlist, the user record associated with the provided ID has already been removed from the waitlist.`); + } + + let sandboxInstanceDetails = await sails.helpers.fleetSandboxCloudProvisioner.provisionNewFleetSandboxInstance.with({ + firstName: userToRemoveFromSandboxWaitlist.firstName, + lastName: userToRemoveFromSandboxWaitlist.lastName, + emailAddress: userToRemoveFromSandboxWaitlist.emailAddress, + }) + .intercept((err)=>{ + return new Error(`When attempting to provision a new Fleet Sandbox instance for a User (id:${userToRemoveFromSandboxWaitlist.id}), an error occured. Full error: ${err}`); + }); + + await User.updateOne({id: userId}).set({ + fleetSandboxURL: sandboxInstanceDetails.fleetSandboxURL, + fleetSandboxExpiresAt: sandboxInstanceDetails.fleetSandboxExpiresAt, + fleetSandboxDemoKey: sandboxInstanceDetails.fleetSandboxDemoKey, + inSandboxWaitlist: false, + }); + + // Send the user an email to let them know that their Fleet sandbox instance is ready. + await sails.helpers.sendTemplateEmail.with({ + to: userToRemoveFromSandboxWaitlist.emailAddress, + from: sails.config.custom.fromEmailAddress, + fromName: sails.config.custom.fromName, + subject: 'Your Fleet Sandbox instance is ready!', + template: 'email-sandbox-ready-approved', + templateData: {}, + }); + + // All done. + return; + + } + + +}; diff --git a/website/api/controllers/admin/view-email-template-preview.js b/website/api/controllers/admin/view-email-template-preview.js index 08962fff866c..08c6a0589bf1 100644 --- a/website/api/controllers/admin/view-email-template-preview.js +++ b/website/api/controllers/admin/view-email-template-preview.js @@ -110,6 +110,10 @@ module.exports = { layout = 'layout-email'; fakeData = {}; break; + case 'email-sandbox-ready-approved': + layout = 'layout-email'; + fakeData = {}; + break; default: layout = 'layout-email-newsletter'; fakeData = { diff --git a/website/api/controllers/admin/view-sandbox-waitlist.js b/website/api/controllers/admin/view-sandbox-waitlist.js new file mode 100644 index 000000000000..53628cf298ae --- /dev/null +++ b/website/api/controllers/admin/view-sandbox-waitlist.js @@ -0,0 +1,31 @@ +module.exports = { + + + friendlyName: 'View sandbox waitlist', + + + description: 'Display "Sandbox waitlist" page.', + + + exits: { + + success: { + viewTemplatePath: 'pages/admin/sandbox-waitlist' + } + + }, + + + fn: async function () { + + let usersCurrentlyOnWaitlist = await User.find({inSandboxWaitlist: true}) + .sort('createdAt ASC'); + + return { + usersWaitingForSandboxInstance: usersCurrentlyOnWaitlist + }; + + } + + +}; diff --git a/website/api/controllers/entrance/signup.js b/website/api/controllers/entrance/signup.js index 9a890be3a91c..a9c4801ec7ea 100644 --- a/website/api/controllers/entrance/signup.js +++ b/website/api/controllers/entrance/signup.js @@ -79,7 +79,7 @@ the account verification message.)`, 'parameters should have been validated/coerced _before_ they were sent.' }, - requestToSandboxTimedOut: { + requestToProvisionerTimedOut: { statusCode: 408, description: 'The request to the cloud provisioner exceeded the set timeout.', }, @@ -151,63 +151,19 @@ the account verification message.)`, // If the Fleet Sandbox waitlist is not enabled (sails.config.custom.fleetSandboxWaitlistEnabled) 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. - const FIVE_DAYS_IN_MS = (5*24*60*60*1000); - // 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() + FIVE_DAYS_IN_MS; - - // 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(10000)// FUTURE: set this timeout to be 5000ms - .intercept(['requestFailed', 'non200Response'], (err)=>{ - // If we received 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); + let sandboxInstanceDetails = await sails.helpers.fleetSandboxCloudProvisioner.provisionNewFleetSandboxInstance.with({ + firstName: firstName, + lastName: lastName, + emailAddress: newEmailAddress, }) - .intercept({name: 'TimeoutError'},(err)=>{ - // If the request timed out, log a warning and return a 'requestToSandboxTimedOut' response. - sails.log.warn('When attempting to provision a new user who just signed up ('+emailAddress+'), the request to the cloud provisioner took over timed out. The incomplete user record has not been saved in the database, and the user will be asked to try signing up again. Raw error: '+err.stack); - return 'requestToSandboxTimedOut'; + .intercept('requestToProvisionerTimedOut', (err)=>{ // If the request to the Fleet Sandbox provisioner fails, we'll log a warning an return a requestToSandboxTimedOut response. This will tell the frontend to display a message asking the user to retry signing up. + sails.log.warn(`When attempting to provision a new Fleet Sandbox instance for a new user signing up (email: ${newEmailAddress}). The Fleet Sandbox provisioner returned 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. Full error: ${err}`); + return 'requestToProvisionerTimedOut'; + }) + .intercept((err)=>{ // For any other errors, we'll throw a 500 error. + return new Error(`When attempting to provision a new Fleet Sandbox instance for a new user signing up (email: ${newEmailAddress}), an error occured. The incomplete user record has not been saved in the database, and the user will be asked to try signing up again. Full error: ${err}`); }); - 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 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') - .tolerate({name: 'TimeoutError'}); - 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.) newUserRecord = await User.create(_.extend({ @@ -217,9 +173,9 @@ the account verification message.)`, emailAddress: newEmailAddress, signupReason, password: await sails.helpers.passwords.hashPassword(password), - fleetSandboxURL: cloudProvisionerResponseData.URL, - fleetSandboxExpiresAt, - fleetSandboxDemoKey, + fleetSandboxURL: sandboxInstanceDetails.fleetSandboxURL, + fleetSandboxExpiresAt: sandboxInstanceDetails.fleetSandboxExpiresAt, + fleetSandboxDemoKey: sandboxInstanceDetails.fleetSandboxDemoKey, stripeCustomerId, inSandboxWaitlist: false, tosAcceptedByIp: this.req.ip diff --git a/website/api/helpers/fleet-sandbox-cloud-provisioner/provision-new-fleet-sandbox-instance.js b/website/api/helpers/fleet-sandbox-cloud-provisioner/provision-new-fleet-sandbox-instance.js new file mode 100644 index 000000000000..c672a3e50f0e --- /dev/null +++ b/website/api/helpers/fleet-sandbox-cloud-provisioner/provision-new-fleet-sandbox-instance.js @@ -0,0 +1,117 @@ +module.exports = { + + + friendlyName: 'Provision new Fleet Sandbox instance', + + + description: 'Provisions a new Fleet Sandbox instance and returns the details of the Sandbox instance.', + + + inputs: { + + firstName: { + type: 'string', + required: true, + description: 'The first name of the user who is having a Fleet Sandbox instance provisioned for them.', + extendedDescription: 'This will be used in the Fleet instance' + }, + lastName: { + type: 'string', + required: true, + description: 'The last name of the user who is having a Fleet Sandbox instance provisioned for them.', + extendedDescription: 'This will be used in the Fleet instance' + }, + emailAddress: { + type: 'string', + required: true, + description: 'The email address of the User record that is having a Fleet sandbox instance provisioned for them.', + extendedDescription: 'This will be used in the Fleet instance' + }, + + }, + + + exits: { + + success: { + description: 'All done.', + outputFriendlyName: 'Sandbox instance details', + outputType: { + fleetSandboxDemoKey: 'string', + fleetSandboxExpiresAt: 'number', + fleetSandboxURL: 'string', + }, + }, + + requestToProvisionerTimedOut: { + description: 'The request to the Fleet Sandbox provisioner exceeded the set timeout.', + }, + }, + + + fn: async function ({firstName, lastName, emailAddress}) { + + const FIVE_DAYS_IN_MS = (5*24*60*60*1000); + // 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() + FIVE_DAYS_IN_MS; + + // 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.with({ + url: 'https://sandbox.fleetdm.com/new', + data: { + 'name': firstName + ' ' + lastName, + 'email': emailAddress, + '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. + }, + headers: { + 'Authorization':sails.config.custom.cloudProvisionerSecret + } + }) + .timeout(10000) + .intercept(['requestFailed', 'non200Response'], (err)=>{ + // If we received a non-200 response from the cloud provisioner API, we'll throw a 500 error. + return new Error('When attempting to provision a Sandbox instance for a user on the Fleet Sandbox waitlist ('+emailAddress+'), the cloud provisioner gave a non 200 response. Raw response received from provisioner: '+err.stack); + }) + .intercept({name: 'TimeoutError'},()=>{ + // If the request timed out, log a warning and return a 'requestToSandboxTimedOut' response. + return 'requestToProvisionerTimedOut'; + }); + + if(!cloudProvisionerResponseData.URL) { + // If we didn't receive a URL in the response from the cloud provisioner API, we'll throw an error before we save the new user record and the user will need to try to sign up again. + throw new Error( + `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. + Here is the malformed response data (parsed response body) from the cloud provisioner API: ${cloudProvisionerResponseData}` + ); + } + + // 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') + .tolerate({name: 'TimeoutError'}); + 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.'); + }); + + return { + fleetSandboxDemoKey, + fleetSandboxExpiresAt, + fleetSandboxURL: cloudProvisionerResponseData.URL, + }; + + } + + +}; + diff --git a/website/assets/js/cloud.setup.js b/website/assets/js/cloud.setup.js index 9c2fcbc62a21..2bfe2296393d 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":[]},"downloadRssFeed":{"verb":"GET","url":"/rss/:categoryName","args":["categoryName"]},"receiveUsageAnalytics":{"verb":"POST","url":"/api/v1/webhooks/receive-usage-analytics","args":["anonymousIdentifier","fleetVersion","licenseTier","numHostsEnrolled","numUsers","numTeams","numPolicies","numLabels","softwareInventoryEnabled","vulnDetectionEnabled","systemUsersEnabled","hostStatusWebhookEnabled","numWeeklyActiveUsers","numWeeklyPolicyViolationDaysActual","numWeeklyPolicyViolationDaysPossible","hostsEnrolledByOperatingSystem","hostsEnrolledByOrbitVersion","hostsEnrolledByOsqueryVersion","storedErrors","numHostsNotResponding","organization"]},"receiveFromGithub":{"verb":"GET","url":"/api/v1/webhooks/github","args":["botSignature","action","sender","repository","changes","issue","comment","pull_request","label","release"]},"receiveFromStripe":{"verb":"POST","url":"/api/v1/webhooks/receive-from-stripe","args":["id","type","data","webhookSecret"]},"receiveFromCustomerFleetInstance":{"verb":"POST","url":"/api/v1/webhooks/receive-from-customer-fleet-instance","args":["timestamp","host","webhookSecret"]},"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","organization","firstName","lastName","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"]},"createOrUpdateOneNewsletterSubscription":{"verb":"POST","url":"/api/v1/create-or-update-one-newsletter-subscription","args":["emailAddress","subscribeTo"]},"unsubscribeFromAllNewsletters":{"verb":"GET","url":"/api/v1/unsubscribe-from-all-newsletters","args":["emailAddress"]},"buildLicenseKey":{"verb":"POST","url":"/api/v1/admin/build-license-key","args":["numberOfHosts","organization","expiresAt","partnerName"]},"createVantaAuthorizationRequest":{"verb":"POST","url":"/api/v1/create-vanta-authorization-request","args":["emailAddress","fleetInstanceUrl","fleetApiKey"]},"deliverMdmBetaSignup":{"verb":"POST","url":"/api/v1/deliver-mdm-beta-signup","args":["emailAddress","fullName","jobTitle","numberOfHosts"]},"deliverAppleCsr":{"verb":"POST","url":"/api/v1/deliver-apple-csr","args":["unsignedCsrData"]},"deliverPremiumUpgradeForm":{"verb":"POST","url":"/api/v1/deliver-premium-upgrade-form","args":["organization","monthsUsingFleetFree","emailAddress","numberOfHosts"]},"deliverLaunchPartySignup":{"verb":"POST","url":"/api/v1/deliver-launch-party-signup","args":["emailAddress","firstName","lastName","jobTitle","phoneNumber"]},"deliverMdmDemoEmail":{"verb":"POST","url":"/api/v1/deliver-mdm-demo-email","args":["emailAddress"]}} + methods: {"downloadSitemap":{"verb":"GET","url":"/sitemap.xml","args":[]},"downloadRssFeed":{"verb":"GET","url":"/rss/:categoryName","args":["categoryName"]},"receiveUsageAnalytics":{"verb":"POST","url":"/api/v1/webhooks/receive-usage-analytics","args":["anonymousIdentifier","fleetVersion","licenseTier","numHostsEnrolled","numUsers","numTeams","numPolicies","numLabels","softwareInventoryEnabled","vulnDetectionEnabled","systemUsersEnabled","hostStatusWebhookEnabled","numWeeklyActiveUsers","numWeeklyPolicyViolationDaysActual","numWeeklyPolicyViolationDaysPossible","hostsEnrolledByOperatingSystem","hostsEnrolledByOrbitVersion","hostsEnrolledByOsqueryVersion","storedErrors","numHostsNotResponding","organization"]},"receiveFromGithub":{"verb":"GET","url":"/api/v1/webhooks/github","args":["botSignature","action","sender","repository","changes","issue","comment","pull_request","label","release"]},"receiveFromStripe":{"verb":"POST","url":"/api/v1/webhooks/receive-from-stripe","args":["id","type","data","webhookSecret"]},"receiveFromCustomerFleetInstance":{"verb":"POST","url":"/api/v1/webhooks/receive-from-customer-fleet-instance","args":["timestamp","host","webhookSecret"]},"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","organization","firstName","lastName","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"]},"createOrUpdateOneNewsletterSubscription":{"verb":"POST","url":"/api/v1/create-or-update-one-newsletter-subscription","args":["emailAddress","subscribeTo"]},"unsubscribeFromAllNewsletters":{"verb":"GET","url":"/api/v1/unsubscribe-from-all-newsletters","args":["emailAddress"]},"buildLicenseKey":{"verb":"POST","url":"/api/v1/admin/build-license-key","args":["numberOfHosts","organization","expiresAt","partnerName"]},"createVantaAuthorizationRequest":{"verb":"POST","url":"/api/v1/create-vanta-authorization-request","args":["emailAddress","fleetInstanceUrl","fleetApiKey"]},"deliverMdmBetaSignup":{"verb":"POST","url":"/api/v1/deliver-mdm-beta-signup","args":["emailAddress","fullName","jobTitle","numberOfHosts"]},"deliverAppleCsr":{"verb":"POST","url":"/api/v1/deliver-apple-csr","args":["unsignedCsrData"]},"deliverPremiumUpgradeForm":{"verb":"POST","url":"/api/v1/deliver-premium-upgrade-form","args":["organization","monthsUsingFleetFree","emailAddress","numberOfHosts"]},"deliverLaunchPartySignup":{"verb":"POST","url":"/api/v1/deliver-launch-party-signup","args":["emailAddress","firstName","lastName","jobTitle","phoneNumber"]},"deliverMdmDemoEmail":{"verb":"POST","url":"/api/v1/deliver-mdm-demo-email","args":["emailAddress"]},"provisionSandboxInstanceAndDeliverEmail":{"verb":"POST","url":"/api/v1/admin/provision-sandbox-instance-and-deliver-email","args":["userId"]}} /* eslint-enable */ }); diff --git a/website/assets/js/pages/admin/sandbox-waitlist.page.js b/website/assets/js/pages/admin/sandbox-waitlist.page.js new file mode 100644 index 000000000000..8a5824cb5953 --- /dev/null +++ b/website/assets/js/pages/admin/sandbox-waitlist.page.js @@ -0,0 +1,32 @@ +parasails.registerPage('sandbox-waitlist', { + // ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗ + // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ + // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ + data: { + syncing: false, + }, + + // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ + // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ + // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ + beforeMount: function() { + //… + }, + mounted: async function() { + //… + }, + + // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ + // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ + methods: { + clickApproveWaitlistUser: async function(userId) { + this.syncing = true; + await Cloud.provisionSandboxInstanceAndDeliverEmail.with({userId}); + this.usersWaitingForSandboxInstance = this.usersWaitingForSandboxInstance.filter((user)=>{ + return user.id !== userId; + }); + this.syncing = false; + } + } +}); diff --git a/website/assets/styles/importer.less b/website/assets/styles/importer.less index 2a1de3126ba6..7349e9468e46 100644 --- a/website/assets/styles/importer.less +++ b/website/assets/styles/importer.less @@ -76,6 +76,7 @@ @import 'pages/vulnerability-management.less'; @import 'pages/support.less'; @import 'pages/try-fleet/waitlist.less'; +@import 'pages/admin/sandbox-waitlist.less'; // Imagine = landing pages for Marketing diff --git a/website/assets/styles/layout.less b/website/assets/styles/layout.less index af2a7cfb241a..ac24c8f19459 100644 --- a/website/assets/styles/layout.less +++ b/website/assets/styles/layout.less @@ -280,6 +280,22 @@ html, body { } } } +[purpose='admin-nav'] { + justify-content: center; + a { + margin-left: 30px; + margin-right: 30px; + color: @core-fleet-black-75; + &:hover { + color: @core-fleet-black; + } + } + span { + color: @core-fleet-black; + font-weight: 600; + margin-right: 30px; + } +} // Footer styles [purpose='page-footer'] { diff --git a/website/assets/styles/pages/admin/sandbox-waitlist.less b/website/assets/styles/pages/admin/sandbox-waitlist.less new file mode 100644 index 000000000000..e1efe749c2f2 --- /dev/null +++ b/website/assets/styles/pages/admin/sandbox-waitlist.less @@ -0,0 +1,64 @@ +#sandbox-waitlist { + padding-top: 80px; + padding-bottom: 80px; + [purpose='approve-button'] { + display: flex; + padding: 2px 8px; + justify-content: center; + align-items: center; + font-size: 14px; + font-style: normal; + font-weight: 700; + line-height: 21px; + } + [purpose='table-container'] { + padding-top: 40px; + width: 100%; + [purpose='waitlist-table'] { + border-radius: 6px; + border: none; + td { + border: none; + padding: 12px 24px; + vertical-align: middle; + } + thead { + font-weight: 600; + background-color: @ui-off-white; + border-radius: 6px; + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; + outline: 1px solid #E2E4EA; + box-shadow: 0 0 0 1px #E2E4EA; + tr { + td:first-child { + border-top-left-radius: 6px; + } + td:last-child { + border-top-right-radius: 6px; + } + } + } + tbody { + color: #515774; + border-radius: 6px; + border-top-right-radius: 0px; + border-top-left-radius: 0px; + outline: 1px solid #E2E4EA; + box-shadow: 0 0 0 1px #E2E4EA; + tr { + border-top: 1px solid @core-vibrant-blue-15; + } + tr:last-child { + td:first-child { + border-bottom-left-radius: 6px; + } + td:last-child { + border-bottom-right-radius: 6px; + } + } + } + } + } + +} diff --git a/website/config/routes.js b/website/config/routes.js index 8d1d363f23d0..f4395536fa47 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -222,6 +222,13 @@ module.exports.routes = { }, }, + 'GET /admin/sandbox-waitlist': { + action: 'admin/view-sandbox-waitlist', + locals: { + layout: 'layouts/layout-customer' + }, + }, + 'GET /tables/:tableName': { action: 'view-osquery-table-details', locals: { @@ -547,4 +554,5 @@ module.exports.routes = { 'POST /api/v1/deliver-premium-upgrade-form': { action: 'deliver-premium-upgrade-form' }, 'POST /api/v1/deliver-launch-party-signup': { action: 'imagine/deliver-launch-party-signup' }, 'POST /api/v1/deliver-mdm-demo-email': { action: 'deliver-mdm-demo-email' }, + 'POST /api/v1/admin/provision-sandbox-instance-and-deliver-email': { action: 'admin/provision-sandbox-instance-and-deliver-email' }, }; diff --git a/website/scripts/provision-sandbox-instance-for-one-user-and-deliver-email.js b/website/scripts/provision-sandbox-instance-for-one-user-and-deliver-email.js new file mode 100644 index 000000000000..276ea6536f72 --- /dev/null +++ b/website/scripts/provision-sandbox-instance-for-one-user-and-deliver-email.js @@ -0,0 +1,62 @@ +module.exports = { + + + friendlyName: 'Provision Sandbox instance for one user and deliver email.', + + + description: 'Provisions a new Fleet Sandbox instance for a user on the Fleet Sandbox waitlist, and sends an email to the user.', + + extendedDescription: 'This script will provision a Sandbox instance for the user who has been on the waitlist the longest.', + + + fn: async function () { + + + let earliestCreatedUserCurrentlyOnWaitlist = await User.find({inSandboxWaitlist: true}) + .limit(1) + .sort('createdAt ASC'); + + // If there are no users on the Fleet sandbox waitlist, end the script. + if(earliestCreatedUserCurrentlyOnWaitlist.length === 0){ + sails.log('There are no users currently waiting on the Fleet Sandbox Waitlist.'); + return; + } + + let userToRemoveFromSandboxWaitlist = earliestCreatedUserCurrentlyOnWaitlist[0]; + + let sandboxInstanceDetails = await sails.helpers.fleetSandboxCloudProvisioner.provisionNewFleetSandboxInstance.with({ + firstName: userToRemoveFromSandboxWaitlist.firstName, + lastName: userToRemoveFromSandboxWaitlist.lastName, + emailAddress: userToRemoveFromSandboxWaitlist.emailAddress, + }) + .intercept((err)=>{ + return new Error(`When attempting to provision a new Fleet Sandbox instance for a User (id:${userToRemoveFromSandboxWaitlist.id}), an error occured. Full error: ${err}`); + }); + + + await User.updateOne({id: userToRemoveFromSandboxWaitlist.id}) + .set({ + fleetSandboxURL: sandboxInstanceDetails.fleetSandboxURL, + fleetSandboxExpiresAt: sandboxInstanceDetails.fleetSandboxExpiresAt, + fleetSandboxDemoKey: sandboxInstanceDetails.fleetSandboxDemoKey, + inSandboxWaitlist: false, + }); + + + // Send the user an email to let them know that their Fleet sandbox instance is ready. + await sails.helpers.sendTemplateEmail.with({ + to: userToRemoveFromSandboxWaitlist.emailAddress, + from: sails.config.custom.fromEmailAddress, + fromName: sails.config.custom.fromName, + subject: 'Your Fleet Sandbox instance is ready!', + template: 'email-sandbox-ready-approved', + templateData: {}, + }); + + sails.log(`Successfully removed a user (id: ${userToRemoveFromSandboxWaitlist.id}) from the Fleet Sandbox waitlist.`); + + } + + +}; + diff --git a/website/views/emails/email-sandbox-ready-approved.ejs b/website/views/emails/email-sandbox-ready-approved.ejs new file mode 100644 index 000000000000..23712ca3fa2a --- /dev/null +++ b/website/views/emails/email-sandbox-ready-approved.ejs @@ -0,0 +1,9 @@ +<% /* Note: This is injected into `views/layouts/layout-email.ejs` */ %> +
+

Your Fleet Sandbox instance is ready

+

You can now access Fleet.

+ +Play in the Fleet Sandbox +

Best,
The Fleet team

+ +
diff --git a/website/views/layouts/layout-customer.ejs b/website/views/layouts/layout-customer.ejs index f3b8c2e62359..d08bd9661bfd 100644 --- a/website/views/layouts/layout-customer.ejs +++ b/website/views/layouts/layout-customer.ejs @@ -141,6 +141,18 @@ Log out + <%if(me && me.isSuperAdmin) {%> +
+
+ Admin pages +
+ +
+ <%} %> <%- body %> @@ -232,6 +244,7 @@ + diff --git a/website/views/layouts/layout-email.ejs b/website/views/layouts/layout-email.ejs index a1f734b942ff..ef8e8541c763 100644 --- a/website/views/layouts/layout-email.ejs +++ b/website/views/layouts/layout-email.ejs @@ -4,15 +4,17 @@
Logo
- <%- body %> -
-
- Fleet logo - Follow Fleet on Twitter - Join the osquery Slack community -
-
-

© 2023 Fleet Device Management Inc.
All trademarks, service marks, and company names are the property of their respective owners.

+
+ <%- body %> +
+
+ Fleet logo + Follow Fleet on Twitter + Join the osquery Slack community +
+
+

© 2023 Fleet Device Management Inc.
All trademarks, service marks, and company names are the property of their respective owners.

+
diff --git a/website/views/layouts/layout-landing.ejs b/website/views/layouts/layout-landing.ejs index 854bc1e576cf..fa3fc5540339 100644 --- a/website/views/layouts/layout-landing.ejs +++ b/website/views/layouts/layout-landing.ejs @@ -232,6 +232,7 @@ + diff --git a/website/views/layouts/layout-sandbox.ejs b/website/views/layouts/layout-sandbox.ejs index bc9d424589a5..dc0190661b0f 100644 --- a/website/views/layouts/layout-sandbox.ejs +++ b/website/views/layouts/layout-sandbox.ejs @@ -359,6 +359,7 @@ + diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index 2fe645cc75f4..17194e341efc 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -432,6 +432,7 @@ + diff --git a/website/views/pages/admin/sandbox-waitlist.ejs b/website/views/pages/admin/sandbox-waitlist.ejs new file mode 100644 index 000000000000..1c4a75cfcb6f --- /dev/null +++ b/website/views/pages/admin/sandbox-waitlist.ejs @@ -0,0 +1,27 @@ +
+
+

Sandbox waitlist

+
+ + + + + + + + + + + + + +
EmailDate signed up
{{user.emailAddress}}Approve
+
+
+

There are no users currently on the Fleet Sandbox waitlist.

+
+ +
+ +
+<%- /* Expose server-rendered data as window.SAILS_LOCALS :: */ exposeLocalsToBrowser() %> diff --git a/website/views/pages/try-fleet/register.ejs b/website/views/pages/try-fleet/register.ejs index e3c0b486f75a..29dcfffeb7c9 100644 --- a/website/views/pages/try-fleet/register.ejs +++ b/website/views/pages/try-fleet/register.ejs @@ -46,7 +46,7 @@ Sign in with existing account A blue arrow pointing right Try again - +

Fleet Sandbox is experiencing unusually high activity. Please refresh the page in 13 seconds and try signing up again.