Add /api/sign_up/send_verification_email.json endpoint, add step 3 of sign up process...
authorNick Downing <nick@ndcode.org>
Sat, 15 Jan 2022 00:42:37 +0000 (11:42 +1100)
committerNick Downing <nick@ndcode.org>
Sat, 15 Jan 2022 02:13:55 +0000 (13:13 +1100)
18 files changed:
_config/email_contact.json [deleted file]
_config/email_feedback.json [deleted file]
_config/globals.json
_config/nodemailer_contact.json [new file with mode: 0644]
_config/nodemailer_feedback.json [new file with mode: 0644]
_config/nodemailer_noreply.json [new file with mode: 0644]
_config/site.jst
_lib/navbar.jst
_lib/post_request.jst
_lib/session_cookie.jst
api/errors.json
api/sign_up/create_account.json.jst
api/sign_up/get_draft.json.jst
api/sign_up/send_verification_email.json.jst [new file with mode: 0644]
api/sign_up/set_draft.json.jst
link.sh
my_account/sign_up/index.html.jst
package.json

diff --git a/_config/email_contact.json b/_config/email_contact.json
deleted file mode 100644 (file)
index 078879d..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-{
-  "user": "contact@ndcode.org",
-  "password": "XXXContact12",
-  "host": "mail.ndcode.org",
-  "tls": {"ciphers": "SSLv3"}
-}
diff --git a/_config/email_feedback.json b/_config/email_feedback.json
deleted file mode 100644 (file)
index 9358eb1..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-{
-  "user": "feedback@ndcode.org",
-  "password": "XXXFeedback12",
-  "host": "mail.ndcode.org",
-  "tls": {"ciphers": "SSLv3"}
-}
index e7ef945..4b5779c 100644 (file)
@@ -5,5 +5,7 @@
   "contact_to": "Nick Downing <nick@ndcode.org>",
   "feedback_from": "NDCODE Feedback <feedback@ndcode.org>",
   "feedback_to": "Nick Downing <nick@ndcode.org>",
+  "noreply_from": "NDCODE <noreply@ndcode.org>",
+  "noreply_signature": "NDCODE Team",
   "copyright": "Integration Logic Pty Ltd trading as NDCODE and contributors"
 }
diff --git a/_config/nodemailer_contact.json b/_config/nodemailer_contact.json
new file mode 100644 (file)
index 0000000..2106ceb
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "host": "mail.ndcode.org",
+  "port": 587,
+  "secure": false,
+  "auth": {
+    "user": "contact@ndcode.org",
+    "pass": "XXXContact12"
+  },
+  "requireTLS": true
+}
diff --git a/_config/nodemailer_feedback.json b/_config/nodemailer_feedback.json
new file mode 100644 (file)
index 0000000..0dd697f
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "host": "mail.ndcode.org",
+  "port": 587,
+  "secure": false,
+  "auth": {
+    "user": "feedback@ndcode.org",
+    "pass": "XXXFeedback12",
+  },
+  "requireTLS": true
+}
diff --git a/_config/nodemailer_noreply.json b/_config/nodemailer_noreply.json
new file mode 100644 (file)
index 0000000..0b1a828
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "host": "mail.ndcode.org",
+  "port": 587,
+  "secure": false,
+  "auth": {
+    "user": "noreply@ndcode.org",
+    "pass": "XXXNoreply12"
+  },
+  "requireTLS": true
+}
index 27a7a5f..4caff7d 100644 (file)
@@ -1,6 +1,6 @@
 let assert = require('assert')
 let logjson = (await import('@ndcode/logjson')).default
-let EmailJSCache = require('@ndcode/emailjs_cache')
+let NodeMailerCache = require('@ndcode/nodemailer_cache')
 let XDate = require('xdate')
 let ZettairCache = require('@ndcode/zettair_cache')
 
@@ -10,7 +10,7 @@ return async (resources, root, prev_site) => {
       super(resources, root, options, prev_site)
       this.database = undefined
       this.database_date = new XDate().toUTCString('yyyyMMdd')
-      this.emailjs_cache = undefined
+      this.nodemailer_cache = undefined
       this.zettair_cache = undefined
     }
 
