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; // In case there was an existing session, broadcast a message that we can // display in other open tabs. if (sails.hooks.sockets) { await sails.helpers.broadcastSessionChange(this.req); } 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; // In case there was an existing session, broadcast a message that we can // display in other open tabs. if (sails.hooks.sockets) { await sails.helpers.broadcastSessionChange(this.req); } 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.)`); } } };