Move fleetdm.com into main Fleet repo (#83)

* rename dir

* no need to install website or docs from npm

At some point, would also be nice to be able to exclude assets/ as well, and to only install a pre-built version of Fleet's frontend code

* Bring in fleetdm.com website

From https://github.com/fleetdm/fleetdm.com as of https://github.com/fleetdm/fleetdm.com/releases/tag/v0.0.21

* add procfile for heroku

Using https://github.com/timanovsky/subdir-heroku-buildpack

* avoid getting anybody's hopes up

* Create deploy-fleet-website.yml (#82)

* Create deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* update pjs with SPDX-like license expressions.  also fix repo URL

and remove package lock

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* remove dummy uri

* Dissect deploy script

* Update deploy-fleet-website.yml

* workaround for eslintrc nesting issue

* lint fixes

* forgot the .js

* add per-commit git config

* Update deploy-fleet-website.yml

* might as well remove that

* cleanup

* connect w/ heroku app and have it actually push

* fix bug I introduced in 578a1a01ffb8404aae869e05005e30a6ba2b2a95

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* the beauty, the glory, of javascript

* GH actions don't like "\n"

* Update deploy-fleet-website.yml

* restore \n chars from 0d45e568f693efba7d7072085bc98d72a482d9ae

hoping I was wrong in 0d45e568f693efba7d7072085bc98d72a482d9ae but see also https://github.community/t/what-is-the-correct-character-escaping-for-workflow-command-values-e-g-echo-xxxx/118465/5

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* Update deploy-fleet-website.yml

* rename script to prevent duplicate building

* Configure the real website

* clean up

* a test of the deploy workflow

* add handbook to npmignore

* I guess you could call this fixing a typo

* point workflow at master branch

* now clearly bogus: this completely unused version string
This commit is contained in:
Mike McNeil 2020-12-02 14:48:03 -06:00 committed by GitHub
parent 47b4f07afb
commit 27eae209fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
213 changed files with 65658 additions and 10 deletions

View File

@ -0,0 +1,71 @@
name: Deploy Fleet website
on:
push:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x]
steps:
- uses: actions/checkout@v2
# Configure our access credentials for the Heroku CLI
- uses: akhileshns/heroku-deploy@v3.6.8
with:
heroku_api_key: ${{secrets.HEROKU_API_TOKEN_FOR_BOT_USER}}
heroku_app_name: "" # this has to be blank or it doesn't work
heroku_email: ${{secrets.HEROKU_EMAIL_FOR_BOT_USER}}
justlogin: true
- run: heroku auth:whoami
# Set the Node.js version
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
# Now start building!
# > …but first, get a little crazy for a sec and delete the top-level package.json file
# > i.e. the one used by the Fleet server. This is because require() in node will go
# > hunting in ancestral directories for missing dependencies, and since some of the
# > bundled transpiler tasks sniff for package availability using require(), this trips
# > up when it encounters another Node universe in the parent directory.
- run: rm -rf package.json package-lock.json node_modules/
# > Turns out there's a similar issue with how eslint plugins are looked up, so we
# > delete the top level .eslintrc file too.
- run: rm -f .eslintrc.js
# Get dependencies (including dev deps)
- run: cd website/ && npm install
# Run sanity checks
- run: cd website/ && npm test
# Compile assets
- run: cd website/ && npm run build-for-prod
# Commit newly-built assets locally so we can push them to Heroku below.
# (This commit will never be pushed to GitHub- only to Heroku.)
# > The local config flags make this work in GitHub's environment.
- run: git add website/.www
- run: git -c "user.name=Fleetwood" -c "user.email=github@example.com" commit -am 'AUTOMATED COMMIT - Deployed the latest, including modified HTML layouts and .sailsrc file that reference minified assets.'
# Configure the Heroku app we'll be deploying to
- run: heroku git:remote -a production-fleetdm-website
- run: git remote -v
# Deploy to Heroku (by pushing)
# > Since a shallow clone was grabbed, we have to "unshallow" it before forcepushing.
- run: echo "Unshallowing local repository…"
- run: git fetch --prune --unshallow
- run: echo "Deploying branch '${GITHUB_REF##*/}' to Heroku…"
- run: git push heroku +${GITHUB_REF##*/}:master
- name: 🌐 https://fleetdm.com
run: echo '' && echo '--' && echo 'OK, done. It should be live momentarily.' && echo '(if you get impatient, check the Heroku dashboard for status)' && echo && echo ' 🌐–• https://fleetdm.com'

3
.npmignore Normal file
View File

@ -0,0 +1,3 @@
website
docs
handbook

View File

@ -1,8 +1,7 @@
{
"name": "@kolide/fleet",
"version": "0.1.0",
"description": "Kolide, Black Box Security. Unboxed",
"author": "Kolide, Inc.",
"name": "@fleetdm/fleet",
"version": "0.99.99",
"description": "The premier osquery fleet manager.",
"private": true,
"sasslintConfig": ".sass-lint.yml",
"scripts": {
@ -195,5 +194,6 @@
],
"clearMocks": true,
"testURL": "http://localhost:8080"
}
},
"license": "SEE LICENSE IN ./LICENSE"
}

31
website/.editorconfig Normal file
View File

@ -0,0 +1,31 @@
################################################
# ╔═╗╔╦╗╦╔╦╗╔═╗╦═╗┌─┐┌─┐┌┐┌┌─┐┬┌─┐
# ║╣ ║║║ ║ ║ ║╠╦╝│ │ ││││├┤ ││ ┬
# o╚═╝═╩╝╩ ╩ ╚═╝╩╚═└─┘└─┘┘└┘└ ┴└─┘
#
# > Formatting conventions for your Sails app.
#
# This file (`.editorconfig`) exists to help
# maintain consistent formatting throughout the
# files in your Sails app.
#
# For the sake of convention, the Sails team's
# preferred settings are included here out of the
# box. You can also change this file to fit your
# team's preferences (for example, if all of the
# developers on your team have a strong preference
# for tabs over spaces),
#
# To review what each of these options mean, see:
# http://editorconfig.org/
#
################################################
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

3
website/.eslintignore Normal file
View File

@ -0,0 +1,3 @@
assets/dependencies/**/*.js
views/**/*.ejs

90
website/.eslintrc Normal file
View File

@ -0,0 +1,90 @@
{
// ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐
// ║╣ ╚═╗║ ║║║║ ║ ├┬┘│
// o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘
// A set of basic code conventions designed to encourage quality and consistency
// across your Sails app's code base. These rules are checked against
// automatically any time you run `npm test`.
//
// > An additional eslintrc override file is included in the `assets/` folder
// > right out of the box. This is specifically to allow for variations in acceptable
// > global variables between front-end JavaScript code designed to run in the browser
// > vs. backend code designed to run in a Node.js/Sails process.
//
// > Note: If you're using mocha, you'll want to add an extra override file to your
// > `test/` folder so that eslint will tolerate mocha-specific globals like `before`
// > and `describe`.
// Designed for ESLint v4.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// For more information about any of the rules below, check out the relevant
// reference page on eslint.org. For example, to get details on "no-sequences",
// you would visit `http://eslint.org/docs/rules/no-sequences`. If you're unsure
// or could use some advice, come by https://sailsjs.com/support.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
"env": {
"node": true
},
"parserOptions": {
"ecmaVersion": 2018
},
"globals": {
// If "no-undef" is enabled below, be sure to list all global variables that
// are used in this app's backend code (including the globalIds of models):
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
"Promise": true,
"sails": true,
"_": true,
// Models:
"User": true
// …and any others.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
},
"rules": {
"block-scoped-var": ["error"],
"callback-return": ["error", ["done", "proceed", "next", "onwards", "callback", "cb"]],
"camelcase": ["warn", {"properties":"always"}],
"comma-style": ["warn", "last"],
"curly": ["warn"],
"eqeqeq": ["error", "always"],
"eol-last": ["warn"],
"handle-callback-err": ["error"],
"indent": ["warn", 2, {
"SwitchCase": 1,
"MemberExpression": "off",
"FunctionDeclaration": {"body":1, "parameters":"off"},
"FunctionExpression": {"body":1, "parameters":"off"},
"CallExpression": {"arguments":"off"},
"ArrayExpression": 1,
"ObjectExpression": 1,
"ignoredNodes": ["ConditionalExpression"]
}],
"linebreak-style": ["error", "unix"],
"no-dupe-keys": ["error"],
"no-duplicate-case": ["error"],
"no-extra-semi": ["warn"],
"no-labels": ["error"],
"no-mixed-spaces-and-tabs": [2, "smart-tabs"],
"no-redeclare": ["warn"],
"no-return-assign": ["error", "always"],
"no-sequences": ["error"],
"no-trailing-spaces": ["warn"],
"no-undef": ["error"],
"no-unexpected-multiline": ["warn"],
"no-unreachable": ["warn"],
"no-unused-vars": ["warn", {"caughtErrors":"all", "caughtErrorsIgnorePattern": "^unused($|[A-Z].*$)", "argsIgnorePattern": "^unused($|[A-Z].*$)", "varsIgnorePattern": "^unused($|[A-Z].*$)" }],
"no-use-before-define": ["error", {"functions":false}],
"one-var": ["warn", "never"],
"prefer-arrow-callback": ["warn", {"allowNamedFunctions":true}],
"quotes": ["warn", "single", {"avoidEscape":false, "allowTemplateLiterals":true}],
"semi": ["warn", "always"],
"semi-spacing": ["warn", {"before":false, "after":true}],
"semi-style": ["warn", "last"]
}
}

134
website/.gitignore vendored Normal file
View File

@ -0,0 +1,134 @@
################################################
# ┌─┐┬┌┬┐╦╔═╗╔╗╔╔═╗╦═╗╔═╗
# │ ┬│ │ ║║ ╦║║║║ ║╠╦╝║╣
# o└─┘┴ ┴ ╩╚═╝╝╚╝╚═╝╩╚═╚═╝
#
# > Files to exclude from your app's repo.
#
# This file (`.gitignore`) is only relevant if
# you are using git.
#
# It exists to signify to git that certain files
# and/or directories should be ignored for the
# purposes of version control.
#
# This keeps tmp files and sensitive credentials
# from being uploaded to your repository. And
# it allows you to configure your app for your
# machine without accidentally committing settings
# which will smash the local settings of other
# developers on your team.
#
# Some reasonable defaults are included below,
# but, of course, you should modify/extend/prune
# to fit your needs!
#
################################################
################################################
# Local Configuration
#
# Explicitly ignore files which contain:
#
# 1. Sensitive information you'd rather not push to
# your git repository.
# e.g., your personal API keys or passwords.
#
# 2. Developer-specific configuration
# Basically, anything that would be annoying
# to have to change every time you do a
# `git pull` on your laptop.
# e.g. your local development database, or
# the S3 bucket you're using for file uploads
# during development.
#
################################################
config/local.js
################################################
# Dependencies
#
#
# When releasing a production app, you _could_
# hypothetically include your node_modules folder
# in your git repo, but during development, it
# is always best to exclude it, since different
# developers may be working on different kernels,
# where dependencies would need to be recompiled
# anyway.
#
# Most of the time, the node_modules folder can
# be excluded from your code repository, even
# in production, thanks to features like the
# package-lock.json file / NPM shrinkwrap.
#
# But no matter what, since this is a Sails app,
# you should always push up the package-lock.json
# or shrinkwrap file to your repository, to avoid
# accidentally pulling in upgraded dependencies
# and breaking your code.
#
# That said, if you are having trouble with
# dependencies, (particularly when using
# `npm link`) this can be pretty discouraging.
# But rather than just adding the lockfile to
# your .gitignore, try this first:
# ```
# rm -rf node_modules
# rm package-lock.json
# npm install
# ```
#
# [?] For more tips/advice, come by and say hi
# over at https://sailsjs.com/support
#
################################################
node_modules
################################################
#
# > Do you use bower?
# > re: the bower_components dir, see this:
# > http://addyosmani.com/blog/checking-in-front-end-dependencies/
# > (credit Addy Osmani, @addyosmani)
#
################################################
################################################
# Temporary files generated by Sails/Waterline.
################################################
.tmp
################################################
# Miscellaneous
#
# Common files generated by text editors,
# operating systems, file systems, dbs, etc.
################################################
*~
*#
.DS_STORE
.netbeans
nbproject
.idea
*.iml
.vscode
.node_history
dump.rdb
npm-debug.log
lib-cov
*.seed
*.log
*.out
*.pid

27
website/.htmlhintrc Normal file
View File

@ -0,0 +1,27 @@
{
"alt-require": true,
"attr-lowercase": ["viewBox"],
"attr-no-duplication": true,
"attr-unsafe-chars": true,
"attr-value-double-quotes": true,
"attr-value-not-empty": false,
"csslint": false,
"doctype-first": false,
"doctype-html5": true,
"head-script-disabled": false,
"href-abs-or-rel": false,
"id-class-ad-disabled": true,
"id-class-value": false,
"id-unique": true,
"inline-script-disabled": true,
"inline-style-disabled": false,
"jshint": false,
"space-tab-mixed-disabled": "space",
"spec-char-escape": false,
"src-not-empty": true,
"style-disabled": false,
"tag-pair": true,
"tag-self-close": false,
"tagname-lowercase": true,
"title-require": false
}

46
website/.lesshintrc Normal file
View File

@ -0,0 +1,46 @@
{
// ╦ ╔═╗╔═╗╔═╗╦ ╦╦╔╗╔╔╦╗┬─┐┌─┐
// ║ ║╣ ╚═╗╚═╗╠═╣║║║║ ║ ├┬┘│
// o╩═╝╚═╝╚═╝╚═╝╩ ╩╩╝╚╝ ╩ ┴└─└─┘
// Configuration designed for the lesshint linter. Describes a loose set of LESS
// conventions that help avoid typos, unexpected failed builds, and hard-to-debug
// selector and CSS rule issues.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// For more information about any of the rules below, check out the reference page
// of all rules at https://github.com/lesshint/lesshint/blob/v6.3.6/lib/linters/README.md
// If you're unsure or could use some advice, come by https://sailsjs.com/support.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
"singleLinePerSelector": false,
"singleLinePerProperty": false,
"zeroUnit": false,
"idSelector": false,
"propertyOrdering": false,
"spaceAroundBang": false,
"fileExtensions": [".less", ".css"],
"excludedFiles": ["vendor.less"],
"importPath": false,
"borderZero": false,
"hexLength": false,
"hexNotation": false,
"newlineAfterBlock": false,
"spaceBeforeBrace": {
"style": "one_space"
},
"spaceAfterPropertyName": false,
"spaceAfterPropertyColon": {
"enabled": true,
"style": "one_space"
},
"maxCharPerLine": false,
"emptyRule": false,
"importantRule": true,
"qualifyingElement": false
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// ^^ This last one is only disabled because the lesshint parser seems to have
// a hard time distinguishing between things like `div.bar` and `&.bar`.
// In this case, the ampersand has a distinct meaning, and it does not refer
// to an element. (It's referring to the case where that class is matched at
// the parent level, rather than talking about a descendant.)
// https://github.com/lesshint/lesshint/blob/v6.3.6/lib/linters/README.md#qualifyingelement
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
}