@@ -29,10 +29,10 @@ return async (resources, root, prev_site) => {
         }
       )
 
-      assert(this.emailjs_cache === undefined)
-      this.emailjs_cache = await this.resources.ref(
-        'emailjs_cache',
-        async () => new EmailJSCache(true)
+      assert(this.nodemailer_cache === undefined)
+      this.nodemailer_cache = await this.resources.ref(
+        'nodemailer_cache',
+        async () => new NodeMailerCache(true)
       )
 
       assert(this.zettair_cache === undefined)
@@ -52,8 +52,8 @@ return async (resources, root, prev_site) => {
       assert(this.database !== undefined)
       await this.resources.unref('database')
 
-      assert(this.emailjs_cache !== undefined)
-      await this.resources.unref('emailjs_cache')
+      assert(this.nodemailer_cache !== undefined)
+      await this.resources.unref('nodemailer_cache')
 
       assert(this.zettair_cache !== undefined)
       await this.resources.unref('zettair_cache')
@@ -80,16 +80,16 @@ return async (resources, root, prev_site) => {
         this.database_date = new_database_date
       }
 
-      assert(this.emailjs_cache !== undefined)
-      this.emailjs_cache.kick()
+      assert(this.nodemailer_cache !== undefined)
+      this.nodemailer_cache.kick()
 
       assert(this.zettair_cache !== undefined)
       this.zettair_cache.kick()
     }
 
-    // retrieves a particular email account (loaded into an emailjs object)
-    get_emailjs(pathname) {
-      return /*await*/ this.emailjs_cache.get(this.root + pathname)
+    // retrieves a particular email account (as a nodemailer transport)
+    get_nodemailer(pathname) {
+      return /*await*/ this.nodemailer_cache.get(this.root + pathname)
     }
 
     // retrieves a particular search index (node.js wrapper of a zettair object)
index 8781d25..bf97f48 100644 (file)
@@ -9,10 +9,6 @@ return async (env, head, body, scripts) => {
   let logo_large = await env.site.get_min_svg('/_svg/logo_large.svg')
   let menu = await env.site.get_menu('/_menu.json')
   let page = await _require('/_lib/page.jst')
-  //let session = await _require('/session.jst')
-
-  // initialize env.sessions, env.session_key, env.session
-  //await session(env)
 
   // initialize env.cart
   //await cart(env)
@@ -48,31 +44,28 @@ return async (env, head, body, scripts) => {
       div.scrollbar-fix {
         div.container {
           div.row.align-items-center.py-3 {
-            div.col-sm-8 {
+            div.col-sm-7 {
               _out.push(logo_large)
             }
-            div.'col-sm-4' {
-              //div {
-              //  let signed_in =
-              //    Object.prototype.hasOwnProperty.call(env.session, 'account')
-              //  span#signed-in-status {
-              //    if (signed_in)
-              //      `Signed in as ${env.session.account}.`
-              //    else
-              //      'Browsing as guest.'
-              //  }
-              //  ' '
-              //  if (signed_in)
-              //    a#sign-in(href="" hidden="") {'Sign in'}
-              //  else
-              //    a#sign-in(href="") {'Sign in'}
-              //  ' '
-              //  if (signed_in)
-              //    a#sign-out(href="") {'Sign out'}
-              //  else
-              //    a#sign-out(href="" hidden="") {'Sign out'}
-              //}
-              //p {}
+            div.'col-sm-5' {
+              div.'mb-1'.text-right {
+                span#signed-in-status {
+                  if (env.signed_in_as !== null)
+                    `Signed in as ${env.signed_in_as}.`
+                  else
+                    'Browsing as guest.'
+                }
+                ' '
+                if (env.signed_in_as !== null)
+                  a#sign-in(href="" hidden="") {'Sign in'}
+                else
+                  a#sign-in(href="") {'Sign in'}
+                ' '
+                if (env.signed_in_as !== null)
+                  a#sign-out(href="") {'Sign out'}
+                else
+                  a#sign-out(href="" hidden="") {'Sign out'}
+              }
   
               form/*.form-inline*/(action="/search/index.html") {
                 div.input-group {
index 7d7f2ba..2b968bb 100644 (file)
@@ -1,6 +1,6 @@
 let stream_buffers = require('stream-buffers')
 
-return async (env, api, func) => {
+return async (env, endpoint, func) => {
   let Problem = await _require('/_lib/Problem.jst')
 
   let result
@@ -24,12 +24,12 @@ return async (env, api, func) => {
     )
     env.request.pipe(write_stream)
     let arguments = JSON.parse((await data).toString())
-    console.log('api', api, 'arguments', JSON.stringify(arguments))
+    console.log('endpoint', endpoint, 'arguments', JSON.stringify(arguments))
 
     result = await func(...arguments)
     if (result === undefined)
       result = null
-    console.log('api', api, 'result', JSON.stringify(result))
+    console.log('endpoint', endpoint, 'result', JSON.stringify(result))
   }
   catch (error) {
     let problem =
@@ -39,11 +39,11 @@ return async (env, api, func) => {
           // title
           'Internal server error',
           // details
-          error.message,
+          (error.stack || error.message).toString()
           // status
           500
         )
-    console.log('api', api, 'problem', problem.detail)
+    console.log('endpoint', endpoint, 'problem', problem.detail)
 
     env.mime_type = 'application/problem+json; charset=utf-8'
     env.site.serve(
index b0f09bf..d5b87a6 100644 (file)
@@ -1,6 +1,7 @@
-let XDate = require('xdate')
+let logjson = (await import('@ndcode/logjson')).default
 let cookie = require('cookie')
 let crypto = require('crypto')
+let XDate = require('xdate')
 
 return async (env, transaction) => {
   let cookies = cookie.parse(env.request.headers.cookie || '')
@@ -10,11 +11,11 @@ return async (env, transaction) => {
     await transaction.get({})
   ).get('sessions', {})
 
-  let session_key, session, expires = new XDate(now)
+  let session, expires = new XDate(now)
   if (
     Object.prototype.hasOwnProperty.call(cookies, 'session_key') &&
       (
-        session = await sessions.get(session_key = cookies.session_key)
+        session = await sessions.get(env.session_key = cookies.session_key)
       ) !== undefined &&
       now < await session.get('expires', 0)
   )
@@ -25,19 +26,20 @@ return async (env, transaction) => {
     // first request for session, maybe a bot, retain session for only 1 day
     expires.addDays(1)
     do {
-      session_key = crypto.randomBytes(16).toString('hex')
-    } while (sessions.has(session_key))
+      env.session_key = crypto.randomBytes(16).toString('hex')
+    } while (sessions.has(env.session_key))
     session = transaction.LazyObject()
-    sessions.set(session_key, session)
+    sessions.set(env.session_key, session)
   }
 
   await session.set('expires', expires.getTime())
-
   env.response.setHeader(
     'Set-Cookie',
-    `session_key=${session_key}; expires=${expires.toUTCString()}; path=/;`
+    `session_key=${env.session_key}; expires=${expires.toUTCString()}; path=/;`
   )
-  env.session_key = session_key
 
+  env.signed_in_as = await logjson.logjson_to_json(
+    await session.get('signed_in_as', null)
+  )
   return session
 }
index fc1b415..3860cfa 100644 (file)
@@ -19,6 +19,7 @@
   "418": "No verification image in session",
   "419": "Verification code mismatch",
   "420": "Account already exists",
+  "421": "Account does not exist",
   "500": "Internal server error",
   "501": "Not implemented",
   "502": "Bad gateway",
index ba00760..c3c74b6 100644 (file)
@@ -8,7 +8,7 @@ return async env => {
   post_request(
     // env
     env,
-    // api
+    // endpoint
     '/api/sign_up/create_account',
     // func
     async (verification_code, details) => {
@@ -21,6 +21,16 @@ return async env => {
         password: details.password.slice(0, 256),
         contact_me: details.contact_me ? true : false
       }
+      if (
+        verification_code.length < 6 ||
+          details.given_names.length === 0 ||
+          details.password.length < 8
+      )
+        throw new Problem(
+          'Bad request',
+          'Minimum length check failed',
+          400
+        )
 
       let transaction = await env.site.database.Transaction()
       try {
@@ -31,7 +41,7 @@ return async env => {
         if (captcha === undefined || XDate.now() >= captcha.get('expires'))
           throw new Problem(
             'No verification image in session',
-            `Please call the "/api/verification_image.png" endpoint to create a verification image, in same session as the "/api/sign_up.json" call and less than one hour prior.`,
+            `Please call the "/api/verification_image.png" endpoint to create a verification image, in same session as the "/api/sign_up/create_account.json" call and less than one hour prior.`,
             418
           )
         
index 1f80c00..6cdd586 100644 (file)
@@ -9,7 +9,7 @@ return async env => {
   post_request(
     // env
     env,
-    // api
+    // endpoint
     '/api/sign_up/get_draft',
     // func
     async () => {
diff --git a/api/sign_up/send_verification_email.json.jst b/api/sign_up/send_verification_email.json.jst
new file mode 100644 (file)
index 0000000..47da582
--- /dev/null
@@ -0,0 +1,92 @@
+let crypto = require('crypto')
+let logjson = (await import('@ndcode/logjson')).default
+let XDate = require('xdate')
+
+return async env => {
+  let nodemailer_noreply = await env.site.get_nodemailer(
+    '/_config/nodemailer_noreply.json'
+  )
+  let globals = await env.site.get_json('/_config/globals.json')
+  let post_request = await _require('/_lib/post_request.jst')
+  let session_cookie = await _require('/_lib/session_cookie.jst')
+  let Problem = await _require('/_lib/Problem.jst')
+
+  post_request(
+    // env
+    env,
+    // endpoint
+    '/api/sign_up/send_verification_email',
+    // func
+    async email => {
+      // coerce and/or validate
+      email = email.slice(0, 256).toLowerCase()
+      if (email.length === 0)
+        throw new Problem(
+          'Bad request',
+          'Minimum length check failed',
+          400
+        )
+
+      let transaction = await env.site.database.Transaction()
+      try {
+        // initialize env.session_key, set cookie in env.response
+        await session_cookie(env, transaction)
+
+        let account = await (
+          await (
+            await transaction.get({})
+          ).get('accounts', {})
+        ).get(email)
+        if (account === undefined)
+          throw new Problem(
+            'Account does not exist',
+            `Please create the account for "${email}" before attempting to send a verification email.`
+            421
+          )
+
+        let key = crypto.randomBytes(16).toString('hex')
+        let expires = new XDate()
+        expires.addDays(1)
+        account.set(
+          'verify_email',
+          transaction.json_to_logjson({key, expires: expires.getTime()})
+        )
+
+        let given_names = await logjson.logjson_to_json(
+          await account.get('given_names', '')
+        )
+        let family_name = await logjson.logjson_to_json(
+          await account.get('family_name', '')
+        )
+        let name =
+          family_name.length ? `${given_names} ${family_name}` : given_names
+
+        await nodemailer_noreply.sendMail(
+          {
+            from: globals.noreply_from,
+            to: `${name} <${email}>`,
+            subject: 'Email address verification',
+            text: `Dear ${given_names},
+
+We have received a request to sign up using your email address.
+
+If this request is valid, please verify your email address by visiting the below link:
+${globals.site_url}/my_account/verify_email/index.html?email=${encodeURIComponent(email)}&key=${key}
+
+The link is valid for 24 hours.
+
+Thanks,
+${globals.noreply_signature}
+`
+          }
+        )
+
+        await transaction.commit()
+      }
+      catch (error) {
+        transaction.rollback()
+        throw error
+      }
+    }
+  )
+}
index 79c4de3..de69d3a 100644 (file)
@@ -8,7 +8,7 @@ return async env => {
   post_request(
     // env
     env,
-    // api
+    // endpoint
     '/api/sign_up/set_draft',
     // func
     async details => {
diff --git a/link.sh b/link.sh
index 09936fd..de4f402 100755 (executable)
--- a/link.sh
+++ b/link.sh
@@ -1,5 +1,5 @@
 #!/bin/sh
 rm -rf node_modules package-lock.json
-npm link @ndcode/emailjs_cache @ndcode/logjson @ndcode/zettair_cache
+npm link @ndcode/logjson @ndcode/nodemailer_cache @ndcode/zettair_cache
 npm install
 npm link
index 0bbf5b8..8a3963a 100644 (file)
@@ -141,7 +141,8 @@ return async env => {
             div.card-body {
               p#step-2-message {'Please enter your details first.'}
 
-              input.btn.btn-outline-secondary#step-2-back(type="button" value="Back") {}
+              input.btn.btn-outline-secondary.mr-3#step-2-back(type="button" value="Back") {}
+              input.btn.btn-outline-secondary#step-2-continue(type="button" value="Continue") {}
             }
           }
         }
@@ -168,7 +169,8 @@ return async env => {
             div.card-body {
               p#step-3-message {'Please create your account first.'}
 
-              input.btn.btn-outline-secondary#step-3-back(type="button" value="Back") {}
+              input.btn.btn-outline-secondary.mr-3#step-3-back(type="button" value="Back") {}
+              input.btn.btn-outline-secondary#step-3-resend-email(type="button" value="Resend email") {}
             }
           }
         }
@@ -191,8 +193,12 @@ return async env => {
           '/api/sign_up/set_draft.json',
           ...arguments
         )
+        let sign_up_send_verification_email = async (...arguments) => api_call(
+          '/api/sign_up/send_verification_email.json',
+          ...arguments
+        )
 
-        let details = () => {
+        let coerce_details = () => {
           return {
             email: document.getElementById('email').value.slice(0, 256).toLowerCase(),
             given_names: document.getElementById('given-names').value.slice(0, 256),
@@ -205,7 +211,7 @@ return async env => {
         let draft_timeout_running = false
         let draft_timeout_handler = async () => {
           draft_timeout_running = false
-          await sign_up_set_draft(details())
+          await sign_up_set_draft(coerce_details())
           //console.log('draft', await sign_up_get_draft())
         }
         let draft_change_handler = () => {
@@ -215,6 +221,81 @@ return async env => {
           }
         }
 
+        let step_1 = async () => {
+          if (
+            !document.getElementById('given-names').reportValidity() ||
+              !document.getElementById('family-name').reportValidity() ||
+              !document.getElementById('email').reportValidity() ||
+              !document.getElementById('password').reportValidity() ||
+              !document.getElementById('verification-code').reportValidity()
+          ) {
+            $('#step-1-tick').hide()
+            $('#step-1-cross').show()
+            //$('#step-1-spinner').hide()
+            return false
+          }
+          $('#step-1-tick').show()
+          $('#step-1-cross').hide()
+          //$('#step-1-spinner').hide()
+          return true
+        }
+
+        let step_3_email = ''
+        let step_2 = async () => {
+          $('#step-2-tick').hide()
+          $('#step-2-cross').hide()
+          $('#step-2-spinner').show()
+          try {
+            let details = coerce_details()
+            await sign_up_create_account(
+              // verification_code
+              document.getElementById('verification-code').value.slice(0, 6).toLowerCase(),
+              // details
+              details
+            )
+            step_3_email = details.email
+          }
+          catch (e) {
+            $('#step-2-tick').hide()
+            $('#step-2-cross').show()
+            $('#step-2-spinner').hide()
+
+            document.getElementById('step-2-message').textContent = e.message
+            $('#step-2-collapse').collapse('show')
+            return false
+          }
+          $('#step-2-tick').show()
+          $('#step-2-cross').hide()
+          $('#step-2-spinner').hide()
+
+          document.getElementById('step-2-message').textContent = `Your account with email "${document.getElementById('email').value}" has been created.`
+          return true
+        }
+
+        let step_3 = async () => {
+          $('#step-3-tick').hide()
+          $('#step-3-cross').hide()
+          $('#step-3-spinner').show()
+          try {
+            await sign_up_send_verification_email(step_3_email)
+          }
+          catch (e) {
+            $('#step-3-tick').hide()
+            $('#step-3-cross').show()
+            $('#step-3-spinner').hide()
+
+            document.getElementById('step-3-message').textContent = e.message
+            $('#step-3-collapse').collapse('show')
+            return false
+          }
+          $('#step-3-tick').show()
+          $('#step-3-cross').hide()
+          $('#step-3-spinner').hide()
+
+          document.getElementById('step-3-message').textContent = 'Verification email has been sent. Please check your email for next steps.'
+          return true
+        }
         document.addEventListener(
           'DOMContentLoaded', 
           () => {
@@ -242,59 +323,37 @@ return async env => {
             document.getElementById('step-1-continue').addEventListener(
               'click',
               async () => {
-                if (
-                  !document.getElementById('given-names').reportValidity() ||
-                    !document.getElementById('family-name').reportValidity() ||
-                    !document.getElementById('email').reportValidity() ||
-                    !document.getElementById('password').reportValidity() ||
-                    !document.getElementById('verification-code').reportValidity()
-                ) {
-                  $('#step-1-tick').hide()
-                  $('#step-1-cross').show()
-                  //$('#step-1-spinner').hide()
-                  return
-                }
-                $('#step-1-tick').show()
-                $('#step-1-cross').hide()
-                //$('#step-1-spinner').hide()
-
-                $('#step-2-tick').hide()
-                $('#step-2-cross').hide()
-                $('#step-2-spinner').show()
-                try {
-                  await sign_up_create_account(
-                    // verification_code
-                    document.getElementById('verification-code').value.slice(0, 6).toLowerCase(),
-                    // details
-                    details()
-                  )
-                }
-                catch (e) {
-                  $('#step-2-tick').hide()
-                  $('#step-2-cross').show()
-                  $('#step-2-spinner').hide()
-
-                  document.getElementById('step-2-message').textContent = e.message
-                  $('#step-2-collapse').collapse('show')
-                  return
-                }
-                $('#step-2-tick').show()
-                $('#step-2-cross').hide()
-                $('#step-2-spinner').hide()
-
-                document.getElementById('step-2-message').textContent = `Your account with email "${document.getElementById('email').value}" has been created.`
-                $('#step-2-collapse').collapse('show')
+                if (await step_1() && await step_2() && await step_3())
+                  $('#step-3-collapse').collapse('show')
               }
             )
 
-            $('#step-2-back').click(
+            document.getElementById('step-2-back').addEventListener(
+              'click',
               () => {$('#step-1-collapse').collapse('show')}
             )
 
-            $('#step-3-back').click(
+            document.getElementById('step-2-continue').addEventListener(
+              'click',
+              async () => {
+                if (await step_3())
+                  $('#step-3-collapse').collapse('show')
+              }
+           )
+
+            document.getElementById('step-3-back').addEventListener(
+              'click',
               () => {$('#step-2-collapse').collapse('show')}
             )
 
+            document.getElementById('step-3-resend-email').addEventListener(
+              'click',
+              async () => {
+                if (await step_3())
+                  $('#step-3-collapse').collapse('show')
+              }
+            )
+
             let image_seq = 1
             $('#new-code').click(
               () => {
index cec56d9..0c3a724 100644 (file)
@@ -4,8 +4,8 @@
   "description": "Example website using JavaScript Template system",
   "directories": {},
   "dependencies": {
-    "@ndcode/emailjs_cache": "^0.1.0",
     "@ndcode/logjson": "^0.1.0",
+    "@ndcode/nodemailer_cache": "^0.1.0",
     "@ndcode/zettair_cache": "^0.1.0",
     "captchagen": "^1.2.0",
     "cookie": "^0.3.1",