Add customer portal and license dispenser to fleetdm.com (#3546)

* Add images for customer portal, dashboard, and email templates

* updated email layout and reset password template, new email template

* update ajax-button component to have an optional spinner

* updated cloud-error & stripe-card-element component styles

* updates to user model, add quote and subscription

* Login, signup, forgot password, update profile

* link to customer portal from pricing

* new-license page, bootstrap updates

* create quote action, dashboard page, update routes

* Add new page styles to importer, update component styles

* updates to js-timestamp

* update modal styles and layout

* using @submitted on ajax form, controller updates

* Update create-quote.js

* updates to quote model, action updates, truncate license key on dashboard

* update email layout, subscribe action, user model

* Update importer.less

* style updates, order confirmation

* use correct font

* style updates

* create license key

* new-license page changes

* signup page changes

* add billing format to js-timestamp component, dashboard updates, change password

* swap get started link for customers

* order -> subscription

* Update login.ejs

* Lint fixes, page updates, mobile styles

* remove edit-profile route, update layout, bootstrap, forms

* change customer-layout name to match other layout names, update copyright year in layouts

* changes requested from code review and #3570

* submit button width, contact font-size

* Update dashboard.less

* Update bootstrap-overrides.less

* slack logo update, login text
This commit is contained in:
eashaw
2022-01-04 20:02:42 -06:00
committed by GitHub
parent a269e9a5f9
commit e335e835c9
61 changed files with 2268 additions and 261 deletions
+1 -1
View File
@@ -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: '/login'};
throw {redirect: '/'};
}
}
+22 -3
View File
@@ -9,7 +9,12 @@ module.exports = {
inputs: {
password: {
oldPassword: {
description: 'The new, unencrypted password.',
example: 'abc123v2',
required: true
},
newPassword: {
description: 'The new, unencrypted password.',
example: 'abc123v2',
required: true
@@ -17,11 +22,25 @@ module.exports = {
},
exits: {
success: {
description: 'The requesting user agent has been successfully changed their password.',
},
fn: async function ({password}) {
badPassword: {
description: `The provided password does not match the user's current password.`,
responseType: 'unauthorized'
}
},
fn: async function (inputs) {
await sails.helpers.passwords.checkPassword(inputs.oldPassword, this.req.me.password)
.intercept('incorrect', 'badPassword');
// Hash the new password.
var hashed = await sails.helpers.passwords.hashPassword(password);
var hashed = await sails.helpers.passwords.hashPassword(inputs.newPassword);
// Update the record for the logged-in user.
await User.updateOne({ id: this.req.me.id })
+23 -12
View File
@@ -9,7 +9,15 @@ module.exports = {
inputs: {
fullName: {
firstName: {
type: 'string'
},
lastName: {
type: 'string'
},
organization: {
type: 'string'
},
@@ -30,7 +38,7 @@ module.exports = {
},
fn: async function ({fullName, emailAddress}) {
fn: async function ({firstName, lastName, organization, emailAddress}) {
var newEmailAddress = emailAddress;
if (newEmailAddress !== undefined) {
@@ -75,7 +83,9 @@ module.exports = {
// Start building the values to set in the db.
// (We always set the fullName if provided.)
var valuesToSet = {
fullName,
firstName,
lastName,
organization,
};
switch (desiredEmailEffect) {
@@ -143,15 +153,16 @@ module.exports = {
// If an email address change was requested, and re-confirmation is required,
// send the "confirm account" email.
if (desiredEmailEffect === 'begin-change' || desiredEmailEffect === 'modify-pending-change') {
await sails.helpers.sendTemplateEmail.with({
to: newEmailAddress,
subject: 'Your account has been updated',
template: 'email-verify-new-email',
templateData: {
fullName: fullName||this.req.me.fullName,
token: valuesToSet.emailProofToken
}
});
throw new Error('Not yet supported: the email confirmation feature is unused and has not been adapted for fleetdm.com. This error should never be displayed.');
// await sails.helpers.sendTemplateEmail.with({
// to: newEmailAddress,
// subject: 'Your account has been updated',
// template: 'email-verify-new-email',
// templateData: {
// fullName: fullName||this.req.me.fullName,
// token: valuesToSet.emailProofToken
// }
// });
}
}
+43
View File
@@ -0,0 +1,43 @@
module.exports = {
friendlyName: 'Create quote',
description: '',
inputs: {
numberOfHosts: {
type: 'number',
required: true,
},
},
exits: {
},
fn: async function ({ numberOfHosts }) {
// Determine the price, 1 dollar * host * month (Billed anually)
let price = 1.00 * numberOfHosts * 12;
let quote = await Quote.create({
numberOfHosts: numberOfHosts,
quotedPrice: price,
organization: this.req.me.organization,
user: this.req.me.id,
}).fetch();
return quote;
}
};
@@ -0,0 +1,132 @@
module.exports = {
friendlyName: 'Save billing info and subscribe',
description: '',
inputs: {
quoteId: {
type: 'number',
required: true,
description: 'The quote to use (determines the price and number of hosts.)'
},
paymentSource: {
required: true,
description: 'New payment source info to use (instead of the saved default payment source).',
extendedDescription: 'If provided, this will also be saved as the new default payment source for the customer, replacing the existing default payment source (if there is one.)',
type: {
stripeToken: 'string',
billingCardLast4: 'string',
billingCardBrand: 'string',
billingCardExpMonth: 'string',
billingCardExpYear: 'string',
},
example: {
stripeToken: 'tok_199k3qEXw14QdSnRwmsK99MH',
billingCardLast4: '4242',
billingCardBrand: 'visa',
billingCardExpMonth: '08',
billingCardExpYear: '2023',
}
},
},
exits: {
couldNotSaveBillingInfo: {
description: 'The billing information provided could not be saved.',
responseType: 'badRequest'
},
},
fn: async function (inputs) {
const stripe = require('stripe')(sails.config.custom.stripeSecret);
let quoteRecord = await Quote.findOne({id: inputs.quoteId});
if(!quoteRecord) {
throw new Error(`Consistency violation: The specified quote (${inputs.quoteId}) no longer seems to exist.`);
}
// If this user has a subscription, we'll throw an error.
let doesUserHaveAnExistingSubscription = await Subscription.findOne({user: this.req.me.id});
if(doesUserHaveAnExistingSubscription) {
throw new Error(`Consistency violation: The requesting user (${this.req.me.emailAddress}) already has an existing subscription!`);
}
// What if the stripe customer id doesn't already exist on the user?
if (!this.req.me.stripeCustomerId) {
throw new Error(`Consistency violation: The logged-in user's (${this.req.me.emailAddress}) Stripe customer id has somehow gone missing!`);
}
// Write new payment card info ("token") to Stripe's API.
await sails.helpers.stripe.saveBillingInfo.with({
stripeCustomerId: this.req.me.stripeCustomerId,
token: inputs.paymentSource.stripeToken
})
.intercept({ type: 'StripeCardError' }, 'couldNotSaveBillingInfo');
// Save payment card info to our database.
await User.updateOne({ id: this.req.me.id })
.set({
hasBillingCard: true,
billingCardBrand: inputs.paymentSource.billingCardBrand,
billingCardLast4: inputs.paymentSource.billingCardLast4,
billingCardExpMonth: inputs.paymentSource.billingCardExpMonth,
billingCardExpYear: inputs.paymentSource.billingCardExpYear,
});
// Create the subscription for this order in Stripe
const subscription = await stripe.subscriptions.create({
customer: this.req.me.stripeCustomerId,
items: [
{
price: sails.config.custom.stripeSubscriptionProduct,
quantity: quoteRecord.numberOfHosts,
},
],
});
// Generate the license key for this subscription
let licenseKey = await sails.helpers.createLicenseKey.with({
numberOfHosts: quoteRecord.numberOfHosts,
organization: this.req.me.organization,
validTo: subscription.current_period_end
});
// Create the subscription record for this order.
await Subscription.create({
organization: this.req.me.organization,
numberOfHosts: quoteRecord.numberOfHosts,
subscriptionPrice: quoteRecord.quotedPrice,
user: this.req.me.id,
stripeSubscriptionId: subscription.id,
nextBillingAt: subscription.current_period_end * 1000,
fleetLicenseKey: licenseKey,
});
// Send the order confirmation template email
await sails.helpers.sendTemplateEmail.with({
to: this.req.me.emailAddress,
from: sails.config.custom.fromEmail,
fromName: sails.config.custom.fromName,
subject: 'Your Fleet Premium order',
template: 'email-order-confirmation',
templateData: {
firstName: this.req.me.firstName,
lastName: this.req.me.lastName,
}
});
}
};
+44
View File
@@ -0,0 +1,44 @@
module.exports = {
friendlyName: 'View dashboard',
description: 'Display "Dashboard" page.',
exits: {
success: {
viewTemplatePath: 'pages/customers/dashboard'
},
redirect: {
description: 'The requesting user does not have a subscription, redirecting to the new license page.',
responseType: 'redirect',
},
},
fn: async function () {
// Get subscription Info
let thisSubscription = await Subscription.findOne({user: this.req.me.id});
// If the user does not have a subscription, then help them subscribe.
if(!thisSubscription) {
throw {redirect: '/customers/new-license'};
}
// Respond with view.
return {
stripePublishableKey: sails.config.custom.enableBillingFeatures? sails.config.custom.stripePublishableKey : undefined,
thisSubscription,
};
}
};
+43
View File
@@ -0,0 +1,43 @@
module.exports = {
friendlyName: 'View new license',
description: 'Display "New license" page.',
exits: {
success: {
viewTemplatePath: 'pages/customers/new-license'
},
redirect: {
description: 'The requesting user already has a subscription, or does not exist',
responseType: 'redirect',
}
},
fn: async function () {
// if the user isn't logged in, we'll redirect them to the register page.
if (!this.req.me) {
throw {redirect: '/customers/register'};
}
// 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) {
throw {redirect: '/customers/dashboard'};
}
// Respond with view.
return { stripePublishableKey: sails.config.custom.stripePublishableKey};
}
};
@@ -55,7 +55,7 @@ module.exports = {
subject: 'Password reset instructions',
template: 'email-reset-password',
templateData: {
fullName: userRecord.fullName,
firstName: userRecord.firstName,
token: token
}
});
+27 -7
View File
@@ -35,11 +35,26 @@ the account verification message.)`,
description: 'The unencrypted password to use for the new account.'
},
fullName: {
organization: {
required: true,
type: 'string',
example: 'Frida Kahlo de Rivera',
description: 'The user\'s full name.',
maxLength: 120,
example: 'The Sails company',
description: 'The organization the user works for'
},
firstName: {
required: true,
type: 'string',
example: 'Frida',
description: 'The user\'s first name.',
},
lastName: {
required: true,
type: 'string',
example: 'Rivera',
description: 'The user\'s last name.',
}
},
@@ -53,7 +68,7 @@ the account verification message.)`,
invalid: {
responseType: 'badRequest',
description: 'The provided fullName, password and/or email address are invalid.',
description: 'The provided firstName, lastName, organization, password and/or email address are invalid.',
extendedDescription: 'If this request was sent from a graphical user interface, the request '+
'parameters should have been validated/coerced _before_ they were sent.'
},
@@ -66,14 +81,16 @@ the account verification message.)`,
},
fn: async function ({emailAddress, password, fullName}) {
fn: async function ({emailAddress, password, firstName, lastName, organization}) {
var newEmailAddress = emailAddress.toLowerCase();
// 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({
fullName,
firstName,
lastName,
organization,
emailAddress: newEmailAddress,
password: await sails.helpers.passwords.hashPassword(password),
tosAcceptedByIp: this.req.ip
@@ -105,16 +122,19 @@ the account verification message.)`,
// Send "confirm account" email
await sails.helpers.sendTemplateEmail.with({
to: newEmailAddress,
from: sails.config.custom.fromEmailAddress,
fromName: sails.config.custom.fromName,
subject: 'Please confirm your account',
template: 'email-verify-account',
templateData: {
fullName,
firstName,
token: newUserRecord.emailProofToken
}
});
} else {
sails.log.info('Skipping new account email verification... (since `verifyEmailAddresses` is disabled)');
}
return;
}
+1 -1
View File
@@ -24,7 +24,7 @@ module.exports = {
fn: async function () {
if (this.req.me) {
throw {redirect: '/'};
throw {redirect: '/customers/new-license'};
}
return {};
+1 -1
View File
@@ -24,7 +24,7 @@ module.exports = {
fn: async function () {
if (this.req.me) {
throw {redirect: '/'};
throw {redirect: '/customers/new-license'};
}
return {};
-4
View File
@@ -25,10 +25,6 @@ module.exports = {
fn: async function () {
if (this.req.me) {
throw {redirect:'/welcome'};
}
return {};
}
+67
View File
@@ -0,0 +1,67 @@
module.exports = {
friendlyName: 'Create license key',
description: '',
inputs: {
numberOfHosts: {
type: 'string',
required: true,
},
organization: {
type: 'string',
required: true,
},
validTo: {
type: 'number',
required: true,
description: 'A JS Timestamp representing when this license will expire.'
}
},
exits: {
success: {
outputType: 'string',
},
},
fn: async function (inputs) {
let jwt = require('jsonwebtoken');
let token = jwt.sign(
{
iss: 'Fleet Device Management Inc.',
exp: inputs.validTo,
sub: inputs.organization,
devices: inputs.numberOfHosts,
note: 'Created with Fleet License key dispenser',
tier: 'premium',
},
{
key: sails.config.custom.licenseKeyGeneratorPrivateKey,
passphrase: sails.config.custom.licenseKeyGeneratorPassphrase
},
{ algorithm: 'ES256' }
);
return token;
}
};
+49
View File
@@ -0,0 +1,49 @@
/**
* Quote.js
*
* @description :: A model definition represents a database table/collection.
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
organization: { // Note: the current organization exists on the user model, this reflects the organization at the time the quote was created.
type: 'string',
description: 'The organization the user entered when they generated a quote',
maxLength: 120,
},
numberOfHosts: {
type: 'number',
description: 'The number of hosts the user wants a license for',
required: true,
},
quotedPrice: {
type: 'number',
description: 'The price of the Fleet Premium license that was quoted to the user',
required: true,
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
user: {
model: 'User',
required: true,
description: 'The user who created this quote.'
},
},
};
+63
View File
@@ -0,0 +1,63 @@
/**
* Subscription.js
*
* @description :: A model definition represents a database table/collection.
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
nextBillingAt: {
type: 'number',
description: 'A JS Timestamp representing the next billing date for this subscription',
required: true,
},
numberOfHosts: {
type: 'number',
description: 'The number of hosts this subscription is valid for',
required: true,
},
subscriptionPrice: {
type: 'number',
description: 'The price of this Fleet Premium subscription',
required: true,
},
stripeSubscriptionId: {
type: 'string',
description: 'The stripe id for this subscription',
required: true,
},
fleetLicenseKey: {
type: 'string',
example:'1234 1234 1234 1234 1234',
description: 'The user\'s Fleet Premium license key'
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
user: {
model: 'User',
description: 'The user who started this subscription.',
required: true
}
},
};
+34 -18
View File
@@ -27,11 +27,11 @@ module.exports = {
defaultsTo: 'confirmed',
description: 'The confirmation status of the user\'s email address.',
extendedDescription:
`Users might be created as "unconfirmed" (e.g. normal signup) or as "confirmed" (e.g. hard-coded
admin users). When the email verification feature is enabled, new users created via the
signup form have \`emailStatus: 'unconfirmed'\` until they click the link in the confirmation email.
Similarly, when an existing user changes their email address, they switch to the "change-requested"
email status until they click the link in the confirmation email.`
`Users might be created as "unconfirmed" (e.g. normal signup) or as "confirmed" (e.g. hard-coded
admin users). When the email verification feature is enabled, new users created via the
signup form have \`emailStatus: 'unconfirmed'\` until they click the link in the confirmation email.
Similarly, when an existing user changes their email address, they switch to the "change-requested"
email status until they click the link in the confirmation email.`
},
emailChangeCandidate: {
@@ -48,30 +48,46 @@ email status until they click the link in the confirmation email.`
example: '2$28a8eabna301089103-13948134nad'
},
fullName: {
firstName: {
type: 'string',
required: true,
description: 'Full representation of the user\'s name.',
description: 'The user\'s first name.',
maxLength: 120,
example: 'Mary Sue van der McHenst'
example: 'Mary'
},
lastName: {
type: 'string',
required: true,
description: 'The user\'s last name.',
maxLength: 120,
example: 'van der McHenst'
},
organization: {
type: 'string',
required: true,
description: 'The organization the user works for.',
maxLength: 120,
example: 'The Sails Company',
},
isSuperAdmin: {
type: 'boolean',
description: 'Whether this user is a "super admin" with extra permissions, etc.',
extendedDescription:
`Super admins might have extra permissions, see a different default home page when they log in,
or even have a completely different feature set from normal users. In this app, the \`isSuperAdmin\`
flag is just here as a simple way to represent two different kinds of users. Usually, it's a good idea
to keep the data model as simple as possible, only adding attributes when you actually need them for
features being built right now.
`Super admins might have extra permissions, see a different default home page when they log in,
or even have a completely different feature set from normal users. In this app, the \`isSuperAdmin\`
flag is just here as a simple way to represent two different kinds of users. Usually, it's a good idea
to keep the data model as simple as possible, only adding attributes when you actually need them for
features being built right now.
For example, a "super admin" user for a small to medium-sized e-commerce website might be able to
change prices, deactivate seasonal categories, add new offerings, and view live orders as they come in.
On the other hand, for an e-commerce website like Walmart.com that has undergone years of development
by a large team, those administrative features might be split across a few different roles.
For example, a "super admin" user for a small to medium-sized e-commerce website might be able to
change prices, deactivate seasonal categories, add new offerings, and view live orders as they come in.
On the other hand, for an e-commerce website like Walmart.com that has undergone years of development
by a large team, those administrative features might be split across a few different roles.
So, while this \`isSuperAdmin\` demarcation might not be the right approach forever, it's a good place to start.`
So, while this \`isSuperAdmin\` demarcation might not be the right approach forever, it's a good place to start.`
},
passwordResetToken: {
+1 -1
View File
@@ -37,7 +37,7 @@ module.exports = function unauthorized() {
delete req.session.userId;
}
return res.redirect('/login');
return res.redirect('/customers/login');
}
};