11
website/.npmrc Normal file
View File

@ -0,0 +1,11 @@
######################
# ╔╗╔╔═╗╔╦╗┬─┐┌─┐ #
# ║║║╠═╝║║║├┬┘│ #
# o╝╚╝╩ ╩ ╩┴└─└─┘ #
######################
# Hide NPM log output unless it is related to an error of some kind:
loglevel=error
# Make "npm audit" an opt-in thing for subsequent installs within this app:
audit=false

9
website/.sailsrc Normal file
View File

@ -0,0 +1,9 @@
{
"generators": {
"modules": {}
},
"_generatedWith": {
"sails": "1.2.5",
"sails-generate": "2.0.0"
}
}

23
website/Gruntfile.js Normal file
View File

@ -0,0 +1,23 @@
/**
* Gruntfile
*
* This Node script is executed when you run `grunt`-- and also when
* you run `sails lift` (provided the grunt hook is installed and
* hasn't been disabled).
*
* WARNING:
* Unless you know what you're doing, you shouldn't change this file.
* Check out the `tasks/` directory instead.
*
* For more information see:
* https://sailsjs.com/anatomy/Gruntfile.js
*/
module.exports = function(grunt) {
var loadGruntTasks = require('sails-hook-grunt/accessible/load-grunt-tasks');
// Load Grunt task configurations (from `tasks/config/`) and Grunt
// task registrations (from `tasks/register/`).
loadGruntTasks(__dirname, grunt);
};

1
website/Procfile Normal file
View File

@ -0,0 +1 @@
web: NODE_ENV=production node app.js

30
website/README.md Normal file
View File

@ -0,0 +1,30 @@
# fleetdm.com
This is where the code for the public https://fleetdm.com website lives.
a [Sails v1](https://sailsjs.com) application
### Links
+ [Sails framework documentation](https://sailsjs.com/get-started)
+ [Version notes / upgrading](https://sailsjs.com/documentation/upgrading)
+ [Deployment tips](https://sailsjs.com/documentation/concepts/deployment)
+ [Community support options](https://sailsjs.com/support)
+ [Professional / enterprise options](https://sailsjs.com/enterprise)
### Version info
This app was originally generated on Wed Aug 26 2020 04:48:44 GMT-0500 (Central Daylight Time) using Sails v1.2.5.
<!-- Internally, Sails used [`sails-generate@2.0.0`](https://github.com/balderdashy/sails-generate/tree/v2.0.0/lib/core-generators/new). -->
This project's boilerplate is based on an expanded seed app provided by the [Sails core team](https://sailsjs.com/about) to make it easier for you to build on top of ready-made features like authentication, enrollment, email verification, and billing. For more information, [drop us a line](https://sailsjs.com/support).
<!--
Note: Generators are usually run using the globally-installed `sails` CLI (command-line interface). This CLI version is _environment-specific_ rather than app-specific, thus over time, as a project's dependencies are upgraded or the project is worked on by different developers on different computers using different versions of Node.js, the Sails dependency in its package.json file may differ from the globally-installed Sails CLI release it was originally generated with. (Be sure to always check out the relevant [upgrading guides](https://sailsjs.com/upgrading) before upgrading the version of Sails used by your app. If you're stuck, [get help here](https://sailsjs.com/support).)
-->

View File

@ -0,0 +1,50 @@
module.exports = {
friendlyName: 'Logout',
description: 'Log out of this app.',
extendedDescription:
`This action deletes the \`req.session.userId\` key from the session of the requesting user agent.
Actual garbage collection of session data depends on this app's session store, and
potentially also on the [TTL configuration](https://sailsjs.com/docs/reference/configuration/sails-config-session)
you provided for it.
Note that this action does not check to see whether or not the requesting user was
actually logged in. (If they weren't, then this action is just a no-op.)`,
exits: {
success: {
description: 'The requesting user agent has been successfully logged out.'
},
redirect: {
description: 'The requesting user agent looks to be a web browser.',
extendedDescription: 'After logging out from a web browser, the user is redirected away.',
responseType: 'redirect'
}
},
fn: async function () {
// Clear the `userId` property from this session.
delete this.req.session.userId;
// Then finish up, sending an appropriate response.
// > Under the covers, this persists the now-logged-out session back
// > to the underlying session store.
if (!this.req.wantsJSON) {
throw {redirect: '/login'};
}
}
};

View File

@ -0,0 +1,79 @@
module.exports = {
friendlyName: 'Update billing card',
description: 'Update the credit card for the logged-in user.',
inputs: {
stripeToken: {
type: 'string',
example: 'tok_199k3qEXw14QdSnRwmsK99MH',
description: 'The single-use Stripe Checkout token identifier representing the user\'s payment source (i.e. credit card.)',
extendedDescription: 'Omit this (or use "") to remove this user\'s payment source.',
whereToGet: {
description: 'This Stripe.js token is provided to the front-end (client-side) code after completing a Stripe Checkout or Stripe Elements flow.'
}
},
billingCardLast4: {
type: 'string',
example: '4242',
description: 'Omit if removing card info.',
whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' }
},
billingCardBrand: {
type: 'string',
example: 'visa',
description: 'Omit if removing card info.',
whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' }
},
billingCardExpMonth: {
type: 'string',
example: '08',
description: 'Omit if removing card info.',
whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' }
},
billingCardExpYear: {
type: 'string',
example: '2023',
description: 'Omit if removing card info.',
whereToGet: { description: 'Credit card info is provided by Stripe after completing the checkout flow.' }
},
},
fn: async function ({stripeToken, billingCardLast4, billingCardBrand, billingCardExpMonth, billingCardExpYear}) {
// Add, update, or remove the default payment source for the logged-in user's
// customer entry in Stripe.
var stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({
stripeCustomerId: this.req.me.stripeCustomerId,
token: stripeToken || '',
}).timeout(5000).retry();
// Update (or clear) the card info we have stored for this user in our database.
// > Remember, never store complete card numbers-- only the last 4 digits + expiration!
// > Storing (or even receiving) complete, unencrypted card numbers would require PCI
// > compliance in the U.S.
await User.updateOne({ id: this.req.me.id })
.set({
stripeCustomerId,
hasBillingCard: stripeToken ? true : false,
billingCardBrand: stripeToken ? billingCardBrand : '',
billingCardLast4: stripeToken ? billingCardLast4 : '',
billingCardExpMonth: stripeToken ? billingCardExpMonth : '',
billingCardExpYear: stripeToken ? billingCardExpYear : ''
});
}
};

View File

@ -0,0 +1,35 @@
module.exports = {
friendlyName: 'Update password',
description: 'Update the password for the logged-in user.',
inputs: {
password: {
description: 'The new, unencrypted password.',
example: 'abc123v2',
required: true
}
},
fn: async function ({password}) {
// Hash the new password.
var hashed = await sails.helpers.passwords.hashPassword(password);
// Update the record for the logged-in user.
await User.updateOne({ id: this.req.me.id })
.set({
password: hashed
});
}
};

View File

@ -0,0 +1,160 @@
module.exports = {
friendlyName: 'Update profile',
description: 'Update the profile for the logged-in user.',
inputs: {
fullName: {
type: 'string'
},
emailAddress: {
type: 'string'
},
},
exits: {
emailAlreadyInUse: {
statusCode: 409,
description: 'The provided email address is already in use.',
},
},
fn: async function ({fullName, emailAddress}) {
var newEmailAddress = emailAddress;
if (newEmailAddress !== undefined) {
newEmailAddress = newEmailAddress.toLowerCase();
}
// Determine if this request wants to change the current user's email address,
// revert her pending email address change, modify her pending email address
// change, or if the email address won't be affected at all.
var desiredEmailEffect;// ('change-immediately', 'begin-change', 'cancel-pending-change', 'modify-pending-change', or '')
if (
newEmailAddress === undefined ||
(this.req.me.emailStatus !== 'change-requested' && newEmailAddress === this.req.me.emailAddress) ||
(this.req.me.emailStatus === 'change-requested' && newEmailAddress === this.req.me.emailChangeCandidate)
) {
desiredEmailEffect = '';
} else if (this.req.me.emailStatus === 'change-requested' && newEmailAddress === this.req.me.emailAddress) {
desiredEmailEffect = 'cancel-pending-change';
} else if (this.req.me.emailStatus === 'change-requested' && newEmailAddress !== this.req.me.emailAddress) {
desiredEmailEffect = 'modify-pending-change';
} else if (!sails.config.custom.verifyEmailAddresses || this.req.me.emailStatus === 'unconfirmed') {
desiredEmailEffect = 'change-immediately';
} else {
desiredEmailEffect = 'begin-change';
}
// If the email address is changing, make sure it is not already being used.
if (_.contains(['begin-change', 'change-immediately', 'modify-pending-change'], desiredEmailEffect)) {
let conflictingUser = await User.findOne({
or: [
{ emailAddress: newEmailAddress },
{ emailChangeCandidate: newEmailAddress }
]
});
if (conflictingUser) {
throw 'emailAlreadyInUse';
}
}
// Start building the values to set in the db.
// (We always set the fullName if provided.)
var valuesToSet = {
fullName,
};
switch (desiredEmailEffect) {
// Change now
case 'change-immediately':
_.extend(valuesToSet, {
emailAddress: newEmailAddress,
emailChangeCandidate: '',
emailProofToken: '',
emailProofTokenExpiresAt: 0,
emailStatus: this.req.me.emailStatus === 'unconfirmed' ? 'unconfirmed' : 'confirmed'
});
break;
// Begin new email change, or modify a pending email change
case 'begin-change':
case 'modify-pending-change':
_.extend(valuesToSet, {
emailChangeCandidate: newEmailAddress,
emailProofToken: await sails.helpers.strings.random('url-friendly'),
emailProofTokenExpiresAt: Date.now() + sails.config.custom.emailProofTokenTTL,
emailStatus: 'change-requested'
});
break;
// Cancel pending email change
case 'cancel-pending-change':
_.extend(valuesToSet, {
emailChangeCandidate: '',
emailProofToken: '',
emailProofTokenExpiresAt: 0,
emailStatus: 'confirmed'
});
break;
// Otherwise, do nothing re: email
}
// Save to the db
await User.updateOne({id: this.req.me.id })
.set(valuesToSet);
// If this is an immediate change, and billing features are enabled,
// then also update the billing email for this user's linked customer entry
// in the Stripe API to make sure they receive email receipts.
// > Note: If there was not already a Stripe customer entry for this user,
// > then one will be set up implicitly, so we'll need to persist it to our
// > database. (This could happen if Stripe credentials were not configured
// > at the time this user was originally created.)
if(desiredEmailEffect === 'change-immediately' && sails.config.custom.enableBillingFeatures) {
let didNotAlreadyHaveCustomerId = (! this.req.me.stripeCustomerId);
let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({
stripeCustomerId: this.req.me.stripeCustomerId,
emailAddress: newEmailAddress
}).timeout(5000).retry();
if (didNotAlreadyHaveCustomerId){
await User.updateOne({ id: this.req.me.id })
.set({
stripeCustomerId
});
}
}
// 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
}
});
}
}
};

View File

@ -0,0 +1,30 @@
module.exports = {
friendlyName: 'View account overview',
description: 'Display "Account Overview" page.',
exits: {
success: {
viewTemplatePath: 'pages/account/account-overview',
}
},
fn: async function () {
// If billing features are enabled, include our configured Stripe.js
// public key in the view locals. Otherwise, leave it as undefined.
return {
stripePublishableKey: sails.config.custom.enableBillingFeatures? sails.config.custom.stripePublishableKey : undefined,
};
}
};

View File

@ -0,0 +1,26 @@
module.exports = {
friendlyName: 'View edit password',
description: 'Display "Edit password" page.',
exits: {
success: {
viewTemplatePath: 'pages/account/edit-password'
}
},
fn: async function () {
return {};
}
};

View File

@ -0,0 +1,26 @@
module.exports = {
friendlyName: 'View edit profile',
description: 'Display "Edit profile" page.',
exits: {
success: {
viewTemplatePath: 'pages/account/edit-profile',
}
},
fn: async function () {
return {};
}
};

View File

@ -0,0 +1,27 @@
module.exports = {
friendlyName: 'View welcome page',
description: 'Display the dashboard "Welcome" page.',
exits: {
success: {
viewTemplatePath: 'pages/dashboard/welcome',
description: 'Display the welcome page for authenticated users.'
},
},
fn: async function () {
return {};
}
};

View File

@ -0,0 +1,79 @@
module.exports = {
friendlyName: 'Deliver contact form message',
description: 'Deliver a contact form message to the appropriate internal channel(s).',
inputs: {
emailAddress: {
required: true,
type: 'string',
description: 'A return email address where we can respond.',
example: 'hermione@hogwarts.edu'
},
topic: {
required: true,
type: 'string',
description: 'The topic from the contact form.',
example: 'I want to buy stuff.'
},
fullName: {
required: true,
type: 'string',
description: 'The full name of the human sending this message.',
example: 'Hermione Granger'
},
message: {
required: true,
type: 'string',
description: 'The custom message, in plain text.'
}
},
exits: {
success: {
description: 'The message was sent successfully.'
}
},
fn: async function({emailAddress, topic, fullName, message}) {
if (!sails.config.custom.internalEmailAddress) {
throw new Error(
`Cannot deliver incoming message from contact form because there is no internal
email address (\`sails.config.custom.internalEmailAddress\`) configured for this
app. To enable contact form emails, you'll need to add this missing setting to
your custom config -- usually in \`config/custom.js\`, \`config/staging.js\`,
\`config/production.js\`, or via system environment variables.`
);
}
await sails.helpers.sendTemplateEmail.with({
to: sails.config.custom.internalEmailAddress,
subject: 'New contact form message',
template: 'internal/email-contact-form',
layout: false,
templateData: {
contactName: fullName,
contactEmail: emailAddress,
topic,
message,
}
});
}
};

View File

@ -0,0 +1,147 @@
module.exports = {
friendlyName: 'Confirm email',
description:
`Confirm a new user's email address, or an existing user's request for an email address change,
then redirect to either a special landing page (for newly-signed up users), or the account page
(for existing users who just changed their email address).`,
inputs: {
token: {
description: 'The confirmation token from the email.',
example: '4-32fad81jdaf$329'
}
},
exits: {
success: {
description: 'Email address confirmed and requesting user logged in.'
},
redirect: {
description: 'Email address confirmed and requesting user logged in. Since this looks like a browser, redirecting...',
responseType: 'redirect'
},
invalidOrExpiredToken: {
responseType: 'expired',
description: 'The provided token is expired, invalid, or already used up.',
},
emailAddressNoLongerAvailable: {
statusCode: 409,
viewTemplatePath: '500',
description: 'The email address is no longer available.',
extendedDescription: 'This is an edge case that is not always anticipated by websites and APIs. Since it is pretty rare, the 500 server error page is used as a simple catch-all. If this becomes important in the future, this could easily be expanded into a custom error page or resolution flow. But for context: this behavior of showing the 500 server error page mimics how popular apps like Slack behave under the same circumstances.',
}
},
fn: async function ({token}) {
// If no token was provided, this is automatically invalid.
if (!token) {
throw 'invalidOrExpiredToken';
}
// Get the user with the matching email token.
var user = await User.findOne({ emailProofToken: token });
// If no such user exists, or their token is expired, bail.
if (!user || user.emailProofTokenExpiresAt <= Date.now()) {
throw 'invalidOrExpiredToken';
}
if (user.emailStatus === 'unconfirmed') {
// ┌─┐┌─┐┌┐┌┌─┐┬┬─┐┌┬┐┬┌┐┌┌─┐ ╔═╗╦╦═╗╔═╗╔╦╗ ╔╦╗╦╔╦╗╔═╗ ╦ ╦╔═╗╔═╗╦═╗ ┌─┐┌┬┐┌─┐┬┬
// │ │ ││││├┤ │├┬┘││││││││ ┬ ╠╣ ║╠╦╝╚═╗ ║───║ ║║║║║╣ ║ ║╚═╗║╣ ╠╦╝ ├┤ │││├─┤││
// └─┘└─┘┘└┘└ ┴┴└─┴ ┴┴┘└┘└─┘ ╚ ╩╩╚═╚═╝ ╩ ╩ ╩╩ ╩╚═╝ ╚═╝╚═╝╚═╝╩╚═ └─┘┴ ┴┴ ┴┴┴─┘
// If this is a new user confirming their email for the first time,
// then just update the state of their user record in the database,
// store their user id in the session (just in case they aren't logged
// in already), and then redirect them to the "email confirmed" page.
await User.updateOne({ id: user.id }).set({
emailStatus: 'confirmed',
emailProofToken: '',
emailProofTokenExpiresAt: 0
});
this.req.session.userId = user.id;
if (this.req.wantsJSON) {
return;
} else {
throw { redirect: '/email/confirmed' };
}
} else if (user.emailStatus === 'change-requested') {
// ┌─┐┌─┐┌┐┌┌─┐┬┬─┐┌┬┐┬┌┐┌┌─┐ ╔═╗╦ ╦╔═╗╔╗╔╔═╗╔═╗╔╦╗ ┌─┐┌┬┐┌─┐┬┬
// │ │ ││││├┤ │├┬┘││││││││ ┬ ║ ╠═╣╠═╣║║║║ ╦║╣ ║║ ├┤ │││├─┤││
// └─┘└─┘┘└┘└ ┴┴└─┴ ┴┴┘└┘└─┘ ╚═╝╩ ╩╩ ╩╝╚╝╚═╝╚═╝═╩╝ └─┘┴ ┴┴ ┴┴┴─┘
if (!user.emailChangeCandidate){
throw new Error(`Consistency violation: Could not update Stripe customer because this user record's emailChangeCandidate ("${user.emailChangeCandidate}") is missing. (This should never happen.)`);
}
// Last line of defense: since email change candidates are not protected
// by a uniqueness constraint in the database, it's important that we make
// sure no one else managed to grab this email in the mean time since we
// last checked its availability. (This is a relatively rare edge case--
// see exit description.)
if (await User.count({ emailAddress: user.emailChangeCandidate }) > 0) {
throw 'emailAddressNoLongerAvailable';
}
// If billing features are enabled, also update the billing email for this
// user's linked customer entry in the Stripe API to make sure they receive
// email receipts.
// > Note: If there was not already a Stripe customer entry for this user,
// > then one will be set up implicitly, so we'll need to persist it to our
// > database. (This could happen if Stripe credentials were not configured
// > at the time this user was originally created.)
if(sails.config.custom.enableBillingFeatures) {
let didNotAlreadyHaveCustomerId = (! user.stripeCustomerId);
let stripeCustomerId = await sails.helpers.stripe.saveBillingInfo.with({
stripeCustomerId: user.stripeCustomerId,
emailAddress: user.emailChangeCandidate
}).timeout(5000).retry();
if (didNotAlreadyHaveCustomerId){
await User.updateOne({ id: user.id }).set({
stripeCustomerId
});
}
}
// Finally update the user in the database, store their id in the session
// (just in case they aren't logged in already), then redirect them to
// their "my account" page so they can see their updated email address.
await User.updateOne({ id: user.id })
.set({
emailStatus: 'confirmed',
emailProofToken: '',
emailProofTokenExpiresAt: 0,
emailAddress: user.emailChangeCandidate,
emailChangeCandidate: '',
});
this.req.session.userId = user.id;
if (this.req.wantsJSON) {
return;
} else {
throw { redirect: '/account' };
}
} else {
throw new Error(`Consistency violation: User ${user.id} has an email proof token, but somehow also has an emailStatus of "${user.emailStatus}"! (This should never happen.)`);
}
}
};

View File

@ -0,0 +1,113 @@
module.exports = {
friendlyName: 'Login',
description: 'Log in using the provided email and password combination.',
extendedDescription:
`This action attempts to look up the user record in the database with the
specified email address. Then, if such a user exists, it uses
bcrypt to compare the hashed password from the database with the provided
password attempt.`,
inputs: {
emailAddress: {
description: 'The email to try in this attempt, e.g. "irl@example.com".',
type: 'string',
required: true
},
password: {
description: 'The unencrypted password to try in this attempt, e.g. "passwordlol".',
type: 'string',
required: true
},
rememberMe: {
description: 'Whether to extend the lifetime of the user\'s session.',
extendedDescription:
`Note that this is NOT SUPPORTED when using virtual requests (e.g. sending
requests over WebSockets instead of HTTP).`,
type: 'boolean'
}
},
exits: {
success: {
description: 'The requesting user agent has been successfully logged in.',
extendedDescription:
`Under the covers, this stores the id of the logged-in user in the session
as the \`userId\` key. The next time this user agent sends a request, assuming
it includes a cookie (like a web browser), Sails will automatically make this
user id available as req.session.userId in the corresponding action. (Also note
that, thanks to the included "custom" hook, when a relevant request is received
from a logged-in user, that user's entire record from the database will be fetched
and exposed as \`req.me\`.)`
},
badCombo: {
description: `The provided email and password combination does not
match any user in the database.`,
responseType: 'unauthorized'
// ^This uses the custom `unauthorized` response located in `api/responses/unauthorized.js`.
// To customize the generic "unauthorized" response across this entire app, change that file
// (see api/responses/unauthorized).
//
// To customize the response for _only this_ action, replace `responseType` with
// something else. For example, you might set `statusCode: 498` and change the
// implementation below accordingly (see http://sailsjs.com/docs/concepts/controllers).
}
},
fn: async function ({emailAddress, password, rememberMe}) {
// Look up by the email address.
// (note that we lowercase it to ensure the lookup is always case-insensitive,
// regardless of which database we're using)
var userRecord = await User.findOne({
emailAddress: emailAddress.toLowerCase(),
});
// If there was no matching user, respond thru the "badCombo" exit.
if(!userRecord) {
throw 'badCombo';
}
// If the password doesn't match, then also exit thru "badCombo".
await sails.helpers.passwords.checkPassword(password, userRecord.password)
.intercept('incorrect', 'badCombo');
// If "Remember Me" was enabled, then keep the session alive for
// a longer amount of time. (This causes an updated "Set Cookie"
// response header to be sent as the result of this request -- thus
// we must be dealing with a traditional HTTP request in order for
// this to work.)
if (rememberMe) {
if (this.req.isSocket) {
sails.log.warn(
'Received `rememberMe: true` from a virtual request, but it was ignored\n'+
'because a browser\'s session cookie cannot be reset over sockets.\n'+
'Please use a traditional HTTP request instead.'
);
} else {
this.req.session.cookie.maxAge = sails.config.custom.rememberMeCookieMaxAge;
}
}//fi
// Modify the active session instance.
// (This will be persisted when the response is sent.)
this.req.session.userId = userRecord.id;
}
};

View File

@ -0,0 +1,66 @@
module.exports = {
friendlyName: 'Send password recovery email',
description: 'Send a password recovery notification to the user with the specified email address.',
inputs: {
emailAddress: {
description: 'The email address of the alleged user who wants to recover their password.',
example: 'rydahl@example.com',
type: 'string',
required: true
}
},
exits: {
success: {
description: 'The email address might have matched a user in the database. (If so, a recovery email was sent.)'
},
},
fn: async function ({emailAddress}) {
// Find the record for this user.
// (Even if no such user exists, pretend it worked to discourage sniffing.)
var userRecord = await User.findOne({ emailAddress });
if (!userRecord) {
return;
}//•
// Come up with a pseudorandom, probabilistically-unique token for use
// in our password recovery email.
var token = await sails.helpers.strings.random('url-friendly');
// Store the token on the user record
// (This allows us to look up the user when the link from the email is clicked.)
await User.updateOne({ id: userRecord.id })
.set({
passwordResetToken: token,
passwordResetTokenExpiresAt: Date.now() + sails.config.custom.passwordResetTokenTTL,
});
// Send recovery email
await sails.helpers.sendTemplateEmail.with({
to: emailAddress,
subject: 'Password reset instructions',
template: 'email-reset-password',
templateData: {
fullName: userRecord.fullName,
token: token
}
});
}
};

View File

@ -0,0 +1,121 @@
module.exports = {
friendlyName: 'Signup',
description: 'Sign up for a new user account.',
extendedDescription:
`This creates a new user record in the database, signs in the requesting user agent
by modifying its [session](https://sailsjs.com/documentation/concepts/sessions), and
(if emailing with Mailgun is enabled) sends an account verification email.
If a verification email is sent, the new user's account is put in an "unconfirmed" state
until they confirm they are using a legitimate email address (by clicking the link in
the account verification message.)`,
inputs: {
emailAddress: {
required: true,
type: 'string',
isEmail: true,
description: 'The email address for the new account, e.g. m@example.com.',
extendedDescription: 'Must be a valid email address.',
},
password: {
required: true,
type: 'string',
maxLength: 200,
example: 'passwordlol',
description: 'The unencrypted password to use for the new account.'
},
fullName: {
required: true,
type: 'string',
example: 'Frida Kahlo de Rivera',
description: 'The user\'s full name.',
}
},
exits: {
success: {
description: 'New user account was created successfully.'
},
invalid: {
responseType: 'badRequest',
description: 'The provided fullName, 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.'
},
emailAlreadyInUse: {
statusCode: 409,
description: 'The provided email address is already in use.',
},
},
fn: async function ({emailAddress, password, fullName}) {
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,
emailAddress: newEmailAddress,
password: await sails.helpers.passwords.hashPassword(password),
tosAcceptedByIp: this.req.ip
}, sails.config.custom.verifyEmailAddresses? {
emailProofToken: await sails.helpers.strings.random('url-friendly'),
emailProofTokenExpiresAt: Date.now() + sails.config.custom.emailProofTokenTTL,
emailStatus: 'unconfirmed'
}:{}))
.intercept('E_UNIQUE', 'emailAlreadyInUse')
.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
});
}
// Store the user's new id in their session.
this.req.session.userId = newUserRecord.id;
if (sails.config.custom.verifyEmailAddresses) {
// Send "confirm account" email
await sails.helpers.sendTemplateEmail.with({
to: newEmailAddress,
subject: 'Please confirm your account',
template: 'email-verify-account',
templateData: {
fullName,
token: newUserRecord.emailProofToken
}
});
} else {
sails.log.info('Skipping new account email verification... (since `verifyEmailAddresses` is disabled)');
}
}
};

View File

@ -0,0 +1,74 @@
module.exports = {
friendlyName: 'Update password and login',
description: 'Finish the password recovery flow by setting the new password and '+
'logging in the requesting user, based on the authenticity of their token.',
inputs: {
password: {
description: 'The new, unencrypted password.',
example: 'abc123v2',
required: true
},
token: {
description: 'The password token that was generated by the `sendPasswordRecoveryEmail` endpoint.',
example: 'gwa8gs8hgw9h2g9hg29hgwh9asdgh9q34$$$$$asdgasdggds',
required: true
}
},
exits: {
success: {
description: 'Password successfully updated, and requesting user agent is now logged in.'
},
invalidToken: {
description: 'The provided password token is invalid, expired, or has already been used.',
responseType: 'expired'
}
},
fn: async function ({password, token}) {
if(!token) {
throw 'invalidToken';
}
// Look up the user with this reset token.
var userRecord = await User.findOne({ passwordResetToken: token });
// If no such user exists, or their token is expired, bail.
if (!userRecord || userRecord.passwordResetTokenExpiresAt <= Date.now()) {
throw 'invalidToken';
}
// Hash the new password.
var hashed = await sails.helpers.passwords.hashPassword(password);
// Store the user's new password and clear their reset token so it can't be used again.
await User.updateOne({ id: userRecord.id })
.set({
password: hashed,
passwordResetToken: '',
passwordResetTokenExpiresAt: 0
});
// Log the user in.
// (This will be persisted when the response is sent.)
this.req.session.userId = userRecord.id;
}
};

View File

@ -0,0 +1,27 @@
module.exports = {
friendlyName: 'View confirmed email',
description: 'Display "Confirmed email" page.',
exits: {
success: {
viewTemplatePath: 'pages/entrance/confirmed-email'
}
},
fn: async function () {
// Respond with view.
return {};
}
};

View File

@ -0,0 +1,36 @@
module.exports = {
friendlyName: 'View forgot password',
description: 'Display "Forgot password" page.',
exits: {
success: {
viewTemplatePath: 'pages/entrance/forgot-password',
},
redirect: {
description: 'The requesting user is already logged in.',
extendedDescription: 'Logged-in users should change their password in "Account settings."',
responseType: 'redirect',
}
},
fn: async function () {
if (this.req.me) {
throw {redirect: '/'};
}
return {};
}
};

View File

@ -0,0 +1,35 @@
module.exports = {
friendlyName: 'View login',
description: 'Display "Login" page.',
exits: {
success: {
viewTemplatePath: 'pages/entrance/login',
},
redirect: {
description: 'The requesting user is already logged in.',
responseType: 'redirect'
}
},
fn: async function () {
if (this.req.me) {
throw {redirect: '/'};
}
return {};
}
};

View File

@ -0,0 +1,57 @@
module.exports = {
friendlyName: 'View new password',
description: 'Display "New password" page.',
inputs: {
token: {
description: 'The password reset token from the email.',
example: '4-32fad81jdaf$329'
}
},
exits: {
success: {
viewTemplatePath: 'pages/entrance/new-password'
},
invalidOrExpiredToken: {
responseType: 'expired',
description: 'The provided token is expired, invalid, or has already been used.',
}
},
fn: async function ({token}) {
// If password reset token is missing, display an error page explaining that the link is bad.
if (!token) {
sails.log.warn('Attempting to view new password (recovery) page, but no reset password token included in request! Displaying error page...');
throw 'invalidOrExpiredToken';
}//•
// Look up the user with this reset token.
var userRecord = await User.findOne({ passwordResetToken: token });
// If no such user exists, or their token is expired, display an error page explaining that the link is bad.
if (!userRecord || userRecord.passwordResetTokenExpiresAt <= Date.now()) {
throw 'invalidOrExpiredToken';
}
// Grab token and include it in view locals
return {
token,
};
}
};

View File

@ -0,0 +1,35 @@
module.exports = {
friendlyName: 'View signup',
description: 'Display "Signup" page.',
exits: {
success: {
viewTemplatePath: 'pages/entrance/signup',
},
redirect: {
description: 'The requesting user is already logged in.',
responseType: 'redirect'
}
},
fn: async function () {
if (this.req.me) {
throw {redirect: '/'};
}
return {};
}
};

View File

@ -0,0 +1,27 @@
module.exports = {
friendlyName: 'View privacy',
description: 'Display "Privacy policy" page.',
exits: {
success: {
viewTemplatePath: 'pages/legal/privacy'
}
},
fn: async function () {
// All done.
return;
}
};

View File

@ -0,0 +1,27 @@
module.exports = {
friendlyName: 'View terms',
description: 'Display "Legal terms" page.',
exits: {
success: {
viewTemplatePath: 'pages/legal/terms'
}
},
fn: async function () {
// All done.
return;
}
};

View File

@ -0,0 +1,27 @@
module.exports = {
friendlyName: 'View contact',
description: 'Display "Contact" page.',
exits: {
success: {
viewTemplatePath: 'pages/contact'
}
},
fn: async function () {
// Respond with view.
return {};
}
};

View File

@ -0,0 +1,27 @@
module.exports = {
friendlyName: 'View faq',
description: 'Display "FAQ" page.',
exits: {
success: {
viewTemplatePath: 'pages/faq'
}
},
fn: async function () {
// Respond with view.
return {};
}
};

View File

@ -0,0 +1,37 @@
module.exports = {
friendlyName: 'View homepage or redirect',
description: 'Display or redirect to the appropriate homepage, depending on login status.',
exits: {
success: {
statusCode: 200,
description: 'Requesting user is a guest, so show the public landing page.',
viewTemplatePath: 'pages/homepage'
},
redirect: {
responseType: 'redirect',
description: 'Requesting user is logged in, so redirect to the internal welcome page.'
},
},
fn: async function () {
if (this.req.me) {
throw {redirect:'/welcome'};
}
return {};
}
};

View File

@ -0,0 +1,33 @@
module.exports = {
friendlyName: 'Redact user',
description: 'Destructively remove properties from the provided User record to prepare it for publication.',
sync: true,
inputs: {
user: {
type: 'ref',
readOnly: false
}
},
fn: function ({ user }) {
for (let [attrName, attrDef] of Object.entries(User.attributes)) {
if (attrDef.protect) {
delete user[attrName];
}//fi
}//∞
}
};

View File

@ -0,0 +1,282 @@
module.exports = {
friendlyName: 'Send template email',
description: 'Send an email using a template.',
extendedDescription: 'To ease testing and development, if the provided "to" email address ends in "@example.com", '+
'then the email message will be written to the terminal instead of actually being sent.'+
'(Thanks [@simonratner](https://github.com/simonratner)!)',
inputs: {
template: {
description: 'The relative path to an EJS template within our `views/emails/` folder -- WITHOUT the file extension.',
extendedDescription: 'Use strings like "foo" or "foo/bar", but NEVER "foo/bar.ejs" or "/foo/bar". For example, '+
'"internal/email-contact-form" would send an email using the "views/emails/internal/email-contact-form.ejs" template.',
example: 'email-reset-password',
type: 'string',
required: true
},
templateData: {
description: 'A dictionary of data which will be accessible in the EJS template.',
extendedDescription: 'Each key will be a local variable accessible in the template. For instance, if you supply '+
'a dictionary with a \`friends\` key, and \`friends\` is an array like \`[{name:"Chandra"}, {name:"Mary"}]\`),'+
'then you will be able to access \`friends\` from the template:\n'+
'\`\`\`\n'+
'<ul>\n'+
'<% for (friend of friends){ %><li><%= friend.name %></li><% }); %>\n'+
'</ul>\n'+
'\`\`\`'+
'\n'+
'This is EJS, so use \`<%= %>\` to inject the HTML-escaped content of a variable, \`<%= %>\` to skip HTML-escaping '+
'and inject the data as-is, or \`<% %>\` to execute some JavaScript code such as an \`if\` statement or \`for\` loop.',
type: {},
defaultsTo: {}
},
to: {
description: 'The email address of the primary recipient.',
extendedDescription: 'If this is any address ending in "@example.com", then don\'t actually deliver the message. '+
'Instead, just log it to the console.',
example: 'nola.thacker@example.com',
required: true,
isEmail: true,
},
toName: {
description: 'Name of the primary recipient as displayed in their inbox.',
example: 'Nola Thacker',
},
subject: {
description: 'The subject of the email.',
example: 'Hello there.',
defaultsTo: ''
},
from: {
description: 'An override for the default "from" email that\'s been configured.',
example: 'anne.martin@example.com',
isEmail: true,
},
fromName: {
description: 'An override for the default "from" name.',
example: 'Anne Martin',
},
layout: {
description: 'Set to `false` to disable layouts altogether, or provide the path (relative '+
'from `views/layouts/`) to an override email layout.',
defaultsTo: 'layout-email',
custom: (layout)=>layout===false || _.isString(layout)
},
ensureAck: {
description: 'Whether to wait for acknowledgement (to hear back) that the email was successfully sent (or at least queued for sending) before returning.',
extendedDescription: 'Otherwise by default, this returns immediately and delivers the request to deliver this email in the background.',
type: 'boolean',
defaultsTo: false
},
bcc: {
description: 'The email addresses of recipients secretly copied on the email.',
example: ['jahnna.n.malcolm@example.com'],
},
attachments: {
description: 'Attachments to include in the email, with the file content encoded as base64.',
whereToGet: {
description: 'If you have `sails-hook-uploads` installed, you can use `sails.reservoir` to get an attachment into the expected format.',
},
example: [
{
contentBytes: 'iVBORw0KGgoAA…',
name: 'sails.png',
type: 'image/png',
}
],
defaultsTo: [],
},
},
exits: {
success: {
outputFriendlyName: 'Email delivery report',
outputDescription: 'A dictionary of information about what went down.',
outputType: {
loggedInsteadOfSending: 'boolean'
}
}
},
fn: async function({template, templateData, to, toName, subject, from, fromName, layout, ensureAck, bcc, attachments}) {
var path = require('path');
var url = require('url');
var util = require('util');
if (!_.startsWith(path.basename(template), 'email-')) {
sails.log.warn(
'The "template" that was passed in to `sendTemplateEmail()` does not begin with '+
'"email-" -- but by convention, all email template files in `views/emails/` should '+
'be namespaced in this way. (This makes it easier to look up email templates by '+
'filename; e.g. when using CMD/CTRL+P in Sublime Text.)\n'+
'Continuing regardless...'
);
}
if (_.startsWith(template, 'views/') || _.startsWith(template, 'emails/')) {
throw new Error(
'The "template" that was passed in to `sendTemplateEmail()` was prefixed with\n'+
'`emails/` or `views/` -- but that part is supposed to be omitted. Instead, please\n'+
'just specify the path to the desired email template relative from `views/emails/`.\n'+
'For example:\n'+
' template: \'email-reset-password\'\n'+
'Or:\n'+
' template: \'admin/email-contact-form\'\n'+
' [?] If you\'re unsure or need advice, see https://sailsjs.com/support'
);
}//•
// Determine appropriate email layout and template to use.
var emailTemplatePath = path.join('emails/', template);
var emailTemplateLayout;
if (layout) {
emailTemplateLayout = path.relative(path.dirname(emailTemplatePath), path.resolve('layouts/', layout));
} else {
emailTemplateLayout = false;
}
// Compile HTML template.
// > Note that we set the layout, provide access to core `url` package (for
// > building links and image srcs, etc.), and also provide access to core
// > `util` package (for dumping debug data in internal emails).
var htmlEmailContents = await sails.renderView(
emailTemplatePath,
_.extend({layout: emailTemplateLayout, url, util }, templateData)
)
.intercept((err)=>{
err.message =
'Could not compile view template.\n'+
'(Usually, this means the provided data is invalid, or missing a piece.)\n'+
'Details:\n'+
err.message;
return err;
});
// Sometimes only log info to the console about the email that WOULD have been sent.
// Specifically, if the "To" email address is anything "@example.com".
//
// > This is used below when determining whether to actually send the email,
// > for convenience during development, but also for safety. (For example,
// > a special-cased version of "user@example.com" is used by Trend Micro Mars
// > scanner to "check apks for malware".)
var isToAddressConsideredFake = Boolean(to.match(/@example\.com$/i));
// If that's the case, or if we're in the "test" environment, then log
// the email instead of sending it:
var dontActuallySend = (
sails.config.environment === 'test' || isToAddressConsideredFake
);
if (dontActuallySend) {
sails.log(
'Skipped sending email, either because the "To" email address ended in "@example.com"\n'+
'or because the current \`sails.config.environment\` is set to "test".\n'+
'\n'+
'But anyway, here is what WOULD have been sent:\n'+
'-=-=-=-=-=-=-=-=-=-=-=-=-= Email log =-=-=-=-=-=-=-=-=-=-=-=-=-\n'+
'To: '+to+'\n'+
'Subject: '+subject+'\n'+
'\n'+
'Body:\n'+
htmlEmailContents+'\n'+
'-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-'
);
} else {
// Otherwise, we'll check that all required Mailgun credentials are set up
// and, if so, continue to actually send the email.
if (!sails.config.custom.sendgridSecret) {
throw new Error(
'Cannot deliver email to "'+to+'" because:\n'+
(()=>{
let problems = [];
if (!sails.config.custom.sendgridSecret) {
problems.push(' • Sendgrid secret is missing from this app\'s configuration (`sails.config.custom.sendgridSecret`)');
}
return problems.join('\n');
})()+
'\n'+
'To resolve these configuration issues, add the missing config variables to\n'+
'\`config/custom.js\`-- or in staging/production, set them up as system\n'+
'environment vars. (If you don\'t have a Sendgrid secret, you can\n'+
'sign up for free at https://sendgrid.com to receive credentials.)\n'+
'\n'+
'> Note that, for convenience during development, there is another alternative:\n'+
'> In lieu of setting up real Sendgrid credentials, you can "fake" email\n'+
'> delivery by using any email address that ends in "@example.com". This will\n'+
'> write automated emails to your logs rather than actually sending them.\n'+
'> (To simulate clicking on a link from an email, just copy and paste the link\n'+
'> from the terminal output into your browser.)\n'+
'\n'+
'[?] If you\'re unsure, visit https://sailsjs.com/support'
);
}
var subjectLinePrefix = sails.config.environment === 'production' ? '' : sails.config.environment === 'staging' ? '[FROM STAGING] ' : '[FROM LOCALHOST] ';
var messageData = {
htmlMessage: htmlEmailContents,
to: to,
toName: toName,
bcc: bcc,
subject: subjectLinePrefix+subject,
from: from,
fromName: fromName,
attachments
};
var deferred = sails.helpers.sendgrid.sendHtmlEmail.with(messageData);
if (ensureAck) {
await deferred;
} else {
// FUTURE: take advantage of .background() here instead (when available)
deferred.exec((err)=>{
if (err) {
sails.log.error(
'Background instruction failed: Could not deliver email:\n'+
util.inspect({template, templateData, to, toName, subject, from, fromName, layout, ensureAck, bcc, attachments},{depth:null})+'\n',
'Error details:\n'+
util.inspect(err)
);
} else {
sails.log.info(
'Background instruction complete: Email sent via email delivery service (or at least queued):\n'+
util.inspect({to, toName, subject, from, fromName, bcc},{depth:null})
);
}
});//_∏_
}//fi
}//fi
// All done!
return {
loggedInsteadOfSending: dontActuallySend,
};
}
};

View File

