diff --git a/website/api/controllers/admin/generate-license-key.js b/website/api/controllers/admin/generate-license-key.js new file mode 100644 index 000000000000..e199d5c5f8ef --- /dev/null +++ b/website/api/controllers/admin/generate-license-key.js @@ -0,0 +1,49 @@ +module.exports = { + + + friendlyName: 'Generate license key',// FUTURE: Rename this to avoid confusion w/ generators. For example: 'Build license key' + + + description: 'Generate and return a Fleet Premium license key.', + + + inputs: { + numberOfHosts: { + type: 'number', + required: true, + }, + + organization: { + type: 'string', + required: true, + }, + + expiresAt: { + type: 'number', + required: true, + description: 'A JS timestamp representing when this license will expire.' + } + }, + + + exits: { + success: { + outputFriendlyName: 'License key', + outputType: 'string', + }, + }, + + + fn: async function ({numberOfHosts, organization, expiresAt}) { + + let licenseKey = await sails.helpers.createLicenseKey.with({ + numberOfHosts: numberOfHosts, + organization: organization, + expiresAt: expiresAt + }); + + return licenseKey; + } + + +}; diff --git a/website/api/controllers/admin/view-generate-license.js b/website/api/controllers/admin/view-generate-license.js new file mode 100644 index 000000000000..187868156145 --- /dev/null +++ b/website/api/controllers/admin/view-generate-license.js @@ -0,0 +1,35 @@ +module.exports = { + + + friendlyName: 'View generate license', + + + description: 'Display "Generate license" page, an admin tool for generating Fleet Premium licenses', + + + exits: { + + success: { + viewTemplatePath: 'pages/admin/generate-license' + }, + + }, + + + fn: async function () { + + // Throw an error if the licenseKeyGeneratorPrivateKey or licenseKeyGeneratorPassphrase are missing. + if(!sails.config.custom.licenseKeyGeneratorPrivateKey) { + throw new Error('Missing config variable: The license key generator private key missing (sails.config.custom.licenseKeyGeneratorPrivateKey)! To use this tool, a license key generator private key is required.'); + } + + if(!sails.config.custom.licenseKeyGeneratorPassphrase) { + throw new Error('Missing config variable: The license key generator passphrase missing(sails.config.custom.licenseKeyGeneratorPassphrase)! To use this tool, a license key generator passphrase is required.'); + } + + // Respond with view. + return {}; + } + + +}; diff --git a/website/api/controllers/customers/save-billing-info-and-subscribe.js b/website/api/controllers/customers/save-billing-info-and-subscribe.js index 596113e4b8be..b22fbf0395c9 100644 --- a/website/api/controllers/customers/save-billing-info-and-subscribe.js +++ b/website/api/controllers/customers/save-billing-info-and-subscribe.js @@ -117,7 +117,7 @@ module.exports = { let licenseKey = await sails.helpers.createLicenseKey.with({ numberOfHosts: quoteRecord.numberOfHosts, organization: inputs.organization ? inputs.organization : this.req.me.organization, - validTo: subscription.current_period_end + expiresAt: subscription.current_period_end }); // Create the subscription record for this order. diff --git a/website/api/controllers/customers/view-new-license.js b/website/api/controllers/customers/view-new-license.js index 2a42d0193ecb..7825b48113aa 100644 --- a/website/api/controllers/customers/view-new-license.js +++ b/website/api/controllers/customers/view-new-license.js @@ -27,7 +27,10 @@ module.exports = { if (!this.req.me) { throw {redirect: '/customers/register'}; } - + // If the user is a super admin, we'll redirect them to the generate-license page. + if(this.req.me.isSuperAdmin) { + throw {redirect: '/admin/generate-license'}; + } // If the user has a license key, we'll redirect them to the customer dashboard. let userHasExistingSubscription = await Subscription.findOne({user: this.req.me.id}); if (userHasExistingSubscription) { diff --git a/website/api/controllers/entrance/view-login.js b/website/api/controllers/entrance/view-login.js index e3646d6e6767..0bdc59dfac6c 100644 --- a/website/api/controllers/entrance/view-login.js +++ b/website/api/controllers/entrance/view-login.js @@ -24,7 +24,9 @@ module.exports = { fn: async function () { if (this.req.me) { - if(this.req.me.hasBillingCard){ + if(this.req.me.isSuperAdmin){ + throw {redirect: '/admin/generate-license'}; + } else if(this.req.me.hasBillingCard) { throw {redirect: '/customers/new-license'}; } else { throw {redirect: '/try-fleet/sandbox'}; diff --git a/website/api/helpers/create-license-key.js b/website/api/helpers/create-license-key.js index 5c89963a2bf3..1a0f1667dd3d 100644 --- a/website/api/helpers/create-license-key.js +++ b/website/api/helpers/create-license-key.js @@ -19,7 +19,7 @@ module.exports = { required: true, }, - validTo: { + expiresAt: { type: 'number', required: true, description: 'A JS Timestamp representing when this license will expire.' @@ -44,7 +44,7 @@ module.exports = { let token = jwt.sign( { iss: 'Fleet Device Management Inc.', - exp: inputs.validTo, + exp: inputs.expiresAt, sub: inputs.organization, devices: inputs.numberOfHosts, note: 'Created with Fleet License key dispenser', diff --git a/website/api/policies/is-super-admin.js b/website/api/policies/is-super-admin.js index 09c473aa9d88..c9dd00ccb2f9 100644 --- a/website/api/policies/is-super-admin.js +++ b/website/api/policies/is-super-admin.js @@ -14,7 +14,13 @@ module.exports = async function (req, res, proceed) { // > For more about where `req.me` comes from, check out this app's // > custom hook (`api/hooks/custom/index.js`). if (!req.me) { - return res.unauthorized(); + // Rather than use the standard res.unauthorized(), if the request did not come from a logged-in user, + // we'll redirect them to an generic version of the customer login page. + if (req.wantsJSON) { + return res.sendStatus(401); + } else { + return res.redirect('/customers/login?admin'); + } }//• // Then check that this user is a "super admin". diff --git a/website/assets/js/cloud.setup.js b/website/assets/js/cloud.setup.js index 7889b115c9e1..f793c90caf1e 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","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"]}} + 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","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"]},"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"]},"generateLicenseKey":{"verb":"POST","url":"/api/v1/admin/generate-license-key","args":[]}} /* eslint-enable */ }); diff --git a/website/assets/js/pages/admin/generate-license.page.js b/website/assets/js/pages/admin/generate-license.page.js new file mode 100644 index 000000000000..f0932c7c5b05 --- /dev/null +++ b/website/assets/js/pages/admin/generate-license.page.js @@ -0,0 +1,73 @@ +parasails.registerPage('generate-license', { + // ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗ + // ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣ + // ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝ + data: { + // Form 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: { + numberOfHosts: {required: true}, + organization: {required: true}, + expiresAt: {required: true}, + }, + // Syncing / loading state + syncing: false, + // Server error state + cloudError: '', + generatedLicenseKey: '', + showResult: false, + }, + + // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ + // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ + // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ + beforeMount: function() { + // Get a formatted date string for year from today's date. + let oneYearFromNowDateString = moment(Date.now() + (365*24*60*60*1000)).format('YYYY-MM-DD'); + // Set the starting value for the validTo input + this.formData.expiresAt = oneYearFromNowDateString; + }, + mounted: async function() { + //… + }, + + // ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗ + // ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝ + methods: { + handleSubmittingForm: async function() { + let validToDate = new Date(this.formData.expiresAt); + let validToTimestamp = validToDate.getTime(); + this.generatedLicenseKey = await Cloud.generateLicenseKey.with({ + numberOfHosts: this.formData.numberOfHosts, + organization: this.formData.organization, + expiresAt: validToTimestamp + }); + }, + + submittedQuoteForm: async function() { + this.syncing = false; + this.showResult = true; + }, + + clickCopyLicenseKey: function(){ + $('[purpose="copied-notification"]').finish(); + $('[purpose="copied-notification"]').fadeIn(100).delay(2000).fadeOut(500); + // https://caniuse.com/mdn-api_clipboard_writetext + navigator.clipboard.writeText(this.generatedLicenseKey); + }, + + clickClearFormFields: async function() { + this.generatedLicenseKey = ''; + this.showResult = false; + this.formErrors = {}; + this.formData = {}; + this.formData.validTo = moment(Date.now() + (365*24*60*60*1000)).format('YYYY-MM-DD'); + await this.forceRender(); + } + } +}); diff --git a/website/assets/js/pages/entrance/login.page.js b/website/assets/js/pages/entrance/login.page.js index a04bc8827e7c..528f72ad2c13 100644 --- a/website/assets/js/pages/entrance/login.page.js +++ b/website/assets/js/pages/entrance/login.page.js @@ -24,13 +24,16 @@ parasails.registerPage('login', { // Server error state for the form cloudError: '', + showCustomerLogin: true, }, // ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗ // ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣ // ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝ beforeMount: function() { - //… + if(window.location.search === '?admin') { + this.showCustomerLogin = false; + } }, mounted: async function() { //… diff --git a/website/assets/styles/importer.less b/website/assets/styles/importer.less index 7a21407d0ad2..85b6aee0d542 100644 --- a/website/assets/styles/importer.less +++ b/website/assets/styles/importer.less @@ -62,6 +62,7 @@ @import 'pages/articles/articles.less'; @import 'pages/reports/state-of-device-management.less'; @import 'pages/osquery-table-details.less'; +@import 'pages/admin/generate-license.less'; @import 'pages/transparency.less'; @import 'pages/press-kit.less'; diff --git a/website/assets/styles/pages/admin/generate-license.less b/website/assets/styles/pages/admin/generate-license.less new file mode 100644 index 000000000000..3e7494d7cf37 --- /dev/null +++ b/website/assets/styles/pages/admin/generate-license.less @@ -0,0 +1,79 @@ +#generate-license { + padding-top: 80px; + + h1 { + font-size: 28px; + line-height: 38px; + } + [purpose='form'] { + max-width: 480px; + input::-webkit-outer-spin-button, input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + input[type='number'] { + -moz-appearance: textfield; + } + label { + font-weight: 400; + margin-bottom: 4px; + } + .form-control { + height: 40px; + border-radius: 6px; + } + .card { + border-radius: 6px; + } + .card-body { + padding: 2em; + } + } + [purpose='cloud-error'] { + max-width: fit-content; + } + [purpose='submit-button'] { + margin-left: auto; + margin-right: auto; + border-radius: 4px; + padding-top: 10px; + padding-bottom: 10px; + display: flex; + span { + display: inline; + margin-left: auto; + margin-right: auto; + font-size: 16px; + line-height: 20px; + text-align: center; + font-weight: 400; + } + + } + + [purpose='result'] { + border: 1px solid @bg-lt-gray; + padding: 4px 6px; + border-radius: 4px; + position: relative; + code { + font: @code-font; + color: @core-fleet-black; + } + } + [purpose='copy-button'] { + cursor: pointer; + color: @core-vibrant-blue; + } + [purpose='copied-notification'] { + display: none; + font-weight: 400; + position: absolute; + top: -30px; + right: 0px; + font-size: 16px; + line-height: 20px; + white-space: nowrap; + color: @core-vibrant-blue; + } +} diff --git a/website/assets/styles/pages/entrance/login.less b/website/assets/styles/pages/entrance/login.less index 199fc372785c..44993e47e4b4 100644 --- a/website/assets/styles/pages/entrance/login.less +++ b/website/assets/styles/pages/entrance/login.less @@ -9,7 +9,15 @@ a { color: @core-vibrant-blue; } - + [purpose='customer-login-container'] { + max-width: 800px; + } + [purpose='login-container'] { + max-width: 600px; + [purpose='customer-portal-form'] { + max-width: 480px; + } + } [purpose='customer-portal-form'] { label { font-weight: 700; diff --git a/website/config/policies.js b/website/config/policies.js index e5f899a1209f..bb6e248e7ea5 100644 --- a/website/config/policies.js +++ b/website/config/policies.js @@ -11,6 +11,7 @@ module.exports.policies = { '*': 'is-logged-in', + 'admin/*': 'is-super-admin', // Bypass the `is-logged-in` policy for: 'entrance/*': true, diff --git a/website/config/routes.js b/website/config/routes.js index f8bd4e6acb56..c8008cba9ba8 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -212,6 +212,13 @@ module.exports.routes = { action: 'view-osquery-table-details', }, + 'GET /admin/generate-license': { + action: 'admin/view-generate-license', + locals: { + layout: 'layouts/layout-customer' + } + }, + // ╦ ╔═╗╔═╗╔═╗╔═╗╦ ╦ ╦═╗╔═╗╔╦╗╦╦═╗╔═╗╔═╗╔╦╗╔═╗ // ║ ║╣ ║ ╦╠═╣║ ╚╦╝ ╠╦╝║╣ ║║║╠╦╝║╣ ║ ║ ╚═╗ @@ -348,5 +355,6 @@ module.exports.routes = { 'POST /api/v1/entrance/update-password-and-login': { action: 'entrance/update-password-and-login' }, 'POST /api/v1/deliver-demo-signup': { action: 'deliver-demo-signup' }, 'POST /api/v1/create-or-update-one-newsletter-subscription': { action: 'create-or-update-one-newsletter-subscription' }, - '/api/v1/unsubscribe-from-all-newsletters': { action: 'unsubscribe-from-all-newsletters' } + '/api/v1/unsubscribe-from-all-newsletters': { action: 'unsubscribe-from-all-newsletters' }, + 'POST /api/v1/admin/generate-license-key': { action: 'admin/generate-license-key' }, }; diff --git a/website/views/layouts/layout-customer.ejs b/website/views/layouts/layout-customer.ejs index 65bdf0255b99..85172923959a 100644 --- a/website/views/layouts/layout-customer.ejs +++ b/website/views/layouts/layout-customer.ejs @@ -198,6 +198,7 @@ + diff --git a/website/views/layouts/layout-landing.ejs b/website/views/layouts/layout-landing.ejs index a1d10a44e786..a1740955d8b2 100644 --- a/website/views/layouts/layout-landing.ejs +++ b/website/views/layouts/layout-landing.ejs @@ -205,6 +205,7 @@ + diff --git a/website/views/layouts/layout-sandbox.ejs b/website/views/layouts/layout-sandbox.ejs index a30ad6fc48af..59f032e99f03 100644 --- a/website/views/layouts/layout-sandbox.ejs +++ b/website/views/layouts/layout-sandbox.ejs @@ -306,6 +306,7 @@ + diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs index d949d37e2ab9..a43433bf2a21 100644 --- a/website/views/layouts/layout.ejs +++ b/website/views/layouts/layout.ejs @@ -404,6 +404,7 @@ + diff --git a/website/views/pages/admin/generate-license.ejs b/website/views/pages/admin/generate-license.ejs new file mode 100644 index 000000000000..f26e165c6880 --- /dev/null +++ b/website/views/pages/admin/generate-license.ejs @@ -0,0 +1,59 @@ +
Generate a Fleet Premium license key.
+Generated license:
+ Copied to clipboard +Copy
+{{generatedLicenseKey}}
+ Sign in to manage your Fleet Premium subscription.
+Sign in to manage your Fleet Premium subscription.
+Sign in to your Fleet account.
+Fleet Premium includes: