diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..3854769 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ + +docs/** +node_modules/** +coverage/** diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..35347aa --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,77 @@ + +{ + "env": { + "es6": true, + "jasmine": true, + "node": true, + "browser": true, + "jest": true + }, + "parserOptions": { + "ecmaVersion": 2018, + "noInlineConfig": true, + "reportUnusedDisableDirectives": true, + "sourceType": "module" + }, + "rules": { + "arrow-body-style": "error", + "arrow-spacing": ["warn", {"before": true, "after": true}], + "brace-style": "error", + "camelcase": ["error", {"properties": "never"}], + "complexity": ["error", 5], + "eol-last": "warn", + "eqeqeq": "error", + "func-call-spacing": ["error", "never"], + "global-require": "error", + "handle-callback-err": "warn", + "indent": ["warn", "tab", {"SwitchCase": 1}], + "key-spacing": ["error", {"beforeColon": false, "afterColon": true}], + "linebreak-style": ["warn", "unix"], + "max-depth": ["error", 3], + "max-len": ["warn", { "code": 120, "tabWidth": 4 }], + "max-lines": ["warn", {"max": 150, "skipBlankLines": true, "skipComments": true}], + "max-lines-per-function": ["warn", {"max": 20, "skipBlankLines": true, "skipComments": true}], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 5], + "max-statements": ["error", 20], + "no-cond-assign": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "warn", + "no-empty-function": "error", + "no-multiple-empty-lines": "warn", + "no-extra-parens": "error", + "no-func-assign": "error", + "no-irregular-whitespace": "error", + "no-magic-numbers": ["warn", {"ignore": [-1, 0, 1]}], + "no-multi-spaces": "warn", + "no-multi-str": "off", + "no-unexpected-multiline": "error", + "no-unreachable": "error", + "no-self-assign": "error", + "no-trailing-spaces": "warn", + "no-undef": "error", + "no-unused-vars": "warn", + "no-var": 2, + "prefer-arrow-callback": "warn", + "prefer-const": "error", + "prefer-template": "error", + "quotes": ["warn", "single"], + "semi": ["warn", "never"], + "space-before-blocks": ["error", { "functions": "always", "keywords": "always", "classes": "always" }], + "space-before-function-paren": ["error", "never"], + "strict": ["error", "global"], + "yoda": "error" + }, + "overrides": [{ + "files": [ "*.test.js", "*.spec.js", "*.steps.js" ], + "rules": { + "global-require": "off", + "max-lines-per-function": "off", + "max-lines": "off", + "max-statements": "off", + "no-magic-numbers": "off" + } + }] +} \ No newline at end of file diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100644 index 0000000..7a89e93 --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,28 @@ +#!/bin/bash + +set -e + +echo "POST-CHECKOUT" + +prevHEAD=$1 +newHEAD=$2 +checkoutType=$3 + +[[ $checkoutType == 1 ]] && checkoutType='branch' || checkoutType='file' ; + +echo " Checkout type: $checkoutType" +echo " prev HEAD: "`git name-rev --name-only $prevHEAD` +echo " new HEAD: "`git name-rev --name-only $newHEAD` + +# this is a file checkout – do nothing +if [ "$3" == "0" ]; then exit; fi + +BRANCH_NAME=$(git symbolic-ref --short -q HEAD) +NUM_CHECKOUTS=`git reflog --date=local | grep -o ${BRANCH_NAME} | wc -l` + +#if the refs of the previous and new heads are the same +#AND the number of checkouts equals one, a new branch has been created +if [ "$1" == "$2" ] && [ ${NUM_CHECKOUTS} -eq 1 ]; then + echo " new branch '$BRANCH_NAME' created" +fi +echo diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100644 index 0000000..a71bdcb --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,7 @@ +#!/bin/sh + +set -e # using the options command to abort script at first error +echo +echo "POST-COMMIT" +# ./node_modules/.bin/markdownlint --ignore node_modules . +echo diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..b23a03d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,76 @@ +#!/bin/sh + +set -e # using the options command to abort script at first error +echo +echo "PRE-COMMIT" + +EMAIL=$(git config user.email) + +# make sure the user has registered a valid university email address +if [[ $EMAIL != *"@coventry.ac.uk" ]]; then + echo " invalid config settings" + echo " Your registered email is currently '$EMAIL'" + echo " please run the following git commands:" + echo " $ git config user.email xxx@coventry.ac.uk" + echo " $ git config user.name 'zzz'" + echo " where 'xxx' is your university username" + echo " and 'zzz' is your name as it appears on your university ID badge" + echo + exit 1 +fi + +# see if the user is trying to merge a branch into master +branch="$(git rev-parse --abbrev-ref HEAD)" +if [[ $2 == 'merge' ]]; then + echo "merging branch" + if [[ "$branch" == "master" ]]; then + echo " trying to merge into the 'master' branch" + echo " you should push the local branch to GitHub" + echo " and merge to master using a pull request" + echo + exit 1 + fi +fi + +# see if the user is trying to commit to the master branch +if [ "$branch" = "master" ]; then + read -p " You are about to commit to the master branch, are you sure? [y|n] " -n 1 -r < /dev/tty + echo + if echo $REPLY | grep -E '^[Yy]$' > /dev/null + then + exit 0 # commit will execute + fi + exit 1 # commit will not execute +fi + +# is the current branch a direct child of the master branch? +# echo "checking parent branch" +# PARENT=$(git show-branch -a | grep -v `git rev-parse --abbrev-ref HEAD` | grep -v origin | sed 's/.*\[\(.*\)\].*/\1/' | grep -v -e '^$' | grep -v "^----$" +# echo "parent branch is $PARENT") + +# see if the user is trying to commit to the master branch +# echo " you are trying to commit to the '$branch' branch" +# if [ "$branch" = "master" ]; then +# echo " you can't commit directly to the master branch" +# echo " create a local feature branch first" +# echo +# exit 1 +# fi + +# check for valid branch name: + +# valid_branch_regex="^iss\d{3}\/[a-z\-]+$" + +# if [[ ! $local_branch =~ $valid_branch_regex ]] +# then +# echo "invalid branch name" +# echo " format is: 'iss000/issue-name'" +# echo " replacing '000' with the issue number and 'issue-name' with the issue name" +# echo " only lower-case letters and replace spaces in the issue name with dashes" +# echo " rename your branch and try again" +# exit 1 +# fi + +./node_modules/.bin/eslint . + +echo " commit successful..." diff --git a/.githooks/pre-merge-commit b/.githooks/pre-merge-commit new file mode 100644 index 0000000..128c360 --- /dev/null +++ b/.githooks/pre-merge-commit @@ -0,0 +1,4 @@ +#!/bin/sh + +set -e # using the options command to abort script at first error +echo "running the 'pre-merge-commit' script" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..b99229a --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,18 @@ +#!/bin/sh + +echo +echo "PRE-PUSH" + +protected_branch='master' +current_branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,') + +if [ $protected_branch = $current_branch ] +then + read -p " You're about to push master, is that what you intended? [y|n] " -n 1 -r < /dev/tty + echo + if echo $REPLY | grep -E '^[Yy]$' > /dev/null + then + exit 0 # push will execute + fi + exit 1 # push will not execute +fi diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg new file mode 100644 index 0000000..79c8815 --- /dev/null +++ b/.githooks/prepare-commit-msg @@ -0,0 +1,37 @@ +#!/bin/sh + +# With thanks to Sergio Vaccaro + +set -e # using the options command to abort script at first error +echo +echo "PREPARE-COMMIT-MSG" + +# Branch to protect +PROTECTED_BRANCH="master" + +# Remote +REMOTE="" + +# Check for merges +if [[ $2 != 'merge' ]]; then + # Not a merge + ECHO " not a merge" + exit 0 +fi + +# Current branch +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Check if in PROTECTED_BRANCH +#if [[ "$CURRENT_BRANCH" != "$PROTECTED_BRANCH" ]]; then +# # Not in PROTECTED_BRANCH: can proceed +# ECHO " not in the ${PROTECTED_BRANCH} branch" +# exit 0 +#fi + +echo " you are trying to merge into the ${PROTECTED_BRANCH} branch" +echo " merging branches to master must be done by creating a pull request" +echo " this merge has been cancelled however you will need to" +echo " reset the operation before continuing by running git reset --merge" +echo +exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cde5261 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ + +.DS_Store +node_modules/ +coverage/ +screenshots/* +docs/ + +data/ +coverage/ +sessions/ +screenshots/ +__image_snapshots_/ +__diff_output__/ +trace/ +.node-persist/ + +*.db +*snap.png +*diff.png +*trace.json +*.0x + +config.json +#Should probably be re-added +#Or at least provide documentation on how to populate + +*.pptx +*.mp4 + +*.codio +*.c9 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..3f4455b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,42 @@ +image: node:latest + +stages: + - code-testing + - staging-server + - acceptance-testing + +linting: + stage: code-testing + script: + - npm install + - npm run linter + +dependency-checks: + stage: code-testing + script: + - npm install + - npm run dependency + +unit-testing: + stage: code-testing + script: + - npm install + - npm test + +code-coverage: + stage: code-testing + script: + - npm install + - npm run coverage + +coverage-report: + stage: staging-server + script: + - npm install + - npm run coverage + artifacts: + paths: + - docs + expire_in: 30 days + only: + - master diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..3dc3115 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,33 @@ + +{ + "line-length": false, + "no-inline-html": { + "allowed_elements": [ + "a", "br" + ] + }, + "header-increment": true, + "ul-style": { + "style": "dash" + }, + "ul-start-left": true, + "no-trailing-spaces": { + "br_spaces": 0 + }, + "no-hard-tabs": true, + "no-reversed-links": true, + "no-multiple-blanks": true, + "no-missing-space-atx": true, + "no-multiple-space-atx": true, + "blanks-around-headers": true, + "header-start-left": true, + "no-duplicate-header": true, + "single-h1": true, + "blanks-around-fences": true, + "blanks-around-lists": true, + "no-bare-urls": true, + "no-space-in-emphasis": true, + "no-space-in-code": true, + "no-space-in-links": true, + "fenced-code-language": true +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ff946a2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ + +{ + "version": "0.2.0", + "configurations": [ + + { + "type": "node", + "request": "attach", + "name": "Attach by Process ID", + "processId": "${command:PickProcess}" + }, + { + "type": "node", + "request": "launch", + "name": "Jest", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "protocol": "inspector", + "windows": { + "program": "${workspaceFolder}/node_modules/jest/bin/jest", + } + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..77726b8 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ + +# Assignment Template + +This repository contains the base files for the assignment. You will need to create a _private duplicate_ in your module organisation. Carry out the following steps: + +1. Click on the **Use this template** button at the top of the screen. +2. Change the owner to your module using the dropdown list. +3. Change the repository name: + 1. If this is your first attempt use your University Username (the one you use to log into the university systems). + 2. If you are doing the module resit append `-resit` to the end. +4. In the **Description** field enter the name of your project topic (eg. `Auction`). +5. Clone your private repository (refer to the **setup** lab if you have forgotten how to do this). + +## Local Config Settings + +Before you make any commits you need to update the [local config settings](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup). Start by using the Terminal (or Git Bash on Windows) navigate inside the project. Once you are in this directory run the following commands, substituting you name as it appears on your ID badge and your university email address (without the `uni.` domain prefix). + +```bash +git config user.name 'John Doe' +git config user.email 'doej@coventry.ac.uk' +git config core.hooksPath .githooks +git config --add merge.ff false +``` + +## Configuring the Email + +The assignment template includes code to send an email validation link to the users when they register. This link needs to be clicked to enable the account and allow them to log in. For this to work you will need to configure the code to connect to an IMAP-enabled email account which it will use to send the emails. + +The simplest solution is to use a Gmail account and to do this you will need to carry out three steps: + +1. Go to the gmail settings screen and, in the "Forwarding and POP/IMAP" tab enable IMAP support. +2. Use the following link to enable less secure apps https://www.google.com/settings/security/lesssecureapps +3. Rename the `config.sample.json` file to `config.json` and update this with your account details. + +Now you can start working on the assignment. Remember to install all the dependencies listed in the `package.json` file. + +## Feature Branching + +You should not be committing directly to the **master** branch, instead each task or activity you complete should be in its own _feature branch_. **Don't attempt this until after the lecture that covers version control (git)**. You should following the following steps: + +1. Log onto GitHub and add an issue to the _issue tracker_, this is your _todo_ list. +2. Create a local feature branch making sure that the name of the branch includes both the issue _number_ and _title_ (in lower case). + 1. For example: `git checkout -b iss023/fix-login-bug`. + 2. You can see a list of all the local branches using `git branch`. +3. As you work on the issue make your local commits by: + 1. staging the files with `git add --all`. + 2. committing with the `no-ff` flag, eg. `git commit --no-ff -m 'detailed commit message here'`. +4. When the task is complete and all the tests pass, push the feature branch to GitHub. + 1. For example `git push origin iss023/fix-login-bug` would push the branch named above. + 2. Switch back to the _master_ branch with `git checkout master`. +5. Back on GitHub raise a **Pull Request** that merges this feature branch to the _master_ branch. +5. If there are no issues you can then merge the branch using the button in the _Pull Request_ interface. +6. Pull the latest version of the master branch code using `git pull origin master`. + diff --git a/__mocks__/nodemailer-promise.js b/__mocks__/nodemailer-promise.js new file mode 100644 index 0000000..a98cdee --- /dev/null +++ b/__mocks__/nodemailer-promise.js @@ -0,0 +1,9 @@ + +const config = function() { + return mailOptions => new Promise( (resolve, reject) => { + if(!mailOptions.to.includes('@')) return reject('MOCK ERROR: INVALID EMAIL') + resolve() + }) +} + +module.exports.config = config diff --git a/config.sample.json b/config.sample.json new file mode 100644 index 0000000..f2e41d8 --- /dev/null +++ b/config.sample.json @@ -0,0 +1,9 @@ + +{ + "host": "smtp.gmail.com", + "port": 465, + "secure": true, + "auth": { + "user": "jdoe@gmail.com", "pass": "password" + } +} diff --git a/cucumber/features/login.feature b/cucumber/features/login.feature new file mode 100644 index 0000000..f66c7e7 --- /dev/null +++ b/cucumber/features/login.feature @@ -0,0 +1,36 @@ + +Feature: Logging In + The user should be able to register a new account and log in to + access the secure page. + + Scenario: registering, validating and logging in + Given The browser is open on the home page + When I click on the "register" link + When I enter "jdoe" in the "username" field + When I enter "p455w0rd" in the "password" field + When I enter "jdoe@coventry.ac.uk" in the "email" field + When I click on the submit button + Then the heading should be "Validate Your Account" + When I enter the email link in the browser + Then the heading should be "Account Validated" + When I click on the "log in" link + Then the heading should be "Log In" + When I enter "jdoe" in the "username" field + When I enter "p455w0rd" in the "password" field + When I click on the submit button + Then the heading should be "Secure Page" + + Scenario: registering and logging in without validating + Given The browser is open on the home page + When I click on the "register" link + When I enter "jdoe" in the "username" field + When I enter "p455w0rd" in the "password" field + When I enter "jdoe@coventry.ac.uk" in the "email" field + When I click on the submit button + Then the heading should be "Validate Your Account" + When I access the homepage + Then the heading should be "Log In" + When I enter "jdoe" in the "username" field + When I enter "p455w0rd" in the "password" field + When I click on the submit button + Then the heading should be "Validate Your Account" diff --git a/cucumber/steps/page.js b/cucumber/steps/page.js new file mode 100644 index 0000000..75e0133 --- /dev/null +++ b/cucumber/steps/page.js @@ -0,0 +1,24 @@ + +const puppeteer = require('puppeteer') + +module.exports = class User { + + constructor(width, height) { + return (async() => { + this.delayMS = 5 + this.browser = await puppeteer.launch( + { + headless: true, + slowMo: this.delayMS, + args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage'] + }) + this.page = await this.browser.newPage() + await this.page.setViewport({ width, height }) + await this.page.goto('http://localhost:8080') + await this.page.evaluate(() => localStorage.clear()) + await this.page.reload() + console.log('constructor completed') + return this.page + })() + } +} diff --git a/cucumber/steps/todo.steps.js b/cucumber/steps/todo.steps.js new file mode 100644 index 0000000..934b750 --- /dev/null +++ b/cucumber/steps/todo.steps.js @@ -0,0 +1,117 @@ + +// https://github.com/cucumber/cucumber-js/blob/master/docs/support_files/hooks.md + +function sleep(ms) { //Helper function to slightly delay shell command + return new Promise(resolve => { + setTimeout(resolve,ms) + }) +} + +const { Before, BeforeAll, After, AfterAll, Given, When, Then } = require('cucumber') +const shell = require('child_process') +// const assert = require('assert') + +const Page = require('./page.js') + +let page // this is the page object we use to reference a web page + +BeforeAll( async() => { + console.log('BEFORE ALL') + shell.exec('node index.js') + await sleep(100) +}) + +AfterAll( async() => { + console.log('AFTER ALL') + page.close() + await shell.exec('pkill node') + return Promise.resolve() +}) + +Before( async() => { + console.log('BEFORE') + await shell.exec('rm -rf *.db') +}) + +After( async() => { + console.log('AFTER') +}) + +Given('The browser is open on the home page', async() => { + console.log('The browser is open on the home page') + page = await new Page(800, 600) +}) + +When('I click on the {string} link', async link => { + console.log(`I click on the "${link}" link`) +}) + +When('I enter {string} in the {string} field', async(value, field) => { + console.log(`I enter "${value}" in the "${field}" field`) + // await page.click(`#${field}`) //field represents the id attribute in html + // await page.keyboard.type(value) +}) + +When('I click on the submit button', async() => { + console.log('I click on the submit button') + // await page.click('#submit') +}) + +Then('the heading should be {string}', async heading => { + console.log(`the heading should be "${heading}"`) + // const text = await page.evaluate( () => { + // const dom = document.querySelector('h1') + // return dom.innerText + // }) + // assert.equal(heading, text) +}) + +When('I enter the email link in the browser', async() => { + console.log('I enter the email link in the browser') +}) + +When('I access the homepage', async() => { + console.log('I access the homepage') +}) + +Then('take a screenshot called {string}', async filename => { + await page.screenshot({ path: `screenshots/${filename}.png` }) +}) + +// Then('the list should contain {string} rows', async rowCount => { +// rowCount = Number(rowCount) +// const items = await page.evaluate( () => { +// const dom = document.querySelectorAll('table tr td:first-child') +// const arr = Array.from(dom) +// return arr.map(td => td.innerText) +// }) +// assert.equal(items.length, rowCount) +// }) + +// Then('the item should be {string}', async item => { +// const items = await page.evaluate( () => { +// const dom = document.querySelectorAll('table tr td:first-child') +// const arr = Array.from(dom) +// return arr.map(td => td.innerText) +// }) +// assert.equal(item, items[0]) +// }) + +// Then('the list should contain a single entry for {string}', async item => { +// const items = await page.evaluate( () => { +// const dom = document.querySelectorAll('table tr td:first-child') +// const arr = Array.from(dom).map(td => td.innerText) +// return arr +// }) +// const count = items.reduce( (acc, val) => (val === item ? acc += 1 : acc), 0) +// assert.equal(count, 1) +// }) + +// Then('the {string} quantity should be {string}', async(item, qty) => { +// const items = await page.evaluate( () => { +// const dom = document.querySelectorAll('table tr') +// // const arr = Array.from(dom) +// return dom +// }) +// assert.equal(2, 2) +// }) diff --git a/index.js b/index.js new file mode 100644 index 0000000..e1e996c --- /dev/null +++ b/index.js @@ -0,0 +1,31 @@ + +const Koa = require('koa') +const session = require('koa-session') +const staticDir = require('koa-static') +const views = require('koa-views') + +const apiRouter = require('./routes/routes') + +const app = new Koa() +app.keys = ['darkSecret'] + +const defaultPort = 8080 +const port = process.env.PORT || defaultPort + +app.use(staticDir('public')) +app.use(session(app)) +app.use(views(`${__dirname}/views`, { extension: 'handlebars' }, {map: { handlebars: 'handlebars' }})) + +app.use( async(ctx, next) => { + console.log(`${ctx.method} ${ctx.path}`) + ctx.hbs = { + authorised: ctx.session.authorised, + host: `${ctx.protocol}://${ctx.host}` + } + for(const key in ctx.query) ctx.hbs[key] = ctx.query[key] + await next() +}) + +app.use(apiRouter.routes(), apiRouter.allowedMethods()) + +module.exports = app.listen(port, async() => console.log(`listening on port ${port}`)) diff --git a/jest-test.config.js b/jest-test.config.js new file mode 100644 index 0000000..f1f7a55 --- /dev/null +++ b/jest-test.config.js @@ -0,0 +1,20 @@ + +module.exports = { + displayName: 'test', + verbose: true, + collectCoverage: true, + 'preset': 'jest-puppeteer', + coverageThreshold: { + global: { + branches: 0, + functions: 0, + lines: 0, + statements: 0 + } + }, + testPathIgnorePatterns: [ + '/node_modules/', + '/__tests__/fixtures/', + ], + roots: ['/unitTests'] +} diff --git a/jest.puppeteer.config.js b/jest.puppeteer.config.js new file mode 100644 index 0000000..8efcd34 --- /dev/null +++ b/jest.puppeteer.config.js @@ -0,0 +1,12 @@ + +module.exports = { + server: { + command: 'node index.js', + port: 8080, + }, + launch: { + headless: false, + devtools: true, + timeout: 30000 + } +} diff --git a/jsdoc.conf b/jsdoc.conf new file mode 100644 index 0000000..9fa536f --- /dev/null +++ b/jsdoc.conf @@ -0,0 +1,22 @@ + +{ + "tags": { + "allowUnknownTags": true, + "dictionaries": ["jsdoc","closure"] + }, + "source": { + "include": [ "." ], + "exclude": [ "node_modules" ], + "includePattern": ".+\\.js(doc|x)?$", + "excludePattern": "(^|\\/|\\\\)_" + }, + "plugins": ["jsdoc-route-plugin"], + "templates": { + "cleverLinks": false, + "monospaceLinks": false + }, + "opts": { + "destination": "docs/jsdoc", + "recurse": true + } +} diff --git a/modules/user.js b/modules/user.js new file mode 100644 index 0000000..8010be6 --- /dev/null +++ b/modules/user.js @@ -0,0 +1,148 @@ + +const crypto = require('crypto') +const bcrypt = require('bcrypt-promise') +const sqlite = require('sqlite-async') +const mailer = require('nodemailer-promise') + +// you will need to create this file with your email details +const config = require('../config.json') +const saltRounds = 10 + +module.exports = class User { + + constructor(dbName = ':memory:') { + return (async() => { + this.db = await sqlite.open(dbName) + // we need this table to store the user accounts + const sql = 'CREATE TABLE IF NOT EXISTS users\ + (id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, pass TEXT, email TEXT,\ + token TEXT, timestamp INTEGER, validated INTEGER DEFAULT 0);' + await this.db.run(sql) + return this + })() + } + + /** + * registers a new user + * @param {String} user the chosen username + * @param {String} pass the chosen password + * @returns {Boolean} returns true if the new user has been added + */ + async register(user, pass, email) { + Array.from(arguments).forEach( val => { + if(val.length === 0) throw new Error('missing field') + }) + let sql = `SELECT COUNT(id) as records FROM users WHERE user="${user}";` + const data = await this.db.get(sql) + if(data.records !== 0) throw new Error(`username "${user}" already in use`) + sql = `SELECT COUNT(id) as records FROM users WHERE email="${email}";` + const emails = await this.db.get(sql) + if(emails.records !== 0) throw new Error(`email address "${email}" is already in use`) + pass = await bcrypt.hash(pass, saltRounds) + sql = `INSERT INTO users(user, pass, email) VALUES("${user}", "${pass}", "${email}")` + await this.db.run(sql) + return true + } + + /** + * send an email message + * @param {String} to the email address to send the email to + * @param {String} subject the subject line of the email + * @param {String} html the html-formatted body of the email + * @returns {Boolean} returns true if the email was sent + */ + async sendEmail(to, subject, html) { + try { + const sendEmail = mailer.config(config) + const from = 'noreply@email.com' + const message = { from, to, subject, html } + await sendEmail(message) + return true + } catch (err) { + throw new Error('not able to send email') + } + } + + /** + * checks to see if a set of login credentials are valid + * @param {String} username the username to check + * @param {String} password the password to check + * @returns {Boolean} returns true if credentials are valid + */ + async login(username, password) { + let sql = `SELECT count(id) AS count FROM users WHERE user="${username}";` + const records = await this.db.get(sql) + if(!records.count) throw new Error(`username "${username}" not found`) + sql = `SELECT pass, validated FROM users WHERE user = "${username}";` + const record = await this.db.get(sql) + if(!record.validated) throw new Error('this account has not been validated') + const valid = await bcrypt.compare(password, record.pass) + if(valid === false) throw new Error(`invalid password for account "${username}"`) + return true + } + + /** + * generates and stores a token for the specified user + * the token will only be valid up to the timestamp specified in expiry + * @param {String} username generates a valid token for the user + * @param {Number} expiry a timestamp indicating when the token expires + * @returns {String} the token + */ + async generateToken(username, expiry) { + const len = 16 + const token = crypto.randomBytes(len).toString('hex') + const sql = `SELECT count(id) AS count FROM users WHERE user="${username}";` + const records = await this.db.get(sql) + if(!records.count) throw new Error(`username "${username}" not found`) + const encToken = await bcrypt.hash(token, saltRounds) + const updateSQL = `UPDATE users SET token = "${encToken}",\ + timestamp = ${expiry} WHERE user="${username}"` + await this.db.run(updateSQL) + return token + } + + /** + * checks the supplied token is valid and deletes it from the database + * @param {String} username the username of the specified user + * @param {String} token the token to check against the database + * @param {Number} now the current UNIX timestamp + * @returns {Boolean} returns true if the token was valid + */ + async checkToken(username, token, now) { + const sql = `SELECT id, token, timestamp, validated FROM users WHERE user="${username}"` + const user = await this.db.get(sql) + if(user.validated) return true // the user has already validated their account + if(user.token === null) throw new Error(`no token found for user "${username}"`) + if(user.timestamp < now) throw new Error('token has expired') + const updateSQL = `UPDATE users SET token = null, timestamp = null, validated = 1 WHERE user="${username}"` + await this.db.run(updateSQL) + return true + } + + /** + * checks the status of an account (exists/validated) + * @param {String} username the username to check + * @returns {Boolean} returns true if the account exists and is validated + */ + async checkStatus(username) { + const user = await this.db.get(`SELECT validated FROM users WHERE user="${username}"`) + if(user === undefined) throw new Error(`the "${username}" account does not exist`) + if(user.validated === 0) throw new Error(`the "${username}" account has not been validated`) + return true + } + + /** + * sets the status of the account to 'validated' + * @param {String} username the username of the specified user + * @returns {Boolean} returns true if account has been validated + */ + async validateAccount(username) { + const sql = `UPDATE users SET validated = 1 WHERE user = "${username}"` + await this.db.run(sql) + return true + } + + async tearDown() { + await this.db.close() + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7c1f2e4 --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "10_auth", + "version": "1.1.3", + "description": "A simple dynamic website template to be used as the base for various projects", + "main": "index.js", + "engines": { + "node": "12.x" + }, + "scripts": { + "coverage": "./node_modules/.bin/jest --coverage 'unitTests/' && ./node_modules/.bin/istanbul check-coverage --statement 100 --branch 100 --function 100 --line 100", + "cucumber": "node_modules/.bin/cucumber-js ./cucumber/features -r ./cucumber/steps", + "packagedeps": "./node_modules/.bin/depcheck .", + "dependency": "./node_modules/.bin/dependency-check -i bcrypt --unused --no-dev . && node_modules/.bin/dependency-check --missing .", + "start": "node index.js", + "jsdoc": "node_modules/.bin/jsdoc -c jsdoc.conf", + "linter": "node_modules/.bin/eslint .", + "test": "./node_modules/.bin/jest --coverage unitTests/" + }, + "author": "", + "license": "ISC", + "dependencies": { + "bcrypt": "^3.0.8", + "bcrypt-promise": "^2.0.0", + "handlebars": "^4.7.3", + "koa": "^2.6.2", + "koa-body": "^4.0.8", + "koa-bodyparser": "^4.2.1", + "koa-router": "^7.4.0", + "koa-send": "^5.0.0", + "koa-session": "^5.10.1", + "koa-static": "^5.0.0", + "koa-views": "^6.1.5", + "mime-types": "^2.1.26", + "nodemailer-promise": "^2.0.0", + "sharp": "^0.25.2", + "sqlite-async": "^1.0.12" + }, + "devDependencies": { + "coverage": "^0.4.1", + "cucumber": "^6.0.5", + "depcheck": "^0.9.2", + "dependency": "0.0.1", + "dependency-check": "^4.1.0", + "eslint": "^5.15.2", + "handlebars-validate": "^0.1.2", + "http-status-codes": "^1.3.2", + "istanbul": "^0.4.5", + "jest": "^24.9.0", + "jest-cucumber": "^2.0.11", + "jest-image-snapshot": "^2.11.0", + "jest-puppeteer": "^4.3.0", + "jscpd": "^2.0.16", + "jsdoc": "^3.6.3", + "jsdoc-route-plugin": "^0.1.0", + "markdownlint": "^0.17.0", + "puppeteer": "^1.20.0", + "puppeteer-har": "^1.1.1", + "shelljs": "^0.8.3", + "start-server-and-test": "^1.10.6", + "supertest": "^4.0.2" + }, + "jest": { + "projects": [ + "/jest-test.config.js" + ], + "preset": "jest-puppeteer" + } +} diff --git a/public/avatars/avatar.png b/public/avatars/avatar.png new file mode 100644 index 0000000..125a389 Binary files /dev/null and b/public/avatars/avatar.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..82339b3 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..0aa646a --- /dev/null +++ b/public/style.css @@ -0,0 +1,229 @@ + +body { + font-family: Arial, Helvetica, sans-serif; + margin: 0; + } + +/* formats the red message boxes */ +.msg { + border: 1px solid red; + font-weight: bold; + color: red; + padding: 1em; + margin: 2em; +} + +header { + background-color: #ccc; + border-bottom: 5px solid grey; + height: 8vw; +} + +h1, h2, h3 { + margin: 0; + padding: 0; + color: #444; +} + +h1 { + padding: 2.6vw; + float: left; + font-size: 3vw; + margin-left: 6vw; +} + +h2 { + font-size: 2.5vw; + margin: 1vw; + margin-left: 0.6vw; +} + +h3 { + font-size: 2vw; + margin: 1vw; + margin-left: 0.6vw; +} + +header > a { + margin-top: 3vw; + margin-right: 3vw; + float: right; + font-size: 1.3vw; +} + +header > img { + width: 7vw; + border-radius: 50%; + position: absolute; + left: 0.5vw; + top: 0.5vw; +} + +p, legend { + font-size: 1.5vw; +} + +main { + width: 100%; + margin-left: 3vw; + /* background-color: pink; */ +} + +article { + width: 50vw; + /* background-color: grey; + float: left; */ +} + +aside { + /* background-color: yellow; + float: right; */ + width: 50vw; +} + +main a { + color: black; + text-decoration: underline; +} + +main a:hover { + color: red; + text-decoration: none; +} + +section { + background-color: #DDD; + width: 24vw; + margin-top: 1vw; + padding: 1vw; + border-radius: 1vw; +} + +section > p { + margin: 0; + padding: 0; + margin-bottom: 0.5vw; +} + +section > h3 { + margin: 0; + padding: 0; + font-size: 1.8vw; +} + +small { + display: block; + text-align: right; +} + +/* ===== FORMS ===== */ + +fieldset { + position: relative; + width: 25vw; + border: 1px solid #CCC; + padding-top: 4vw; +} + +legend { + position:absolute; + top:0; + left:0; + width: 25.5vw; + background-color: #ccc; + border-bottom: 5px solid grey; + padding: 0.6vw; +} + +input[type="file"] { + display: none; +} + +input[type="submit"], header > a, .custom-file-upload { + background-color: grey; + border: 2px solid grey; + color: white; + padding: 0.4vw 0.8vw; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 1.5vw; + /* border-radius: 0.6vw; */ +} + +input[type="submit"]:hover, header > a:hover, .custom-file-upload:hover { + color: grey; + background-color: white; +} + +input[type="text"], input[type="email"], input[type="password"], textarea, select { + font-size: 1.5vw; + width: 25vw; + border: 1px solid #CCC; + padding: 0.5vw; + /* margin-bottom: 1vw; */ + /* border-radius: 0.6vw; */ +} + +textarea { + height: 10vw; + resize: none; +} + +select { + width: 26.3vw; + -webkit-appearance: none; + -webkit-border-radius: 0px; +} + +form > br { + line-height: 3vw; +} + +dl { + width: 35vw; + overflow: hidden; + /* background: #ff0; */ + padding: 0; + margin: 0; + font-size: 1.5vw; +} +dt { + float: left; + width: 10.5vw; + /* adjust the width; make sure the total of both is 100% */ + /* background: #cc0; */ + padding: 0.5vw; + margin: 0 +} +dd { + float: left; + width: 21vw; + /* adjust the width; make sure the total of both is 100% */ + /* background: #dd0; */ + padding: 0.5vw; + margin: 0 +} + +/* ===== TABLES ===== */ + +table { + /* border: 1px solid grey; */ + border-collapse: collapse; +} + +tr:nth-child(odd) { + background: #DDD; +} + +td { + /* border: 1px solid grey; */ + padding: 0.5vw; + font-size: 1.5vw; + width: 25vw; +} + +td:last-child { + width: 10vw; + text-align: right; +} diff --git a/routes/public.js b/routes/public.js new file mode 100644 index 0000000..cf176dd --- /dev/null +++ b/routes/public.js @@ -0,0 +1,108 @@ + +const Router = require('koa-router') +const koaBody = require('koa-body')({multipart: true, uploadDir: '.'}) + +const router = new Router() + +const User = require('../modules/user') +const dbName = 'website.db' + +/** + * The secure home page. + * + * @name Home Page + * @route {GET} / + */ +router.get('/', async ctx => { + try { + await ctx.render('index', ctx.hbs) + } catch(err) { + await ctx.render('error', ctx.hbs) + } +}) + + +/** + * The user registration page. + * + * @name Register Page + * @route {GET} /register + */ +router.get('/register', async ctx => await ctx.render('register')) + +/** + * The script to process new user registrations. + * + * @name Register Script + * @route {POST} /register + */ +router.post('/register', koaBody, async ctx => { + const user = await new User(dbName) + try { + // call the functions in the module + await user.register(ctx.request.body.user, ctx.request.body.pass, ctx.request.body.email) + const day = 86400, milliseconds = 1000 + const expiry = Math.floor(Date.now() / milliseconds) + day + const token = await user.generateToken(ctx.request.body.user, expiry) + const html = `

Paste this url into your browser

\ +

${ctx.hbs.host}/validate/${ctx.request.body.user}/${token}

` + await user.sendEmail(ctx.request.body.email, 'Validate Your Account', html) + ctx.redirect(`/postregister?msg=new user "${ctx.request.body.user}" added`) + } catch(err) { + ctx.hbs.msg = err.message + ctx.hbs.body = ctx.request.body + console.log(ctx.hbs) + await ctx.render('register', ctx.hbs) + } finally { + user.tearDown() + } +}) + +router.get('/postregister', async ctx => await ctx.render('validate')) + +router.get('/validate/:user/:token', async ctx => { + try { + console.log('VALIDATE') + console.log(`URL --> ${ctx.request.url}`) + if(!ctx.request.url.includes('.css')) { + console.log(ctx.params) + const milliseconds = 1000 + const now = Math.floor(Date.now() / milliseconds) + const user = await new User(dbName) + await user.checkToken(ctx.params.user, ctx.params.token, now) + ctx.hbs.msg = `account "${ctx.params.user}" has been validated` + await ctx.render('login', ctx.hbs) + } + } catch(err) { + await ctx.render('login', ctx.hbs) + } +}) + +router.get('/login', async ctx => { + console.log(ctx.hbs) + await ctx.render('login', ctx.hbs) +}) + +router.post('/login', koaBody, async ctx => { + const user = await new User(dbName) + ctx.hbs.body = ctx.request.body + try { + const body = ctx.request.body + await user.login(body.user, body.pass) + ctx.session.authorised = true + const referrer = body.referrer || '/secure' + return ctx.redirect(`${referrer}?msg=you are now logged in...`) + } catch(err) { + ctx.hbs.msg = err.message + await ctx.render('login', ctx.hbs) + } finally { + user.tearDown() + } +}) + +router.get('/logout', async ctx => { + ctx.session.authorised = null + ctx.redirect('/?msg=you are now logged out') +}) + +module.exports = router diff --git a/routes/routes.js b/routes/routes.js new file mode 100644 index 0000000..c5c86a8 --- /dev/null +++ b/routes/routes.js @@ -0,0 +1,12 @@ + +const Router = require('koa-router') + +const publicRouter = require('./public') +const secureRouter = require('./secure') + +const apiRouter = new Router() + +const nestedRoutes = [publicRouter, secureRouter] +for (const router of nestedRoutes) apiRouter.use(router.routes(), router.allowedMethods()) + +module.exports = apiRouter diff --git a/routes/secure.js b/routes/secure.js new file mode 100644 index 0000000..1fc95ce --- /dev/null +++ b/routes/secure.js @@ -0,0 +1,17 @@ + +const Router = require('koa-router') + +const router = new Router({ prefix: '/secure' }) + +router.get('/', async ctx => { + try { + console.log(ctx.hbs) + if(ctx.hbs.authorised !== true) return ctx.redirect('/login?msg=you need to log in&referrer=/secure') + await ctx.render('secure', ctx.hbs) + } catch(err) { + ctx.hbs.error = err.message + await ctx.render('error', ctx.hbs) + } +}) + +module.exports = router diff --git a/temp.js b/temp.js new file mode 100644 index 0000000..f44f1b8 --- /dev/null +++ b/temp.js @@ -0,0 +1,20 @@ + +const mailer = require('nodemailer-promise') +const config = require('./config.json') + +async function main() { + try { + const sendEmail = mailer.config(config) + const message = { + from: 'sender@email.com', // sender address + to: 'marktyers@gmail.com', // list of receivers + subject: 'Subject of your email', // Subject line + html: '

Your html here

'// plain text body + } + await sendEmail(message) + } catch (err) { + console.log(`error: ${err.message}`) + } +} + +main() diff --git a/unitTests/user.spec.js b/unitTests/user.spec.js new file mode 100644 index 0000000..6d6a462 --- /dev/null +++ b/unitTests/user.spec.js @@ -0,0 +1,202 @@ + +const Accounts = require('../modules/user.js') + +describe('register()', () => { + + test('register a valid account', async done => { + expect.assertions(1) + const account = await new Accounts() + const register = await account.register('doej', 'password', 'doej@gmail.com') + expect(register).toBe(true) + account.tearDown() + done() + }) + + test('register a duplicate username', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password', 'doej@gmail.com') + await expect( account.register('doej', 'password', 'doej@gmail.com') ) + .rejects.toEqual( Error('username "doej" already in use') ) + account.tearDown() + done() + }) + + test('error if blank username', async done => { + expect.assertions(1) + const account = await new Accounts() + await expect( account.register('', 'password', 'doej@gmail.com') ) + .rejects.toEqual( Error('missing field') ) + done() + }) + + test('error if blank password', async done => { + expect.assertions(1) + const account = await new Accounts() + await expect( account.register('doej', '', 'doej@gmail.com') ) + .rejects.toEqual( Error('missing field') ) + done() + }) + test('error if blank email', async done => { + expect.assertions(1) + const account = await new Accounts() + await expect( account.register('doej', 'password', '') ) + .rejects.toEqual( Error('missing field') ) + done() + }) + test('error if duplicate email', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('bondj', 'p455w0rd', 'doej@gmail.com') + await expect( account.register('doej', 'password', 'doej@gmail.com') ) + .rejects.toEqual( Error('email address "doej@gmail.com" is already in use') ) + done() + }) +}) + +describe('sendEmail()', () => { + test('send email to valid user', async done => { + expect.assertions(1) + const account = await new Accounts() + const status = await account.sendEmail('marktyers@gmail.com', 'test', '

Test

') + expect(status).toBe(true) + done() + }) + test('throws error if invalid email', async done => { + expect.assertions(1) + const account = await new Accounts() + await expect(account.sendEmail('marktyers', 'test', '

Test

')) + .rejects.toEqual( Error('not able to send email') ) + done() + }) +}) + +describe('generateToken()', () => { + test('should generate a valid token', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + const token = await account.generateToken('doej', Math.floor(Date.now() / 1000)) + expect(typeof token).toEqual('string') + done() + }) + test('should throw an error if invalid username', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.generateToken('baduser', Math.floor(Date.now() / 1000)) ) + .rejects.toEqual( Error('username "baduser" not found') ) + done() + }) +}) + +describe('checkToken()', () => { + test('check for a valid token', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + const token = await account.generateToken('doej', Math.floor(Date.now() / 1000) + 100) + const valid = await account.checkToken('doej', token, Math.floor(Date.now() / 1000)) + expect(valid).toBe(true) + done() + }) + test('should return true if account already validated', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + const token = await account.generateToken('doej', Math.floor(Date.now() / 1000) + 100) + await account.checkToken('doej', token, Math.floor(Date.now() / 1000)) // this will delete the token + const valid = await account.checkToken('doej', token, Math.floor(Date.now() / 1000)) + expect(valid).toBe(true) + done() + }) + test('throw an error if there is no token', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.checkToken('doej', 'xxxxxx', Math.floor(Date.now() / 1000)) ) + .rejects.toEqual(Error('no token found for user "doej"') ) + done() + }) + test('throw an error if the token has expired', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + const token = await account.generateToken('doej', Math.floor(Date.now() / 1000) - 100) + await expect( account.checkToken('doej', token, Math.floor(Date.now() / 1000)) ) + .rejects.toEqual(Error('token has expired') ) + done() + }) +}) + +describe('checkStatus()', () => { + test('the account not validated when created', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.checkStatus('doej') ) + .rejects.toEqual( Error('the "doej" account has not been validated') ) + done() + }) + test('an invalid username throws an error', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.checkStatus('baduser') ) + .rejects.toEqual( Error('the "baduser" account does not exist') ) + done() + }) +}) + +describe('validateAccount()', () => { + test('validating a real account', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await account.validateAccount('doej') + const status = await account.checkStatus('doej') + expect(status).toBe(true) + done() + }) +}) + +describe('login()', () => { + test('log in with valid credentials', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await account.validateAccount('doej') + const valid = await account.login('doej', 'password') + expect(valid).toBe(true) + done() + }) + + test('invalid username', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.login('roej', 'password') ) + .rejects.toEqual( Error('username "roej" not found') ) + done() + }) + + test('account not validated', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.login('doej', 'bad') ) + .rejects.toEqual( Error('this account has not been validated') ) + done() + }) + + test('invalid password', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await account.validateAccount('doej') + await expect( account.login('doej', 'bad') ) + .rejects.toEqual( Error('invalid password for account "doej"') ) + done() + }) + +}) diff --git a/views/error.handlebars b/views/error.handlebars new file mode 100644 index 0000000..63db6df --- /dev/null +++ b/views/error.handlebars @@ -0,0 +1,20 @@ + + + + + + + ERROR + + + + + +
+

An Error Has Occurred

+
+
+

{{message}}

+
+ + diff --git a/views/index.handlebars b/views/index.handlebars new file mode 100644 index 0000000..0b5a128 --- /dev/null +++ b/views/index.handlebars @@ -0,0 +1,31 @@ + + + + + + + Home Page + + + + + +
+

Home

+ {{#if authorised}} + Log out + {{else}} + Log in + {{/if}} +
+ {{#if msg}} +

{{msg}}

+ {{/if}} +
+

This is the home page. Users can see it without logging in.

+ {{#if authorised}} +

secure page

+ {{/if}} +
+ + diff --git a/views/login.handlebars b/views/login.handlebars new file mode 100644 index 0000000..acc65c4 --- /dev/null +++ b/views/login.handlebars @@ -0,0 +1,36 @@ + + + + + + + Log In + + + + + +
+

Log In

+ Register +
+ {{#if msg}} +

{{msg}}

+ {{/if}} +
+
+

+
+ +

+

+
+ +

+ +

+
+

Home page

+
+ + diff --git a/views/register.handlebars b/views/register.handlebars new file mode 100644 index 0000000..69e90ea --- /dev/null +++ b/views/register.handlebars @@ -0,0 +1,38 @@ + + + + + + + Create an Account + + + + + +
+

Create an Account

+ Log in +
+ {{#if msg}} +

{{msg}}

+ {{/if}} +
+
+

+
+ +

+

+
+ +

+

+
+ +

+

+
+
+ + diff --git a/views/secure.handlebars b/views/secure.handlebars new file mode 100644 index 0000000..3a4e2a7 --- /dev/null +++ b/views/secure.handlebars @@ -0,0 +1,25 @@ + + + + + + + Home Page + + + + + +
+

Secure Page

+ Log out +
+ {{#if msg}} +

{{msg}}

+ {{/if}} +
+

This is a secure page. Users need to have a valid account and be logged in to see it.

+

Home page

+
+ + diff --git a/views/validate.handlebars b/views/validate.handlebars new file mode 100644 index 0000000..ac6ddd5 --- /dev/null +++ b/views/validate.handlebars @@ -0,0 +1,21 @@ + + + + + + + Account Validation + + + + + +
+

Account Created

+
+
+

Your account has been successfully created and will need to be validated before use

+

Please check the email you registered and click on the link it contains.

+
+ +