@ -0,0 +1,251 @@
/**
* @description :: The conventional "custom" hook. Extends this app with custom server-start-time and request-time logic.
* @docs :: https://sailsjs.com/docs/concepts/extending-sails/hooks
*/
module.exports = function defineCustomHook(sails) {
return {
/**
* Runs when a Sails app loads/lifts.
*/
initialize: async function () {
sails.log.info('Initializing project hook... (`api/hooks/custom/`)');
// Check Stripe/Sendgrid configuration (for billing and emails).
var IMPORTANT_STRIPE_CONFIG = ['stripeSecret', 'stripePublishableKey'];
var IMPORTANT_SENDGRID_CONFIG = ['sendgridSecret', 'internalEmailAddress'];
var isMissingStripeConfig = _.difference(IMPORTANT_STRIPE_CONFIG, Object.keys(sails.config.custom)).length > 0;
var isMissingSendgridConfig = _.difference(IMPORTANT_SENDGRID_CONFIG, Object.keys(sails.config.custom)).length > 0;
if (isMissingStripeConfig || isMissingSendgridConfig) {
let missingFeatureText = isMissingStripeConfig && isMissingSendgridConfig ? 'billing and email' : isMissingStripeConfig ? 'billing' : 'email';
let suffix = '';
if (_.contains(['silly'], sails.config.log.level)) {
suffix =
`
> Tip: To exclude sensitive credentials from source control, use:
> config/local.js (for local development)
> environment variables (for production)
>
> If you want to check them in to source control, use:
> config/custom.js (for development)
> config/env/staging.js (for staging)
> config/env/production.js (for production)
>
> (See https://sailsjs.com/docs/concepts/configuration for help configuring Sails.)
`;
}
let problems = [];
if (sails.config.custom.stripeSecret === undefined) {
problems.push('No `sails.config.custom.stripeSecret` was configured.');
}
if (sails.config.custom.stripePublishableKey === undefined) {
problems.push('No `sails.config.custom.stripePublishableKey` was configured.');
}
if (sails.config.custom.sendgridSecret === undefined) {
problems.push('No `sails.config.custom.sendgridSecret` was configured.');
}
if (sails.config.custom.internalEmailAddress === undefined) {
problems.push('No `sails.config.custom.internalEmailAddress` was configured.');
}
sails.log.verbose(
`Some optional settings have not been configured yet:
---------------------------------------------------------------------
${problems.join('\n')}
Until this is addressed, this app's ${missingFeatureText} features
will be disabled and/or hidden in the UI.
[?] If you're unsure or need advice, come by https://sailsjs.com/support
---------------------------------------------------------------------${suffix}`);
}//fi
// Set an additional config keys based on whether Stripe config is available.
// This will determine whether or not to enable various billing features.
sails.config.custom.enableBillingFeatures = !isMissingStripeConfig;
// After "sails-hook-organics" finishes initializing, configure Stripe
// and Sendgrid packs with any available credentials.
sails.after('hook:organics:loaded', ()=>{
sails.helpers.stripe.configure({
secret: sails.config.custom.stripeSecret
});
sails.helpers.sendgrid.configure({
secret: sails.config.custom.sendgridSecret,
from: sails.config.custom.fromEmailAddress,
fromName: sails.config.custom.fromName,
});
});//_∏_
// ... Any other app-specific setup code that needs to run on lift,
// even in production, goes here ...
},
routes: {
/**
* Runs before every matching route.
*
* @param {Ref} req
* @param {Ref} res
* @param {Function} next
*/
before: {
'/*': {
skipAssets: true,
fn: async function(req, res, next){
var url = require('url');
// First, if this is a GET request (and thus potentially a view),
// attach a couple of guaranteed locals.
if (req.method === 'GET') {
// The `_environment` local lets us do a little workaround to make Vue.js
// run in "production mode" without unnecessarily involving complexities
// with webpack et al.)
if (res.locals._environment !== undefined) {
throw new Error('Cannot attach Sails environment as the view local `_environment`, because this view local already exists! (Is it being attached somewhere else?)');
}
res.locals._environment = sails.config.environment;
// The `me` local is set explicitly to `undefined` here just to avoid having to
// do `typeof me !== 'undefined'` checks in our views/layouts/partials.
// > Note that, depending on the request, this may or may not be set to the
// > logged-in user record further below.
if (res.locals.me !== undefined) {
throw new Error('Cannot attach view local `me`, because this view local already exists! (Is it being attached somewhere else?)');
}
res.locals.me = undefined;
}//fi
// Next, if we're running in our actual "production" or "staging" Sails
// environment, check if this is a GET request via some other host,
// for example a subdomain like `webhooks.` or `click.`. If so, we'll
// automatically go ahead and redirect to the corresponding path under
// our base URL, which is environment-specific.
// > Note that we DO NOT redirect virtual socket requests and we DO NOT
// > redirect non-GET requests (because it can confuse some 3rd party
// > platforms that send webhook requests.) We also DO NOT redirect
// > requests in other environments to allow for flexibility during
// > development (e.g. so you can preview an app running locally on
// > your laptop using a local IP address or a tool like ngrok, in
// > case you want to run it on a real, physical mobile/IoT device)
var configuredBaseHostname;
try {
configuredBaseHostname = url.parse(sails.config.custom.baseUrl).host;
} catch (unusedErr) { /*…*/}
if ((sails.config.environment === 'staging' || sails.config.environment === 'production') && !req.isSocket && req.method === 'GET' && req.hostname !== configuredBaseHostname) {
sails.log.info('Redirecting GET request from `'+req.hostname+'` to configured expected host (`'+configuredBaseHostname+'`)...');
return res.redirect(sails.config.custom.baseUrl+req.url);
}//•
// No session? Proceed as usual.
// (e.g. request for a static asset)
if (!req.session) { return next(); }
// Not logged in? Proceed as usual.
if (!req.session.userId) { return next(); }
// Otherwise, look up the logged-in user.
var loggedInUser = await User.findOne({
id: req.session.userId
});
// If the logged-in user has gone missing, log a warning,
// wipe the user id from the requesting user agent's session,
// and then send the "unauthorized" response.
if (!loggedInUser) {
sails.log.warn('Somehow, the user record for the logged-in user (`'+req.session.userId+'`) has gone missing....');
delete req.session.userId;
return res.unauthorized();
}
// Add additional information for convenience when building top-level navigation.
// (i.e. whether to display "Dashboard", "My Account", etc.)
if (!loggedInUser.password || loggedInUser.emailStatus === 'unconfirmed') {
loggedInUser.dontDisplayAccountLinkInNav = true;
}
// Expose the user record as an extra property on the request object (`req.me`).
// > Note that we make sure `req.me` doesn't already exist first.
if (req.me !== undefined) {
throw new Error('Cannot attach logged-in user as `req.me` because this property already exists! (Is it being attached somewhere else?)');
}
req.me = loggedInUser;
// If our "lastSeenAt" attribute for this user is at least a few seconds old, then set it
// to the current timestamp.
//
// (Note: As an optimization, this is run behind the scenes to avoid adding needless latency.)
var MS_TO_BUFFER = 60*1000;
var now = Date.now();
if (loggedInUser.lastSeenAt < now - MS_TO_BUFFER) {
User.updateOne({id: loggedInUser.id})
.set({ lastSeenAt: now })
.exec((err)=>{
if (err) {
sails.log.error('Background task failed: Could not update user (`'+loggedInUser.id+'`) with a new `lastSeenAt` timestamp. Error details: '+err.stack);
return;
}//•
sails.log.verbose('Updated the `lastSeenAt` timestamp for user `'+loggedInUser.id+'`.');
// Nothing else to do here.
});//_∏_ (Meanwhile...)
}//fi
// If this is a GET request, then also expose an extra view local (`<%= me %>`).
// > Note that we make sure a local named `me` doesn't already exist first.
// > Also note that we strip off any properties that correspond with protected attributes.
if (req.method === 'GET') {
if (res.locals.me !== undefined) {
throw new Error('Cannot attach logged-in user as the view local `me`, because this view local already exists! (Is it being attached somewhere else?)');
}
// Exclude any fields corresponding with attributes that have `protect: true`.
var sanitizedUser = _.extend({}, loggedInUser);
sails.helpers.redactUser(sanitizedUser);
// If there is still a "password" in sanitized user data, then delete it just to be safe.
// (But also log a warning so this isn't hopelessly confusing.)
if (sanitizedUser.password) {
sails.log.warn('The logged in user record has a `password` property, but it was still there after pruning off all properties that match `protect: true` attributes in the User model. So, just to be safe, removing the `password` property anyway...');
delete sanitizedUser.password;
}//fi
res.locals.me = sanitizedUser;
// Include information on the locals as to whether billing features
// are enabled for this app, and whether email verification is required.
res.locals.isBillingEnabled = sails.config.custom.enableBillingFeatures;
res.locals.isEmailVerificationRequired = sails.config.custom.verifyEmailAddresses;
}//fi
// Prevent the browser from caching logged-in users' pages.
// (including w/ the Chrome back button)
// > • https://mixmax.com/blog/chrome-back-button-cache-no-store
// > • https://madhatted.com/2013/6/16/you-do-not-understand-browser-history
res.setHeader('Cache-Control', 'no-cache, no-store');
return next();
}
}
}
}
};
};

171
website/api/models/User.js Normal file
View File

@ -0,0 +1,171 @@
/**
* User.js
*
* A user who can log in to this application.
*/
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
emailAddress: {
type: 'string',
required: true,
unique: true,
isEmail: true,
maxLength: 200,
example: 'mary.sue@example.com'
},
emailStatus: {
type: 'string',
isIn: ['unconfirmed', 'change-requested', 'confirmed'],
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.`
},
emailChangeCandidate: {
type: 'string',
isEmail: true,
description: 'A still-unconfirmed email address that this user wants to change to (if relevant).'
},
password: {
type: 'string',
required: true,
description: 'Securely hashed representation of the user\'s login password.',
protect: true,
example: '2$28a8eabna301089103-13948134nad'
},
fullName: {
type: 'string',
required: true,
description: 'Full representation of the user\'s name.',
maxLength: 120,
example: 'Mary Sue van der McHenst'
},
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.
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.`
},
passwordResetToken: {
type: 'string',
description: 'A unique token used to verify the user\'s identity when recovering a password. Expires after 1 use, or after a set amount of time has elapsed.'
},
passwordResetTokenExpiresAt: {
type: 'number',
description: 'A JS timestamp (epoch ms) representing the moment when this user\'s `passwordResetToken` will expire (or 0 if the user currently has no such token).',
example: 1502844074211
},
emailProofToken: {
type: 'string',
description: 'A pseudorandom, probabilistically-unique token for use in our account verification emails.'
},
emailProofTokenExpiresAt: {
type: 'number',
description: 'A JS timestamp (epoch ms) representing the moment when this user\'s `emailProofToken` will expire (or 0 if the user currently has no such token).',
example: 1502844074211
},
stripeCustomerId: {
type: 'string',
protect: true,
description: 'The id of the customer entry in Stripe associated with this user (or empty string if this user is not linked to a Stripe customer -- e.g. if billing features are not enabled).',
extendedDescription:
`Just because this value is set doesn't necessarily mean that this user has a billing card.
It just means they have a customer entry in Stripe, which might or might not have a billing card.`
},
hasBillingCard: {
type: 'boolean',
description: 'Whether this user has a default billing card hooked up as their payment method.',
extendedDescription:
`More specifically, this indcates whether this user record's linked customer entry in Stripe has
a default payment source (i.e. credit card). Note that a user have a \`stripeCustomerId\`
without necessarily having a billing card.`
},
billingCardBrand: {
type: 'string',
example: 'Visa',
description: 'The brand of this user\'s default billing card (or empty string if no billing card is set up).',
extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.'
},
billingCardLast4: {
type: 'string',
example: '4242',
description: 'The last four digits of the card number for this user\'s default billing card (or empty string if no billing card is set up).',
extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.'
},
billingCardExpMonth: {
type: 'string',
example: '08',
description: 'The two-digit expiration month from this user\'s default billing card, formatted as MM (or empty string if no billing card is set up).',
extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.'
},
billingCardExpYear: {
type: 'string',
example: '2023',
description: 'The four-digit expiration year from this user\'s default billing card, formatted as YYYY (or empty string if no credit card is set up).',
extendedDescription: 'To ensure PCI compliance, this data comes from Stripe, where it reflects the user\'s default payment source.'
},
tosAcceptedByIp: {
type: 'string',
description: 'The IP (ipv4) address of the request that accepted the terms of service.',
extendedDescription: 'Useful for certain types of businesses and regulatory requirements (KYC, etc.)',
moreInfoUrl: 'https://en.wikipedia.org/wiki/Know_your_customer'
},
lastSeenAt: {
type: 'number',
description: 'A JS timestamp (epoch ms) representing the moment at which this user most recently interacted with the backend while logged in (or 0 if they have not interacted with the backend at all yet).',
example: 1502844074211
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// n/a
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
// n/a
},
};

View File

@ -0,0 +1,26 @@
/**
* is-logged-in
*
* A simple policy that allows any request from an authenticated user.
*
* For more about how to use policies, see:
* https://sailsjs.com/config/policies
* https://sailsjs.com/docs/concepts/policies
* https://sailsjs.com/docs/concepts/policies/access-control-and-permissions
*/
module.exports = async function (req, res, proceed) {
// If `req.me` is set, then we know that this request originated
// from a logged-in user. So we can safely proceed to the next policy--
// or, if this is the last policy, the relevant action.
// > For more about where `req.me` comes from, check out this app's
// > custom hook (`api/hooks/custom/index.js`).
if (req.me) {
return proceed();
}
//--•
// Otherwise, this request did not come from a logged-in user.
return res.unauthorized();
};

View File

@ -0,0 +1,28 @@
/**
* is-super-admin
*
* A simple policy that blocks requests from non-super-admins.
*
* For more about how to use policies, see:
* https://sailsjs.com/config/policies
* https://sailsjs.com/docs/concepts/policies
* https://sailsjs.com/docs/concepts/policies/access-control-and-permissions
*/
module.exports = async function (req, res, proceed) {
// First, check whether the request comes from a logged-in user.
// > 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();
}//•
// Then check that this user is a "super admin".
if (!req.me.isSuperAdmin) {
return res.forbidden();
}//•
// IWMIH, we've got ourselves a "super admin".
return proceed();
};

View File

@ -0,0 +1,37 @@
/**
* expired.js
*
* A custom response that content-negotiates the current request to either:
* serve an HTML error page about the specified token being invalid or expired
* or send back 498 (Token Expired/Invalid) with no response body.
*
* Example usage:
* ```
* return res.expired();
* ```
*
* Or with actions2:
* ```
* exits: {
* badToken: {
* description: 'Provided token was expired, invalid, or already used up.',
* responseType: 'expired'
* }
* }
* ```
*/
module.exports = function expired() {
var req = this.req;
var res = this.res;
sails.log.verbose('Ran custom response: res.expired()');
if (req.wantsJSON) {
return res.status(498).send('Token Expired/Invalid');
}
else {
return res.status(498).view('498');
}
};

View File

@ -0,0 +1,43 @@
/**
* unauthorized.js
*
* A custom response that content-negotiates the current request to either:
* log out the current user and redirect them to the login page
* or send back 401 (Unauthorized) with no response body.
*
* Example usage:
* ```
* return res.unauthorized();
* ```
*
* Or with actions2:
* ```
* exits: {
* badCombo: {
* description: 'That email address and password combination is not recognized.',
* responseType: 'unauthorized'
* }
* }
* ```
*/
module.exports = function unauthorized() {
var req = this.req;
var res = this.res;
sails.log.verbose('Ran custom response: res.unauthorized()');
if (req.wantsJSON) {
return res.sendStatus(401);
}
// Or log them out (if necessary) and then redirect to the login page.
else {
if (req.session.userId) {
delete req.session.userId;
}
return res.redirect('/login');
}
};

54
website/app.js Normal file
View File

@ -0,0 +1,54 @@
/**
* app.js
*
* Use `app.js` to run your app without `sails lift`.
* To start the server, run: `node app.js`.
*
* This is handy in situations where the sails CLI is not relevant or useful,
* such as when you deploy to a server, or a PaaS like Heroku.
*
* For example:
* => `node app.js`
* => `npm start`
* => `forever start app.js`
* => `node debug app.js`
*
* The same command-line arguments and env vars are supported, e.g.:
* `NODE_ENV=production node app.js --port=80 --verbose`
*
* For more information see:
* https://sailsjs.com/anatomy/app.js
*/
// Ensure we're in the project directory, so cwd-relative paths work as expected
// no matter where we actually lift from.
// > Note: This is not required in order to lift, but it is a convenient default.
process.chdir(__dirname);
// Attempt to import `sails` dependency, as well as `rc` (for loading `.sailsrc` files).
var sails;
var rc;
try {
sails = require('sails');
rc = require('sails/accessible/rc');
} catch (err) {
console.error('Encountered an error when attempting to require(\'sails\'):');
console.error(err.stack);
console.error('--');
console.error('To run an app using `node app.js`, you need to have Sails installed');
console.error('locally (`./node_modules/sails`). To do that, just make sure you\'re');
console.error('in the same directory as your app and run `npm install`.');
console.error();
console.error('If Sails is installed globally (i.e. `npm install -g sails`) you can');
console.error('also run this app with `sails lift`. Running with `sails lift` will');
console.error('not run this file (`app.js`), but it will do exactly the same thing.');
console.error('(It even uses your app directory\'s local Sails install, if possible.)');
return;
}//-•
// Start server
sails.lift(rc('sails'));

61
website/assets/.eslintrc Normal file
View File

@ -0,0 +1,61 @@
{
// ╔═╗╔═╗╦ ╦╔╗╔╔╦╗┬─┐┌─┐ ┌─┐┬ ┬┌─┐┬─┐┬─┐┬┌┬┐┌─┐
// ║╣ ╚═╗║ ║║║║ ║ ├┬┘│ │ │└┐┌┘├┤ ├┬┘├┬┘│ ││├┤
// o╚═╝╚═╝╩═╝╩╝╚╝ ╩ ┴└─└─┘ └─┘ └┘ └─┘┴└─┴└─┴─┴┘└─┘
// ┌─ ┌─┐┌─┐┬─┐ ┌┐ ┬─┐┌─┐┬ ┬┌─┐┌─┐┬─┐ ┬┌─┐ ┌─┐┌─┐┌─┐┌─┐┌┬┐┌─┐ ─┐
// │ ├┤ │ │├┬┘ ├┴┐├┬┘│ ││││└─┐├┤ ├┬┘ │└─┐ ├─┤└─┐└─┐├┤ │ └─┐ │
// └─ └ └─┘┴└─ └─┘┴└─└─┘└┴┘└─┘└─┘┴└─ └┘└─┘ ┴ ┴└─┘└─┘└─┘ ┴ └─┘ ─┘
// > An .eslintrc configuration override for use in the `assets/` directory.
//
// This extends the top-level .eslintrc file, primarily to change the set of
// supported globals, as well as any other relevant settings. (Since JavaScript
// code in the `assets/` folder is intended for the browser habitat, a different
// set of globals is supported. For example, instead of Node.js/Sails globals
// like `sails` and `process`, you have access to browser globals like `window`.)
//
// (See .eslintrc in the root directory of this Sails app for more context.)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
"extends": [
"../.eslintrc"
],
"env": {
"browser": true,
"node": false
},
"parserOptions": {
"ecmaVersion": 8
//^ If you are not using a transpiler like Babel, change this to `5`.
},
"globals": {
// Allow any window globals you're relying on here; e.g.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
"SAILS_LOCALS": true,
"io": true,
"Cloud": true,
"parasails": true,
"$": true,
"_": true,
"bowser": true,
"StripeCheckout": true,
"Stripe": true,
"Vue": true,
"VueRouter": true,
"moment": true,
// "google": true,
// ...etc.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Make sure backend globals aren't indadvertently tolerated in our client-side JS:
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
"sails": false,
"User": false
// ...and any other backend globals (e.g. `"Organization": false`)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
website/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,19 @@
/**
* cloud.setup.js
*
* Configuration for this Sails app's generated browser SDK ("Cloud").
*
* Above all, the purpose of this file is to provide endpoint definitions,
* each of which corresponds with one particular route+action on the server.
*
* > This file was automatically generated.
* > (To regenerate, run `sails run rebuild-cloud-sdk`)
*/
Cloud.setup({
/* eslint-disable */
methods: {"confirmEmail":{"verb":"GET","url":"/email/confirm","args":["token"]},"logout":{"verb":"GET","url":"/api/v1/account/logout","args":[]},"updatePassword":{"verb":"PUT","url":"/api/v1/account/update-password","args":["password"]},"updateProfile":{"verb":"PUT","url":"/api/v1/account/update-profile","args":["fullName","emailAddress"]},"updateBillingCard":{"verb":"PUT","url":"/api/v1/account/update-billing-card","args":["stripeToken","billingCardLast4","billingCardBrand","billingCardExpMonth","billingCardExpYear"]},"login":{"verb":"PUT","url":"/api/v1/entrance/login","args":["emailAddress","password","rememberMe"]},"signup":{"verb":"POST","url":"/api/v1/entrance/signup","args":["emailAddress","password","fullName"]},"sendPasswordRecoveryEmail":{"verb":"POST","url":"/api/v1/entrance/send-password-recovery-email","args":["emailAddress"]},"updatePasswordAndLogin":{"verb":"POST","url":"/api/v1/entrance/update-password-and-login","args":["password","token"]},"deliverContactFormMessage":{"verb":"POST","url":"/api/v1/deliver-contact-form-message","args":["emailAddress","topic","fullName","message"]}}
/* eslint-enable */
});

View File

@ -0,0 +1,69 @@
/**
* <ajax-button>
* -----------------------------------------------------------------------------
* A button with a built-in loading spinner.
*
* @type {Component}
*
* @event click [emitted when clicked]
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('ajaxButton', {
// ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╩╚═╚═╝╩ ╚═╝
props: [
'syncing'
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
//…
};
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<button @click="click()" type="submit" class="btn ajax-button" :class="[syncing ? 'syncing' : '']">
<span class="button-text" v-if="!syncing"><slot name="default">Submit</slot></span>
<span class="button-loader clearfix" v-if="syncing">
<slot name="syncing-state">
<span style="top: -4px; font-size: 12px; margin: 0 2px;" class="loading-dot dot1 position-relative"><small><span class="fa fa-circle"></span></small></span>
<span style="top: -4px; font-size: 12px; margin: 0 2px;" class="loading-dot dot2 position-relative"><small><span class="fa fa-circle"></span></small></span>
<span style="top: -4px; font-size: 12px; margin: 0 2px;" class="loading-dot dot3 position-relative"><small><span class="fa fa-circle"></span></small></span>
<span style="top: -4px; font-size: 12px; margin: 0 2px;" class="loading-dot dot4 position-relative"><small><span class="fa fa-circle"></span></small></span>
</slot>
</span>
</button>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function(){
//…
},
beforeDestroy: function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
click: async function(){
this.$emit('click');
},
}
});

View File

@ -0,0 +1,379 @@
/**
* <ajax-form>
* -----------------------------------------------------------------------------
* A form that talks to the backend using AJAX.
* > For example usage, take a look at one of the forms generated in a new
* > Sails app when using the "Web app" template.
*
* @type {Component}
*
* @slot default [form contents]
*
* @event update:cloudError [:cloud-error.sync="…"]
* @event update:syncing [:syncing.sync="…"]
* @event update:formErrors [:form-errors.sync="…"]
* @event submitted [emitted after the server responds with a 2xx status code]
* @event rejected [emitted after the server responds with a non-2xx status code]
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('ajaxForm', {
// ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╩╚═╚═╝╩ ╚═╝
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Note:
// Some of these props rely on the `.sync` modifier re-introduced in Vue 2.3.x.
// For more info, see: https://vuejs.org/v2/guide/components.html#sync-Modifier
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
props: [
'syncing',// « 2-way bound (:syncing.sync="…")
'cloudError',// « 2-way bound (:cloud-error.sync="…")
'action',
'formErrors',// « 2-way bound (:form-errors.sync="…")
'formData',
'formRules',
'handleSubmitting',// « alternative for `action`
'handleParsing',// « alternative for `formRules`+`formData`+`formErrors`
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
//…
};
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<form class="ajax-form" @submit.prevent="submit()" @keydown.meta.enter="keydownMetaEnter()">
<slot name="default"></slot>
</form>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function (){
if (this.action === undefined && this.handleSubmitting === undefined) {
throw new Error('Neither `:action` nor `:handle-submitting` was passed in to <ajax-form>, but one or the other must be provided.');
} else if (this.action !== undefined && this.handleSubmitting !== undefined) {
throw new Error('Both `:action` AND `:handle-submitting` were passed in to <ajax-form>, but only one or the other should be provided.');
} else if (this.action !== undefined && (!_.isString(this.action) || !_.isFunction(Cloud[_.camelCase(this.action)]))) {
throw new Error('Invalid `action` in <ajax-form>. `action` should be the name of a method on the `Cloud` global. For example: `action="login"` would make this form communicate using `Cloud.login()`, which corresponds to the "login" action on the server.');
} else if (this.action !== undefined && !_.isFunction(Cloud[this.action])) {
throw new Error('Unrecognized `action` in <ajax-form>. Did you mean to type `action="'+_.camelCase(this.action)+'"`? (<ajax-form> expects `action` to be provided in camelCase format. In other words, to reference the action at "api/controllers/foo/bar/do-something", use `action="doSomething"`.)');
} else if (this.handleSubmitting !== undefined && !_.isFunction(this.handleSubmitting)) {
throw new Error('Invalid `:handle-submitting` function passed to <ajax-form>. (Any chance you forgot the ":" in front of the prop name?) For example: `:handle-submitting="handleSubmittingSomeForm"`. This function should be an `async function`, and it should either throw a special exit signal or return response data from the server. (If this custom `handleSubmitting` will be doing something more complex than a single request to a server, feel free to return whatever amalgamation of data you wish.)');
}
if (this.handleParsing === undefined && this.formData === undefined) {
throw new Error('Neither `:form-data` nor `:handle-parsing` was passed in to <ajax-form>, but one or the other must be provided.');
} else if (this.handleParsing !== undefined && this.formData !== undefined) {
throw new Error('Both `:form-data` AND `:handle-parsing` were passed in to <ajax-form>, but only one or the other should be provided.');
} else if (this.handleParsing !== undefined && !_.isFunction(this.handleParsing)) {
throw new Error('Invalid `:handle-parsing` function passed to <ajax-form>. (Any chance you forgot the ":" in front of the prop name?) For example: `:handle-parsing="handleParsingSomeForm"`. This function should return a dictionary (plain JavaScript object like `{}`) of parsed form data, ready to be sent in a request to the server.');
} else if (this.formData !== undefined && (!_.isObject(this.formData) || _.isFunction(this.formData) || _.isArray(this.formData))) {
throw new Error('Invalid `:form-data` passed to <ajax-form>. (Any chance you forgot the ":" in front of the prop name?) For example: `:form-data="someFormData"`. This should reference a dictionary (plain JavaScript object like `{}`). Specifically, `:form-data` should only be used in the case where the raw data from the form in the user interface happens to correspond **EXACTLY** with the names and format of the argins that should be sent in a request to the server. (For more nuanced behavior, use `handle-parsing` instead!)');
}
if (!this.formData && (this.formRules || this.formErrors)) {
throw new Error('If `:form-rules` or `:form-errors.sync` are in use, then `:form-data` must also be passed in. (If the AJAX request doesn\'t need form data, then use an empty dictionary, i.e. `:form-data="{}"`.)');
} else if (this.formRules && !this.formErrors) {
throw new Error('If `:form-rules` are provided, then `:form-errors.sync` must also be passed in.');
}
if (this.formRules) {
var SUPPORTED_RULES = [
'required', 'isEmail', 'isIn', 'is', 'minLength', 'maxLength',
'sameAs', 'isHalfwayDecentPassword', 'custom'
];
for (let fieldName in this.formRules) {
for (let ruleName in this.formRules[fieldName]) {
if (_.contains(SUPPORTED_RULES, ruleName)) {
// OK. Good enough.
// - - - - - - - - - - - - - - - - - - - - -
// FUTURE: move rule rhs checks out here
// (so error messages from bad usage are
// logged sooner)
// - - - - - - - - - - - - - - - - - - - - -
} else {
let kebabRules = _.map(_.clone(SUPPORTED_RULES), (ruleName)=>_.kebabCase(ruleName));
let lowerCaseRules = _.map(_.clone(SUPPORTED_RULES), (ruleName)=>ruleName.toLowerCase());
let ruleIdx = (
_.indexOf(kebabRules, ruleName) === -1 ?
_.indexOf(lowerCaseRules, ruleName.toLowerCase()) === -1 ?
-1
: _.indexOf(lowerCaseRules, ruleName.toLowerCase())
: _.indexOf(kebabRules, ruleName)
);
if (ruleIdx !== -1) {
throw new Error('Did you mean `'+SUPPORTED_RULES[ruleIdx]+'`? (note the capitalization)\nYou are seeing this error because <ajax-form> encountered an unsupported (but vaguely familiar-looking) client-side validation rule: `'+ruleName+'`.');
} else {
throw new Error('<ajax-form> does not support that client-side validation rule (`'+ruleName+'`).\n [?] If you\'re unsure, visit https://sailsjs.com/support');
}
}
}//∞
}//∞
}
// Focus our "focus-first" field, if relevant.
// (but not on mobile, because it can get weird)
if(typeof bowser !== 'undefined' && !bowser.mobile && this.$find('[focus-first]').length > 0) {
this.$focus('[focus-first]');
}
},
beforeDestroy: function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
keydownMetaEnter: async function() {
await this._submit();
},
submit: async function () {
await this._submit();
},
// ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
// ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
// ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
_submit: async function () {
// Prevent double-posting.
if (this.syncing) {
return;
}//•
// Clear the userland "cloudError" prop.
this.$emit('update:cloudError', '');
// Determine the argins that will be sent to the server in our request.
var argins;
if (this.handleParsing) {
// Run the provided "handle-parsing" logic.
// > This should clear out any pre-existing error messages, perform any additional
// > client-side form validation checks, and do any necessary data transformations
// > to munge the form data into the format expected by the server.
argins = this.handleParsing();
if (argins === undefined) {
// If argins came back undefined, then avast.
// (This means that parsing the form failed. Submission will not be attempted.)
return;
} else if (!_.isObject(argins) || _.isArray(argins) || _.isFunction(argins)) {
throw new Error('Invalid data returned from custom form parsing logic. (Should return a dictionary of argins, like `{}`.)');
}//•
} else if (this.formData) {
// Or use the simpler, built-in absorbtion strategy.
// > This uses the provided form data as our argins, verbatim. Then it runs
// > built-in client-side validation, if configured to do so.
argins = this.formData;
let formData = this.formData;
let formErrors = {};
for (let fieldName in this.formRules) {
let fieldValue = formData[fieldName];
for (let ruleName in this.formRules[fieldName]) {
let ruleRhs = this.formRules[fieldName][ruleName];
let violation;
let isFieldValuePresent = (
fieldValue !== undefined &&
fieldValue !== '' &&
!_.isNull(fieldValue)
);
if (ruleName === 'required' && (ruleRhs === true || ruleRhs === false)) {
// ® Must be defined, non-null, and not the empty string
if (ruleRhs === false) {
violation = false;
} else {
violation = (
!isFieldValuePresent
);
}
} else if (!isFieldValuePresent) {
// Do nothing.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Note:
// In order to allow use with optional fields, all rules except for
// `required: true` are only actually checked when the field value
// is "present" -- i.e. some value other than `null`, `undefined`,
// or `''` (empty string).
//
// > Trying to figure out how to handle a conditionally-requiured
// > field that uses one of these validations? For example, a
// > "Confirm email" re-entry field for an optional email address?
// > Just make `required` validation rule dynamic, and everything
// > else will work as expected.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
} else if (ruleName === 'isEmail' && (ruleRhs === true || ruleRhs === false)) {
// ® Must be an email address (unless falsy)
if (ruleRhs === false) {
violation = false;
} else {
violation = (
!parasails.util.isValidEmailAddress(fieldValue)
);
}
} else if (ruleName === 'isIn' && _.isArray(ruleRhs)) {
// ® Must be one of these things
violation = (
!_.contains(ruleRhs, fieldValue)
);
} else if (ruleName === 'is') {
// ® Must be exactly this thing (useful for required checkboxes)
violation = (
ruleRhs !== fieldValue
);
} else if (ruleName === 'minLength' && _.isNumber(ruleRhs)) {
// ® Must consist of at least this many characters
violation = (
!_.isString(fieldValue) ||
fieldValue.length < ruleRhs
);
} else if (ruleName === 'maxLength' && _.isNumber(ruleRhs)) {
// ® Must consist of no more than this many characters
violation = (
!_.isString(fieldValue) ||
fieldValue.length > ruleRhs
);
} else if (ruleName === 'sameAs' && ruleRhs !== '' && _.isString(ruleRhs)) {
// ® Must match the value in another field
let otherFieldName = ruleRhs;
let otherFieldValue = formData[otherFieldName];
violation = (
otherFieldValue !== fieldValue
);
} else if (ruleName === 'isHalfwayDecentPassword' && (ruleRhs === true || ruleRhs === false)) {
// ® Must be a halfway-decent password
// > This is an arbitrary distinction, so change it if you want.
// > Just... please use common sense. And try to avoid engaging
// > in security theater.
if (ruleRhs === false) {
violation = false;
} else {
violation = (
(!_.isString(fieldValue) && !_.isNumber(fieldValue)) ||
fieldValue.length < 6
);
}
} else if (ruleName === 'custom' && _.isFunction(ruleRhs)) {
// ® Provided function must return truthy when invoked with the value.
try {
violation = (
!ruleRhs(fieldValue)
);
} catch (err) {
console.warn(err);
violation = true;
}
} else {
throw new Error('Cannot interpret client-side validation rule (`'+ruleName+'`) because the configuration provided for it is not recognized by <ajax-form>.\n [?] If you\'re unsure, visit https://sailsjs.com/support');
}
// If a rule violation was detected, then set it as a form error
// and break out of the `for` loop to continue on to the next field.
// (We only track one form error per field.)
if (violation) {
formErrors[fieldName] = ruleName;
break;
}//˚
}//∞
}//∞
// Whether there are any errors or not, update userland "formErrors" prop
// so that the markup reflects the new reality (i.e. inline validation errors
// either get rendered or go away.)
this.$emit('update:formErrors', formErrors);
// If there were any form errors, avast. (Submission will not be attempted.)
if (Object.keys(formErrors).length > 0) {
// In development mode, also log a warning
// (so that it's clear what's going on just in case validation
// states/messages are not hooked up in the HTML template)
if (this._environment !== 'production') {
console.warn(`<ajax-form> encountered ${Object.keys(formErrors).length} form error${Object.keys(formErrors).length !== 1 ? 's' : ''} when performing client-side validation of "form-data" versus "form-rules". (Note: This warning is only here to assist with debugging-- it will not be displayed in production. If you're unsure, check out https://sailsjs.com/support for more resources.)`, _.cloneDeep(formErrors));
}//fi
return;
}//•
}//fi (determining argins)
// Set syncing state to `true` on userland "syncing" prop.
this.$emit('update:syncing', true);
// Submit the form
var failedWithCloudExit;
var rawErrorFromCloudSDK;
var result;
if (this.handleSubmitting) {
try {
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: Consider cloning the argins ahead of time to prevent accidental mutation of form data.
// (but remember argins could contain File instances that might not be clone-able)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
result = await this.handleSubmitting(argins);
} catch (err) {
rawErrorFromCloudSDK = err;
if (_.isString(err) && err !== '') {
failedWithCloudExit = err;
} else if (_.isError(err) && err.exit) {
failedWithCloudExit = err.exit;
} else if (_.isObject(err) && !_.isError(err) && !_.isArray(err) && !_.isFunction(err) && Object.keys(err)[0] && _.isString(Object.keys(err)[0])) {
failedWithCloudExit = Object.keys(err)[0];
} else {
throw err;
}
}
} else {
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// FUTURE: Potentially filter unused data in argins here before proceeding
// (assuming cloudsdk has that information available)
// Or better yet, just have `Cloud.*.with()` take care of that automatically.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
result = await Cloud[this.action].with(argins)
.tolerate((err)=>{
rawErrorFromCloudSDK = err;
failedWithCloudExit = err.exit || 'error';
});
}
// When a cloud error occurs, tolerate it, but set the userland "cloudError"
// prop accordingly.
if (failedWithCloudExit) {
this.$emit('update:cloudError', failedWithCloudExit);
}
// Set syncing state to `false` on userland "syncing" prop.
this.$emit('update:syncing', false);
// If the server says we were successful, then emit the "submitted" event.
if (!failedWithCloudExit) {
this.$emit('submitted', result);
} else {
this.$emit('rejected', rawErrorFromCloudSDK);
}
},
}
});

View File

@ -0,0 +1,93 @@
/**
* <cloud-error>
* -----------------------------------------------------------------------------
*
* @type {Component}
*
* --- SLOTS: ---
* @slot default
*
* --- EVENTS EMITTED: ---
* N/A
*
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('cloud-error', {
// ╔═╗╦ ╦╔╗ ╦ ╦╔═╗ ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝║ ║╠╩╗║ ║║ ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╚═╝╚═╝╩═╝╩╚═╝ ╩ ╩╚═╚═╝╩ ╚═╝
props: [
'withoutMargins'
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╦╔╗╔╔╦╗╔═╗╦═╗╔╗╔╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ║║║║ ║ ║╣ ╠╦╝║║║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╩╝╚╝ ╩ ╚═╝╩╚═╝╚╝╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function () {
return {
beWithoutMargins: undefined,
};
},
beforeMount: function() {
if (this.withoutMargins !== undefined && typeof this.withoutMargins !== 'boolean') {
throw new Error('<cloud-error> received an invalid `withoutMargins`. If provided, this prop should be precisely true or false.');
}
this.beWithoutMargins = this.withoutMargins||false;
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<div>
<p :class="{ 'm-0': beWithoutMargins }" class="text-danger"><slot name="default">An error occured while processing your request. Please check your information and try again, or <a href="/contact">contact support</a> if the error persists.</slot></p>
</div>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
mounted: async function () {
},
watch: {
withoutMargins: function(unused) { throw new Error('Changes to `withoutMargins` are not currently supported in <cloud-error>!'); },
},
beforeDestroy: function() {
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
// ╦╔╗╔╔╦╗╔═╗╦═╗╔╗╔╔═╗╦ ╔═╗╦ ╦╔═╗╔╗╔╔╦╗ ╦ ╦╔═╗╔╗╔╔╦╗╦ ╔═╗╦═╗╔═╗
// ║║║║ ║ ║╣ ╠╦╝║║║╠═╣║ ║╣ ╚╗╔╝║╣ ║║║ ║ ╠═╣╠═╣║║║ ║║║ ║╣ ╠╦╝╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╝╚╝╩ ╩╩═╝ ╚═╝ ╚╝ ╚═╝╝╚╝ ╩ ╩ ╩╩ ╩╝╚╝═╩╝╩═╝╚═╝╩╚═╚═╝
//…
// ╔═╗╦ ╦╔╗ ╦ ╦╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
// ╠═╝║ ║╠╩╗║ ║║ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
// ╩ ╚═╝╚═╝╩═╝╩╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
// > Public methods are rarely exposed by Vue components, but sometimes they
// > are an important escape hatch. They are callable via something like
// > `this.$refs.componentNameInCamelCase.doSomething())`, and, by convention,
// > are always prefixed with "do".
// N/A
// ╔═╗╦═╗╦╦ ╦╔═╗╔╦╗╔═╗ ╔╦╗╔═╗╔╦╗╦ ╦╔═╗╔╦╗╔═╗
// ╠═╝╠╦╝║╚╗╔╝╠═╣ ║ ║╣ ║║║║╣ ║ ╠═╣║ ║ ║║╚═╗
// ╩ ╩╚═╩ ╚╝ ╩ ╩ ╩ ╚═╝ ╩ ╩╚═╝ ╩ ╩ ╩╚═╝═╩╝╚═╝
//…
}
});

View File

@ -0,0 +1,130 @@
/**
* <js-timestamp>
* -----------------------------------------------------------------------------
* A human-readable, self-updating "timeago" timestamp, with some special rules:
*
* Within 24 hours, displays in "timeago" format.
* Within a month, displays month, day, and time of day.
* Within a year, displays just the month and day.
* Older/newer than that, displays the month and day with the full year.
*
* @type {Component}
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('jsTimestamp', {
// ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╩╚═╚═╝╩ ╚═╝
props: [
'at',// « The JS timestamp to format
'short',// « Whether to shorten the formatted date by not including the time of day (may only be used with timeago, and even then only applicable in certain situations)
'format',// « one of: 'calendar', 'timeago' (defaults to 'timeago'. Otherwise, the "calendar" format displays data as US-style calendar dates with a four-character year, separated by dashes. In other words: "MM-DD-YYYY")
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
formatType: undefined,
formattedTimestamp: '',
interval: undefined
};
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<span>{{formattedTimestamp}}</span>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
if (this.at === undefined) {
throw new Error('Incomplete usage of <js-timestamp>: Please specify `at` as a JS timestamp (i.e. epoch ms, a number). For example: `<js-timestamp :at="something.createdAt">`');
}
if(this.format === undefined) {
this.formatType = 'timeago';
} else {
if(!_.contains(['calendar', 'timeago'], this.format)) { throw new Error('Unsupported `format` ('+this.format+') passed in to the JS timestamp component! Must be either \'calendar\' or \'timeago\'.'); }
this.formatType = this.format;
}
// If timeago timestamp, update the timestamp every minute.
if(this.formatType === 'timeago') {
this._formatTimeago();
this.interval = setInterval(async()=>{
try {
this._formatTimeago();
await this.forceRender();
} catch (err) {
err.raw = err;
throw new Error('Encountered unexpected error while attempting to automatically re-render <js-timestamp> in the background, as the seconds tick by. '+err.message);
}
},60*1000);//œ
}
// If calendar timestamp, just set it the once.
// (Also don't allow usage with `short`.)
if(this.formatType === 'calendar') {
this.formattedTimestamp = moment(this.at).format('MM-DD-YYYY');
if (this.short) {
throw new Error('Invalid usage of <js-timestamp>: Cannot use `short="true"` at the same time as `format="calendar"`.');
}
}
},
beforeDestroy: function() {
if(this.formatType === 'timeago') {
clearInterval(this.interval);
}
},
watch: {
at: function() {
// Render to account for after-mount programmatic changes to `at`.
if(this.formatType === 'timeago') {
this._formatTimeago();
} else if(this.formatType === 'calendar') {
this.formattedTimestamp = moment(this.at).format('MM-DD-YYYY');
} else {
throw new Error();
}
}
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
_formatTimeago: function() {
var now = new Date().getTime();
var timeDifference = Math.abs(now - this.at);
// If the timestamp is less than a day old, format as time ago.
if(timeDifference < 1000*60*60*24) {
this.formattedTimestamp = moment(this.at).fromNow();
} else {
// If the timestamp is less than a month-ish old, we'll include the
// time of day in the formatted timestamp.
let includeTime = !this.short && timeDifference < 1000*60*60*24*31;
// If the timestamp is from a different year, we'll include the year
// in the formatted timestamp.
let includeYear = moment(now).format('YYYY') !== moment(this.at).format('YYYY');
this.formattedTimestamp = moment(this.at).format('MMMM DD'+(includeYear ? ', YYYY' : '')+(includeTime ? ' [at] h:mma' : ''));
}
}
}
});

View File

@ -0,0 +1,226 @@
/**
* <modal>
* -----------------------------------------------------------------------------
* A modal dialog pop-up.
*
* > Be careful adding other Vue.js lifecycle callbacks in this file! The
* > finnicky combination of Vue transitions and bootstrap modal animations used
* > herein work, and are very well-tested in practical applications. But any
* > changes to that specific cocktail could be unpredictable, with unsavory
* > consequences.
*
* @type {Component}
*
* @event close [emitted when the closing process begins]
* @event opened [emitted when the opening process is completely done]
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('modal', {
// ╔═╗╦═╗╔═╗╔═╗╔═╗
// ╠═╝╠╦╝║ ║╠═╝╚═╗
// ╩ ╩╚═╚═╝╩ ╚═╝
props: [
'hideCloseButton'//« removes the default "x" button
],
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
// Spinlock used for preventing trying to close the bootstrap modal more than once.
// (in practice it doesn't seem to hurt anything if it tries to close more than once,
// but still.... better safe than sorry!)
_bsModalIsAnimatingOut: false,
isMobileSafari: false,//« more on this below
originalScrollPosition: undefined,//« more on this below
};
},
// ╦ ╦╔╦╗╔╦╗╦
// ╠═╣ ║ ║║║║
// ╩ ╩ ╩ ╩ ╩╩═╝
template: `
<transition name="modal" v-on:leave="leave" v-bind:css="false">
<div class="modal fade" tabindex="-1" role="dialog">
<div class="petticoat"></div>
<div class="modal-dialog custom-width position-relative" role="document" purpose="modal-dialog">
<div class="modal-content" purpose="modal-content">
<button type="button" style="top: 5px; right: 0; font-size: 28px; line-height: 1;" class="py-2 px-3 position-absolute" data-dismiss="modal" aria-label="Close" purpose="modal-close-button" v-if="!hideCloseButton">&times;</button>
<slot></slot>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</transition>
`,
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
// If this is mobile safari, make note of it.
this.isMobileSafari = (typeof bowser !== 'undefined') && bowser.mobile && bowser.safari;
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// ^^So there's a bug in mobile safari that misplaces the caret when the keyboard opening
// causes the page to scroll, so we need to do some special tricks to keep it from getting ugly.
// It's only in iOS 11... we think. Hopefully it will be fixed.
// In the mean time, we have to get wacky.
//
// > More info about the bug here:
// > https://github.com/twbs/bootstrap/issues/24835#issuecomment-345974819
// > https://stackoverflow.com/questions/46567233/how-to-fix-the-ios-11-input-element-in-fixed-modals-bug?rq=1
//
// FUTURE: maybe the bug will be fixed and we can remove this someday?
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
if(this.isMobileSafari) {
// Get our original scroll position before opening the modal and save it for later.
this.originalScrollPosition = $(window).scrollTop();
}
},
mounted: function(){
// ^^ Note that this is not an `async function`.
// This is just to be safe, since the timing here is a little tricky w/ the
// animations and the fact that we're integrating with Bootstrap's modal.
// (That said, it might work fine-- just hasn't been extensively tested.)
// Immediately call out to the Bootstrap modal and tell it to show itself.
$(this.$el).modal({
// Set the modal backdrop to the 'static' option, which means it doesn't close the modal
// when clicked.
backdrop: 'static',
show: true
});
// Attach listener for underlying custom modal closing event,
// and when that happens, have Vue emit a custom "close" event.
// (Note: This isn't just for convenience-- it's crucial that
// the parent logic can use this event to update its scope.)
$(this.$el).on('hide.bs.modal', ()=>{
// Undo any mobile safari workarounds we may have added.
// (i.e. shed the wackiness)
if(this.isMobileSafari) {
// Remove style overrides on our modal dialog.
$(this.$el).css({
'overflow-y': '',
'position': '',
'left': '',
'top': '',
});
// Beckon to our siblings so they come out of hiding
this.$get().parent().children().not(this.$el).css({
'display': ''
});
// Scroll to our original position when the modal was summoned.
window.scrollTo(0, this.originalScrollPosition);
}//fi
this._bsModalIsAnimatingOut = true;
this.$emit('close');
});//œ
// Attach listener for underlying custom modal "opened" event,
// and when that happens, have Vue emit our own custom "opened" event.
// This is so we know when the entry animation has completed, allows
// us to do cool things like auto-focus the first input in a form modal.
$(this.$el).on('shown.bs.modal', ()=>{
// If this is mobile safari, let's get wacky.
if(this.isMobileSafari) {
// Scroll to the top of the page.
window.scrollTo(0, 0);
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// ^^FUTURE: Don't actually do this -- instead, try setting `top` of the
// modal to whatever the original scrollTop of our window was. This
// eliminates the need for auto-scrolling to the top and ripping you out
// of the context you were in before the modal opens. It would also allow
// us to keep the nice animation when opening/closing modals on iOS.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Hide siblings to lop off any extra space at the bottom.
this.$get().parent().children().not(this.$el).css({
'display': 'none'
});
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// ^^FUTURE: Instead of just hiding siblings, which isn't perfect and won't
// always work for everyone, try grabbing outerHeight of the modal element
// and using that to set an explicit height for the body.
// (but also be sure to handle the case where the body is short!)
// But for now, this should work as long as we have sticky footer styles.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
// Hard code some style overrides on our modal dialog.
// Without these, it gets weird.
$(this.$el).css({
'overflow-y': 'auto!important',
'position': 'absolute',
'left': '0',
'top': '0',
});
}//fi
// Focus our "focus-first" field, if relevant.
// (but not on mobile, because it can get weird)
if(typeof bowser !== 'undefined' && !bowser.mobile && this.$find('[focus-first]').length > 0) {
this.$focus('[focus-first]');
}
this.$emit('opened');
$(this.$el).off('shown.bs.modal');
});//ƒ
},
// ^Note that there is no `beforeDestroy()` lifecycle callback in this
// component. This is on purpose, since the timing vs. `leave()` gets tricky.
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
leave: function (el, done) {
// > Note: This function signature comes from Vue.js's transition system.
// > It will likely be replaced with `async function (el){…}` in a future
// > release of Vue/Sails.js (i.e. no callback argument).
// If this shutting down was spawned by the bootstrap modal's built-in logic,
// then we'll have already begun animating the modal shut. So we check our
// spinlock to make sure. If it turns out that we HAVEN'T started that process
// yet, then we go ahead and start it now.
if (!this._bsModalIsAnimatingOut) {
$(this.$el).modal('hide');
}//fi
// When the bootstrap modal finishes animating into nothingness, unbind all
// the DOM events used by bootstrap, and then call `done()`, which passes
// control back to Vue and lets it finish the job (i.e. afterLeave).
//
// > Note that the other lifecycle events like `destroyed` were actually
// > already fired at this point.
// >
// > Also note that, since we're potentially long past the `destroyed` point
// > of the lifecycle here, we can't call `.$emit()` anymore either. So,
// > for example, we wouldn't be able to emit a "fullyClosed" event --
// > because by the time it'd be appropriate to emit the Vue event, our
// > context for triggering it (i.e. the relevant instance of this component)
// > will no longer be capable of emitting custom Vue events (because by then,
// > it is no longer "reactive").
// >
// > For more info, see:
// > https://github.com/vuejs/vue-router/issues/1302#issuecomment-291207073
$(this.$el).on('hidden.bs.modal', ()=>{
$(this.$el).off('hide.bs.modal');
$(this.$el).off('hidden.bs.modal');
$(this.$el).off('shown.bs.modal');
done();
});//_∏_
},
}
});

View File

@ -0,0 +1,192 @@
/**
* <stripe-card-element>
* -----------------------------------------------------------------------------
* A wrapper for the Stripe Elements "card" component (https://stripe.com/elements)
*
* @type {Component}
*
* @event update:busy [:busy.sync="…"]
* @event input [emitted when the stripe token changes (supports v-model)]
* @event invalidated [emitted when the field had been changed to include an invalid value]
* @event validated [emitted when the field had been changed to include a valid value]
* -----------------------------------------------------------------------------
*/
parasails.registerComponent('stripeCardElement', {
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔═╗╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠╣ ╠═╣║ ║╣
// ╩╝╚╝ ╩ ╚═╝╩╚═╚ ╩ ╩╚═╝╚═╝
props: [
'stripePublishableKey',
'isErrored',
'errorMessage',//« optional custom error message to display
'value',//« the v-model passed in.
'busy',
'showExisting',// « whether to show the existing card info passed into `v-model`
],
// ╔╦╗╔═╗╦═╗╦╔═╦ ╦╔═╗
// ║║║╠═╣╠╦╝╠╩╗║ ║╠═╝
// ╩ ╩╩ ╩╩╚═╩ ╩╚═╝╩
template: `
<div>
<div v-if="existingCardData">
<span class="existing-card">{{existingCardData.billingCardBrand}} ending in <strong>{{existingCardData.billingCardLast4}}</strong></span>
<small class="new-card-text d-inline-block ml-2">(Want to use a different card ? <a class="text-primary change-card-button" type="button" @click="clickChangeExistingCard()">Click here</a>.)</small>
</div>
<div class="card-element-wrapper" :class="[existingCardData ? 'secret-card-element-wrapper' : '', isErrored ? 'is-invalid' : '']" :aria-hidden="existingCardData ? true : false">
<div class="card-element form-control" :class="isErrored ? 'is-invalid' : ''" card-element></div>
<span class="status-indicator syncing text-primary" :class="[isSyncing ? '' : 'hidden']"><span class="fa fa-spinner"></span></span>
<span class="status-indicator text-primary" :class="[isValidated ? '' : 'hidden']"><span class="fa fa-check-circle"></span></span>
<div class="invalid-feedback" v-if="!isValidated && isErrored">{{ errorMessage || 'Please check your card info.'}}</div>
</div>
</div>
`,
// ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ╚═╗ ║ ╠═╣ ║ ║╣
// ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: function (){
return {
isSyncing: false,
isValidated: false,
existingCardData: undefined,
// The underlying Stripe instance
_stripe: undefined,
// The underlying Stripe elements instance
_elements: undefined,
// The underlying Stripe element instance this component creates as it mounts.
_element: undefined,
};
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
// Initialize an instance of Stripe "elements", which we'll pass into
// our <stripe-element> component instances.
// (We also save an instance of `stripe` for use below.)
this._stripe = Stripe(this.stripePublishableKey);
this._elements = this._stripe.elements();
},
mounted: function (){
if(this.showExisting && _.isObject(this.value) && this.value.stripeToken && this.value.billingCardBrand && this.value.billingCardLast4) {
this.existingCardData = _.extend({}, this.value);
}
this._element = this._elements.create('card', {
// Classes
// > https://stripe.com/docs/js/elements_object/create_element?type=card#elements_create-options-classes
classes: {
// When the iframe is focused, attach the "pseudofocused" class
// to our wrapper <div>.
focus: 'pseudofocused'
},
// iframe styles:
// > https://stripe.com/docs/js/appendix/style?type=card
// You can update this section to match your website's theme
style: {
base: {
lineHeight: '36px',
fontSize: '16px',
color: '#495057',
iconColor: '#14acc2',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
'::placeholder': {
color: '#6c757d',
},
},
invalid: {
color: '#dc3545',
},
},
});
this._element.mount($(this.$el).find('[card-element]')[0]);
// When a change occurs, immediately clear the token by
// emitting an input event with '', show a loading spinner, and start
// fetching a new token. Then in userland, the validation error for a missing
// card becomes something reasonable that implies that we may not have finished
// getting it yet, so hold your horses.
this._element.on('change', (stripeEvent)=> {
// If there is an error, set the v-model to be empty.
if(stripeEvent.error) {
this.$emit('input', '');
} else if(stripeEvent.complete) {
// If the field is complete, (aka valid), fetch a token and set that on the v-model
// (first clearing out the v-model, so this won't be considered valid yet e.g. if it was just changed.
if(this.isSyncing) { return; }
this.$emit('');
this.isSyncing = true;
this.$emit('update:busy', true);
this.isValidated = false;
this._fetchNewToken();
} else {
// FUTURE: possibly handle other events, if necessary.
}//fi
});//œ
},
beforeDestroy: function (){
// Note: There isn't any documented way to tear down a `stripe` instance.
// Same thing for the `elements` instance. Only individual "element" instances
// can be cleaned up after, using `.unmount()`.
this._element.unmount();
},
// ╔═╗╦ ╦╔═╗╔╗╔╔╦╗╔═╗
// ║╣ ╚╗╔╝║╣ ║║║ ║ ╚═╗
// ╚═╝ ╚╝ ╚═╝╝╚╝ ╩ ╚═╝
methods: {
clickChangeExistingCard: function() {
this.existingCardData = undefined;
this.$emit('input', '');
},
// Public method for fetching a fresh token (e.g. if card is declined)
doGetNewToken: function() {
this.isSyncing = true;
this.$emit('update:busy', true);
this.isValidated = false;
this.$emit('input', '');
this._fetchNewToken();
},
_fetchNewToken: function() {
this._getStripeTokenFromCardElement(this._stripe, this._element)
.then((paymentSourceInfo)=>{
this.isSyncing = false;
this.$emit('update:busy', false);
this.isValidated = true;
this.$emit('input', paymentSourceInfo);
}).catch((err)=>{
this.isSyncing = false;
this.$emit('update:busy', false);
this.isValidated = false;
// This error is only relevant if something COMPLETELY unexpected goes wrong,
// in which case we want to actually know about that.
throw err;
});//_∏_
},
_getStripeTokenFromCardElement: function(stripeInstance, stripeElement) {
// Build a Promise & send it back as our "thenable" (AsyncFunction's return value).
// (this is necessary b/c we're wrapping an api that isn't `await`-compatible)
return new Promise((resolve, reject)=>{
try {
// Create a stripe token using the Stripe "element".
stripeInstance.createToken(stripeElement)
.then((result)=>{
// Silently ignore the case where the field is empty, or if there are
// card validation issues.
if(!result || result.error) {
resolve();
return;
}
// Send back the token & payment info.
resolve({
stripeToken: result.token.id,
billingCardBrand: result.token.card.brand,
billingCardLast4: result.token.card.last4,
billingCardExpMonth: result.token.card.exp_month,
billingCardExpYear: result.token.card.exp_year
});
});
} catch (err) {
console.error('Could not obtain Stripe token:', err);
reject(err);
}
});//_∏_
}
}
});

View File

@ -0,0 +1,25 @@
parasails.registerPage('[id="498"]', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
//…
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function(){
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
//…
}
});

View File

@ -0,0 +1,106 @@
parasails.registerPage('account-overview', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
isBillingEnabled: false,
hasBillingCard: false,
// Syncing/loading states for this page.
syncingOpenCheckout: false,
syncingUpdateCard: false,
syncingRemoveCard: false,
// For <ajax-form>
formData: { /* … */ },
formRules: { /* … */ },
formErrors: { /* … */ },
cloudError: '',
syncing: '',
// For <modal>:
modal: '',
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function (){
_.extend(this, window.SAILS_LOCALS);
this.isBillingEnabled = !!this.stripePublishableKey;
// Determine whether there is billing info for this user.
this.me.hasBillingCard = (
this.me.billingCardBrand &&
this.me.billingCardLast4 &&
this.me.billingCardExpMonth &&
this.me.billingCardExpYear
);
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
clickUpdateBillingCardButton: function() {
this.modal = 'update-billing-card';
this.formData = { newPaymentSource: undefined };
this.formRules = { newPaymentSource: {required: true}};
},
closeModal: async function() {
// Dismiss modal
this.modal = '';
await this._resetForms();
},
handleSubmittingUpdateBillingCard: async function(argins) {
var newPaymentSource = argins.newPaymentSource;
await Cloud.updateBillingCard.with(newPaymentSource);
},
submittedUpdateBillingCard: async function() {
Object.assign(this.me, _.pick(this.formData.newPaymentSource, ['billingCardLast4', 'billingCardBrand', 'billingCardExpMonth', 'billingCardExpYear']));
this.me.hasBillingCard = true;
// Dismiss modal
this.modal = '';
await this._resetForms();
},
_resetForms: async function() {
this.cloudError = '';
this.formData = {};
this.formRules = {};
this.formErrors = {};
await this.forceRender();
},
clickRemoveCardButton: async function() {
this.modal = 'remove-billing-card';
this.formData.stripeToken = '';
},
submittedRemoveCardForm: async function() {
// Update billing info on success.
this.me.billingCardLast4 = undefined;
this.me.billingCardBrand = undefined;
this.me.billingCardExpMonth = undefined;
this.me.billingCardExpYear = undefined;
this.me.hasBillingCard = false;
// Close the modal and clear it out.
this.closeModal();
},
}
});

View File

@ -0,0 +1,50 @@
parasails.registerPage('edit-password', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
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: { /* … */ },
// Form rules
formRules: {
password: {required: true},
confirmPassword: {required: true, sameAs: 'password'},
},
// Server error state for the form
cloudError: '',
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
// Redirect to a different web page on success.
// > (Note that we re-enable the syncing state here. This is on purpose--
// > to make sure the spinner stays there until the page navigation finishes.)
this.syncing = true;
window.location = '/account';
},
}
});

View File

@ -0,0 +1,52 @@
parasails.registerPage('edit-profile', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
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: { /* … */ },
// Form rules
formRules: {
fullName: {required: true},
emailAddress: {required: true, isEmail: true},
},
// Server error state for the form
cloudError: '',
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
// Set the form data.
this.formData.fullName = this.me.fullName;
this.formData.emailAddress = this.me.emailChangeCandidate ? this.me.emailChangeCandidate : this.me.emailAddress;
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
// Redirect to the account page on success.
// > (Note that we re-enable the syncing state here. This is on purpose--
// > to make sure the spinner stays there until the page navigation finishes.)
this.syncing = true;
window.location = '/account';
},
}
});

View File

@ -0,0 +1,54 @@
parasails.registerPage('contact', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
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: { /* … */ },
// Form rules
formRules: {
emailAddress: {isEmail: true, required: true},
fullName: {required: true},
topic: {required: true},
message: {required: true},
},
// Server error state for the form
cloudError: '',
// Success state when form has been submitted
cloudSuccess: false,
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
submittedForm: async function() {
// Show the success message.
this.cloudSuccess = true;
},
}
});

View File

@ -0,0 +1,59 @@
parasails.registerPage('welcome', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
modal: '',
pageLoadedAt: Date.now()
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function() {
//…
},
// ╦ ╦╦╦═╗╔╦╗╦ ╦╔═╗╦ ╔═╗╔═╗╔═╗╔═╗╔═╗
// ╚╗╔╝║╠╦╝ ║ ║ ║╠═╣║ ╠═╝╠═╣║ ╦║╣ ╚═╗
// ╚╝ ╩╩╚═ ╩ ╚═╝╩ ╩╩═╝ ╩ ╩ ╩╚═╝╚═╝╚═╝
// Configure deep-linking (aka client-side routing)
virtualPagesRegExp: /^\/welcome\/?([^\/]+)?\/?/,
afterNavigate: async function(virtualPageSlug){
// `virtualPageSlug` is determined by the regular expression above, which
// corresponds with `:unused?` in the server-side route for this page.
switch (virtualPageSlug) {
case 'hello':
this.modal = 'example';
break;
default:
this.modal = '';
}
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
clickOpenExampleModalButton: async function() {
this.goto('/welcome/hello');
// Or, without deep links, instead do:
// ```
// this.modal = 'example';
// ```
},
closeExampleModal: async function() {
this.goto('/welcome');
// Or, without deep links, instead do:
// ```
// this.modal = '';
// ```
},
}
});

View File

@ -0,0 +1,25 @@
parasails.registerPage('confirmed-email', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
//…
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
// ║ ║╠╣ ║╣ ║ ╚╦╝║ ║ ║╣
// ╩═╝╩╚ ╚═╝╚═╝ ╩ ╚═╝╩═╝╚═╝
beforeMount: function() {
//…
},
mounted: async function(){
//…
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
//…
}
});

Some files were not shown because too many files have changed in this diff Show More