├── .env-example ├── .eslintrc ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── README.md ├── app.vue ├── components ├── account │ ├── FormSubmit.vue │ ├── Navigate.vue │ ├── passwordToggle.vue │ └── title.vue └── icon │ ├── LongArrowLeft.vue │ └── Spinner.vue ├── formkit.config.ts ├── layouts └── user.vue ├── middleware ├── auth.ts └── unauth.ts ├── nuxt.config.ts ├── package-lock.json ├── package.json ├── pages ├── account-verification │ └── [uuid].vue ├── dashboard.vue ├── forgot-password.vue ├── index.vue ├── reset-password.vue └── sign-up.vue ├── public ├── account-verification.png ├── dashboard-example.jpg ├── favicon.ico ├── password-reset.png └── thumbnail-1.jpg ├── server ├── api │ ├── dashboard.get.ts │ └── user │ │ ├── access-token.post.ts │ │ ├── forgot-password.ts │ │ ├── logout.post.ts │ │ ├── re-verify-user.post.ts │ │ ├── reset-password.post.ts │ │ ├── sign-in.post.ts │ │ ├── sign-up.post.ts │ │ └── verify-user.post.ts ├── models │ └── user.model.ts ├── plugins │ └── mongoDB.ts ├── tsconfig.json └── utils │ ├── check-access-token.ts │ ├── compare-strings.ts │ ├── generate-uuid.ts │ ├── get-email-template.ts │ ├── hash-strings.ts │ ├── hide-email.ts │ ├── sanitize.ts │ ├── send-email.ts │ ├── sign-token.ts │ └── verify-token.ts ├── stores └── useToken.ts ├── tailwind.config.ts ├── tsconfig.json └── types ├── email.ts └── user.ts /.env-example: -------------------------------------------------------------------------------- 1 | MONGO_URI= 2 | TOKEN_SECRET= 3 | ACCESS_TOKEN= 4 | ELASTIC_API= 5 | APP_URL= 6 | EMAIL= -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs/eslint-config-typescript" 4 | ], 5 | "rules": { 6 | "indent": "off", 7 | "vue/html-indent": "off", 8 | "vue/multi-word-component-names": "off", 9 | "space-before-function-paren": "off", 10 | "no-tabs": "off", 11 | "vue/html-closing-bracket-newline": "off", 12 | "import/default": "off", 13 | "import/no-named-as-default-member": "off", 14 | "no-lonely-if": "off", 15 | "array-bracket-spacing": "off" 16 | } 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .nuxt 4 | .nitro 5 | .cache 6 | dist 7 | 8 | # Node dependencies 9 | node_modules 10 | 11 | # Logs 12 | logs 13 | *.log 14 | 15 | # Misc 16 | .DS_Store 17 | .fleet 18 | .idea 19 | 20 | # Local env files 21 | .env 22 | .env.* 23 | !.env.example 24 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Postback" 4 | ] 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 Authentication using MongoDB, Refresh access token & Email Verification. 2 | 3 | This demo showcases seamless user authentication, featuring a refresh access token mechanism for API route protection. After registering, a verification email will be sent same thin when requesting a password reset. All-in-one example covering sign up, sign in, account verification, and password management. 4 | 5 | [![Video Example](./public//thumbnail-1.jpg)](https://www.youtube.com/watch?v=y6ulxSMYf40) 6 | 7 | Dashboard Example 8 | ![Dashboard Example with Refresh Access Token](./public/dashboard-example.jpg) 9 | 10 | Account Verification email template. 11 | ![Account Verification](./public/account-verification.png) 12 | 13 | 14 | Password Reset email template 15 | ![Password Reset](./public/password-reset.png) -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /components/account/FormSubmit.vue: -------------------------------------------------------------------------------- 1 | 7 | 18 | 19 | -------------------------------------------------------------------------------- /components/account/Navigate.vue: -------------------------------------------------------------------------------- 1 | 4 | 30 | 31 | -------------------------------------------------------------------------------- /components/account/passwordToggle.vue: -------------------------------------------------------------------------------- 1 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /components/account/title.vue: -------------------------------------------------------------------------------- 1 | 10 | 18 | 19 | -------------------------------------------------------------------------------- /components/icon/LongArrowLeft.vue: -------------------------------------------------------------------------------- 1 | 4 | 19 | 20 | -------------------------------------------------------------------------------- /components/icon/Spinner.vue: -------------------------------------------------------------------------------- 1 | 3 | 21 | 22 | -------------------------------------------------------------------------------- /formkit.config.ts: -------------------------------------------------------------------------------- 1 | import { defineFormKitConfig } from '@formkit/vue' 2 | import { generateClasses } from '@formkit/themes' 3 | 4 | const input = 'outline outline-1 outline-zinc-200 bg-zinc-100 w-full rounded h-[40px] pl-4 text-zinc-600 text-sm focus:outline-slate-400 formkit-errors:outline-rose-500 formkit-invalid:outline-rose-500' 5 | export default defineFormKitConfig({ 6 | config: { 7 | classes: generateClasses({ 8 | global: { 9 | message: 'text-red-500 text-xs mt-2', 10 | inner: 'max-w-none', 11 | wrapper: '!max-w-none', 12 | label: 'font-inter text-zinc-600', 13 | help: 'text-xs text-zinc-400 mt-2' 14 | }, 15 | text: { 16 | input 17 | }, 18 | password: { 19 | input 20 | }, 21 | email: { 22 | input 23 | }, 24 | number: { 25 | input 26 | }, 27 | submit: { 28 | input: 'w-full bg-blue-600 text-white min-h-[45px] rounded uppercase font-bold font-inter leading-0 duration-200 hover:bg-blue-700 relative overflow-hidden', 29 | outer: 'mt-4' 30 | } 31 | }) 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /layouts/user.vue: -------------------------------------------------------------------------------- 1 | 4 | 25 | 64 | -------------------------------------------------------------------------------- /middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async () => { 2 | const { data: accessToken } = await useFetch('/api/user/access-token', { 3 | method: 'POST' 4 | }) 5 | if (accessToken.value) { 6 | const token = useToken() 7 | token.accessToken = accessToken.value as string 8 | } else { 9 | return await navigateTo('/') 10 | } 11 | }) 12 | -------------------------------------------------------------------------------- /middleware/unauth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async () => { 2 | // if (process.server) { 3 | // const cookie = useCookie('authorization') 4 | // if (cookie.value) { 5 | // return await navigateTo('/dashboard') 6 | // } 7 | // } 8 | }) 9 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | devtools: { 4 | enabled: true, 5 | 6 | timeline: { 7 | enabled: true 8 | } 9 | }, 10 | modules: ['@formkit/nuxt', '@nuxtjs/tailwindcss', ['@pinia/nuxt', { 11 | autoImports: [ 12 | 'acceptHMRUpdate', 13 | 'defineStore', 14 | ['defineStore', 'definePiniaStore'] 15 | ] 16 | }]], 17 | imports: { 18 | dirs: ['stores'] 19 | }, 20 | runtimeConfig: { 21 | MONGO_URI: process.env.MONGO_URI, 22 | TOKEN_SECRET: process.env.TOKEN_SECRET, 23 | ACCESS_TOKEN: process.env.ACCESS_TOKEN, 24 | ELASTIC_API: process.env.ELASTIC_API, 25 | APP_URL: process.env.APP_URL, 26 | EMAIL: process.env.EMAIL, 27 | public: { 28 | ELASTIC_URL: 'https://api.elasticemail.com/v4' 29 | } 30 | }, 31 | alias: { 32 | pinia: '/node_modules/@pinia/nuxt/node_modules/pinia/dist/pinia.mjs' 33 | }, 34 | nitro: { 35 | plugins: ['~/server/plugins/mongoDB.ts'] 36 | } 37 | }) 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxt dev", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "postinstall": "nuxt prepare" 10 | }, 11 | "devDependencies": { 12 | "@nuxt/devtools": "latest", 13 | "@nuxtjs/eslint-config-typescript": "^12.0.0", 14 | "@nuxtjs/tailwindcss": "^6.8.0", 15 | "@types/bcrypt": "^5.0.0", 16 | "@types/jsonwebtoken": "^9.0.2", 17 | "@types/mongo-sanitize": "^1.0.1", 18 | "@types/nanoid-dictionary": "^4.2.0", 19 | "@types/node": "^18.17.1", 20 | "eslint": "^8.46.0", 21 | "nuxt": "^3.6.5" 22 | }, 23 | "dependencies": { 24 | "@formkit/nuxt": "^0.17.5", 25 | "@pinia/nuxt": "^0.4.11", 26 | "bcrypt": "^5.1.0", 27 | "jsonwebtoken": "^9.0.1", 28 | "mongo-sanitize": "^1.1.0", 29 | "mongoose": "^7.4.2", 30 | "mongoose-sanitize": "^1.1.0", 31 | "nanoid": "^4.0.2", 32 | "nanoid-dictionary": "^4.3.0", 33 | "xss": "^1.0.14" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /pages/account-verification/[uuid].vue: -------------------------------------------------------------------------------- 1 | 59 | 109 | 110 | -------------------------------------------------------------------------------- /pages/dashboard.vue: -------------------------------------------------------------------------------- 1 | 25 | 77 | 78 | -------------------------------------------------------------------------------- /pages/forgot-password.vue: -------------------------------------------------------------------------------- 1 | 30 | 71 | 72 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 34 | 98 | 99 | -------------------------------------------------------------------------------- /pages/reset-password.vue: -------------------------------------------------------------------------------- 1 | 31 | 60 | 61 | -------------------------------------------------------------------------------- /pages/sign-up.vue: -------------------------------------------------------------------------------- 1 | 44 | 120 | 121 | -------------------------------------------------------------------------------- /public/account-verification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReaganM02/Nuxt3-MongoDB-Refresh-Access-Token/193854d66a2231fbe1a5db9070bc65429877e4db/public/account-verification.png -------------------------------------------------------------------------------- /public/dashboard-example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReaganM02/Nuxt3-MongoDB-Refresh-Access-Token/193854d66a2231fbe1a5db9070bc65429877e4db/public/dashboard-example.jpg -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReaganM02/Nuxt3-MongoDB-Refresh-Access-Token/193854d66a2231fbe1a5db9070bc65429877e4db/public/favicon.ico -------------------------------------------------------------------------------- /public/password-reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReaganM02/Nuxt3-MongoDB-Refresh-Access-Token/193854d66a2231fbe1a5db9070bc65429877e4db/public/password-reset.png -------------------------------------------------------------------------------- /public/thumbnail-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReaganM02/Nuxt3-MongoDB-Refresh-Access-Token/193854d66a2231fbe1a5db9070bc65429877e4db/public/thumbnail-1.jpg -------------------------------------------------------------------------------- /server/api/dashboard.get.ts: -------------------------------------------------------------------------------- 1 | import { H3Error } from 'h3' 2 | import { UserData } from '../../types/user' 3 | import userModel from '../models/user.model' 4 | 5 | export default defineEventHandler(async (event): Promise => { 6 | try { 7 | const userUUID = await checkAccessToken(event) 8 | if (!userUUID) { 9 | return createError({ statusCode: 401, statusMessage: 'Unauthorize' }) 10 | } 11 | const user = await userModel.findOne({ uuid: userUUID }).select('-_d -__v') 12 | if (!user) { 13 | return createError({ statusCode: 401, statusMessage: 'User not found.' }) 14 | } 15 | user.email = hideEmail(user.email) 16 | return user as unknown as UserData 17 | } catch (error) { 18 | return createError({ statusCode: 500, statusMessage: 'Something went wrong.' }) 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /server/api/user/access-token.post.ts: -------------------------------------------------------------------------------- 1 | import userModel from '../../models/user.model' 2 | 3 | export default defineEventHandler(async (event) => { 4 | try { 5 | const header = getHeader(event, 'cookie') 6 | if (!header || !header.startsWith('authorization')) { 7 | return createError({ statusCode: 401, statusMessage: 'Unauthorize' }) 8 | } 9 | const authorization = header.split('=')[1] 10 | const verify = verifyToken(authorization, useRuntimeConfig().TOKEN_SECRET) 11 | if (!verify) { 12 | return createError({ statusCode: 401, statusMessage: 'Unauthorize' }) 13 | } 14 | const user = await userModel.findOne({ uuid: verify.uuid }) 15 | if (!user) { 16 | return createError({ statusCode: 401, statusMessage: 'Unauthorize' }) 17 | } 18 | const token = signToken({ uuid: user.uuid }, useRuntimeConfig().ACCESS_TOKEN, '1hr') 19 | return token 20 | } catch (error) { 21 | return createError({ statusCode: 500, statusMessage: 'Something went wrong.' }) 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /server/api/user/forgot-password.ts: -------------------------------------------------------------------------------- 1 | import userModel from '../../models/user.model' 2 | 3 | export default defineEventHandler(async (event) => { 4 | try { 5 | const body = await readBody<{ email: string } | null>(event) 6 | if (!body) { 7 | return createError({ statusCode: 400, statusMessage: 'Bad request.' }) 8 | } 9 | const user = await userModel.findOne({ email: body.email }) 10 | if (!user) { 11 | return createError({ statusCode: 404, statusMessage: 'Email not found.' }) 12 | } 13 | if (!user.verified) { 14 | return createError({ statusCode: 401, statusMessage: 'User not found.' }) 15 | } 16 | const id = generateUuid(6) 17 | user.resetPassword = await hashStrings(id) 18 | user.resetPasswordExpAt = new Date(Date.now() + 10 * 60 * 1000) // 10 minutes 19 | 20 | const emailTemplate = await getEmailTemplate('authentication-forgot-password') 21 | if (!emailTemplate) { 22 | return createError({ statusCode: 500, statusMessage: 'Apologies, an internal server error while sending verification email. Please contact development team.' }) 23 | } 24 | 25 | const URLLink = `${useRuntimeConfig().APP_URL}/reset-password/?id=${id}&uuid=${user.uuid}` 26 | const sendAnEmail = await sendEmail({ 27 | body: emailTemplate, 28 | email: [user.email], 29 | mergeFields: { firstName: user.firstName, URLLink }, 30 | from: `Reagan M <${useRuntimeConfig().EMAIL}>`, 31 | replyTo: `Reagan M <${useRuntimeConfig().EMAIL}>`, 32 | subject: 'Account Recovery: Request Password Reset' 33 | }) 34 | if (!sendAnEmail) { 35 | return createError({ statusCode: 500, statusMessage: 'Apologies, an internal server error is hindering the verification email sending. Please contact our development team.' }) 36 | } 37 | 38 | user.save() 39 | 40 | return 200 41 | } catch (error) { 42 | return createError({ statusCode: 500, statusMessage: 'Something went wrong.' }) 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /server/api/user/logout.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler((event) => { 2 | try { 3 | deleteCookie(event, 'authorization') 4 | return 200 5 | } catch (error) { 6 | return createError({ statusCode: 500, statusMessage: 'Something went wrong.' }) 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /server/api/user/re-verify-user.post.ts: -------------------------------------------------------------------------------- 1 | import { ReverifyUserPayload } from '../../../types/user' 2 | import userModel from '../../models/user.model' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | const body = await readBody(event) 7 | if (!body) { 8 | return createError({ statusCode: 400, statusMessage: 'Bad request.' }) 9 | } 10 | const user = await userModel.findOne({ uuid: body.uuid }) 11 | if (!user) { 12 | return createError({ statusCode: 404, statusMessage: 'User not found.' }) 13 | } 14 | if (user.verified) { 15 | return createError({ statusCode: 400, statusMessage: 'Account is already verified, please login' }) 16 | } 17 | const verificationCode = generateUuid(6) 18 | user.verificationCode = await hashStrings(verificationCode) 19 | user.verificationCodeExpAt = new Date(Date.now() + 10 * 60 * 1000)// 10 minutes 20 | 21 | const emailTemplate = await getEmailTemplate('authentication-account-verification') 22 | if (!emailTemplate) { 23 | return createError({ statusCode: 500, statusMessage: 'Apologies, an internal server error while sending verification email. Please contact development team.' }) 24 | } 25 | const sendAnEmail = await sendEmail({ 26 | body: emailTemplate, 27 | email: [user.email], 28 | mergeFields: { firstName: user.firstName, code: verificationCode }, 29 | from: `Reagan M <${useRuntimeConfig().EMAIL}>`, 30 | replyTo: `Reagan M <${useRuntimeConfig().EMAIL}>`, 31 | subject: 'Account Verification' 32 | }) 33 | if (!sendAnEmail) { 34 | return createError({ statusCode: 500, statusMessage: 'Apologies, an internal server error is hindering the verification email sending. Please contact our development team.' }) 35 | } 36 | 37 | user.save() 38 | return hideEmail(user.email) 39 | } catch (error) { 40 | return createError({ statusCode: 500, statusMessage: 'Something went wrong.' }) 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /server/api/user/reset-password.post.ts: -------------------------------------------------------------------------------- 1 | import userModel from '../../models/user.model' 2 | 3 | interface Query { 4 | uuid: string 5 | id: string 6 | } 7 | export default defineEventHandler(async (event) => { 8 | try { 9 | const query = getQuery(event) as unknown as Query | null 10 | const body = await readBody<{ password: string } | null>(event) 11 | if (!query || !query.id || !query.uuid || !body) { 12 | return createError({ statusCode: 400, statusMessage: 'Bad request.' }) 13 | } 14 | const user = await userModel.findOne({ uuid: query.uuid, resetPasswordExpAt: { $gt: new Date(Date.now()) } }).select('+resetPassword +resetPasswordExpAt') 15 | if (!user) { 16 | return createError({ statusCode: 404, statusMessage: 'User not found or verification code has expired.' }) 17 | } 18 | const isResetPasswordIDCorrect = await compareStrings(query.id, user.resetPassword!) 19 | if (!isResetPasswordIDCorrect) { 20 | return createError({ statusCode: 400, statusMessage: 'Invalid link ID' }) 21 | } 22 | user.password = await hashStrings(body.password) 23 | user.resetPassword = undefined 24 | user.resetPasswordExpAt = undefined 25 | user.save() 26 | return 200 27 | } catch (error) { 28 | return createError({ statusCode: 500, statusMessage: 'Something went wrong.' }) 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /server/api/user/sign-in.post.ts: -------------------------------------------------------------------------------- 1 | import { SignInRequestBody } from '../../../types/user' 2 | import userModel from '../../models/user.model' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | const body = await readBody(event) 7 | if (!body) { 8 | return createError({ statusCode: 400, statusMessage: 'Bad request.' }) 9 | } 10 | const user = await userModel.findOne({ email: sanitize(body.email) }).select('+password') 11 | if (!user) { 12 | return createError({ statusCode: 401, statusMessage: 'Invalid email or password' }) 13 | } 14 | if (!user.verified) { 15 | return createError({ statusCode: 401, statusMessage: 'Unauthorized' }) 16 | } 17 | const isPasswordCorrect = await compareStrings(body.password, user.password) 18 | if (!isPasswordCorrect) { 19 | return createError({ statusCode: 401, statusMessage: 'Invalid email or password' }) 20 | } 21 | const token = signToken({ uuid: user.uuid }, useRuntimeConfig().TOKEN_SECRET, '30d') 22 | setCookie(event, 'authorization', token, { 23 | httpOnly: true, 24 | secure: true, 25 | sameSite: 'none', 26 | expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) 27 | }) 28 | return 200 29 | } catch (error) { 30 | return createError({ statusCode: 500, statusMessage: 'Something went wrong.' }) 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /server/api/user/sign-up.post.ts: -------------------------------------------------------------------------------- 1 | import { Error } from 'mongoose' 2 | import { MongoError } from 'mongodb' 3 | import { UserRequestBody } from '../../../types/user' 4 | import userModel from '../../models/user.model' 5 | 6 | export default defineEventHandler(async (event) => { 7 | try { 8 | const body = await readBody(event) 9 | if (!body) { 10 | return createError({ statusCode: 400, statusMessage: 'Bad request.' }) 11 | } 12 | const verificationCode = generateUuid(6) 13 | const user = await userModel.create({ 14 | uuid: generateUuid(19), 15 | firstName: sanitize(body.firstName), 16 | lastName: sanitize(body.lastName), 17 | email: sanitize(body.email), 18 | password: await hashStrings(body.password), 19 | verificationCode: await hashStrings(verificationCode), 20 | verificationCodeExpAt: new Date(Date.now() + 10 * 60 * 1000) // 10 minutes 21 | }) 22 | const emailTemplate = await getEmailTemplate('authentication-account-verification') 23 | if (!emailTemplate) { 24 | return createError({ statusCode: 500, statusMessage: 'Apologies, an internal server error while sending verification email. Please contact development team.' }) 25 | } 26 | const sendAnEmail = await sendEmail({ 27 | body: emailTemplate, 28 | email: [user.email], 29 | mergeFields: { firstName: user.firstName, code: verificationCode }, 30 | from: `Reagan M <${useRuntimeConfig().EMAIL}>`, 31 | replyTo: `Reagan M <${useRuntimeConfig().EMAIL}>`, 32 | subject: 'Account Verification' 33 | }) 34 | if (!sendAnEmail) { 35 | return createError({ statusCode: 500, statusMessage: 'Apologies, an internal server error is hindering the verification email sending. Please contact our development team.' }) 36 | } 37 | return user.uuid 38 | } catch (error: unknown) { 39 | if (error instanceof Error.ValidationError) { 40 | return createError({ statusCode: 400, statusMessage: 'Bad request', data: Object.values(error.errors).map(err => ({ [err.path]: err.message })) }) 41 | } 42 | if (error instanceof MongoError) { 43 | return createError({ statusCode: 409, statusMessage: 'Email already exist' }) 44 | } 45 | return createError({ statusCode: 500, statusMessage: 'Something went wrong' }) 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /server/api/user/verify-user.post.ts: -------------------------------------------------------------------------------- 1 | import { VerifyUserPayload } from '../../../types/user' 2 | import userModel from '../../models/user.model' 3 | 4 | export default defineEventHandler(async (event) => { 5 | try { 6 | const body = await readBody(event) 7 | if (!body) { 8 | return createError({ statusCode: 400, statusMessage: 'Bad request.' }) 9 | } 10 | const user = await userModel.findOne({ uuid: body.uuid, verificationCodeExpAt: { $gt: new Date(Date.now()) } }).select('+verificationCode +verificationCodeExpAt') 11 | if (!user) { 12 | return createError({ statusCode: 404, statusMessage: 'Possible issues: Invalid user, expired verification code, or user already verified.' }) 13 | } 14 | const isVerificationCodeCorrect = await compareStrings(body.code as unknown as string, user.verificationCode!) 15 | if (!isVerificationCodeCorrect) { 16 | return createError({ statusCode: 400, statusMessage: 'Invalid verification code.' }) 17 | } 18 | user.verified = true 19 | user.verificationCode = undefined 20 | user.verificationCodeExpAt = undefined 21 | user.save() 22 | 23 | const token = signToken({ uuid: user.uuid }, useRuntimeConfig().TOKEN_SECRET, '15m') 24 | setCookie(event, 'authorization', token, { 25 | httpOnly: true, 26 | secure: true, 27 | sameSite: 'none', 28 | expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) 29 | }) 30 | 31 | return 200 32 | } catch (error) { 33 | return createError({ statusCode: 500, statusMessage: 'Something went wrong.' }) 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /server/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { UserSchema } from '../../types/user' 3 | 4 | const userSchema = new mongoose.Schema({ 5 | uuid: { 6 | type: Number, 7 | required: [true, 'UUID is required.'], 8 | index: { 9 | unique: true 10 | } 11 | }, 12 | firstName: { 13 | type: String, 14 | required: [true, 'First name is required.'], 15 | trim: true, 16 | validate: { 17 | validator: function (value: string) { 18 | return /^[a-zA-Z\s-]+$/.test(value) 19 | }, 20 | message: 'Invalid First Name' 21 | } 22 | }, 23 | lastName: { 24 | type: String, 25 | required: [true, 'First name is required.'], 26 | trim: true, 27 | validate: { 28 | validator: function (value: string) { 29 | return /^[a-zA-Z\s-]+$/.test(value) 30 | }, 31 | message: 'Invalid First Name' 32 | } 33 | }, 34 | email: { 35 | type: String, 36 | required: [true, 'Email is required.'], 37 | validate: { 38 | validator: function (value: string) { 39 | return /^\S+@\S+\.\S+$/.test(value) 40 | }, 41 | message: 'Invalid email.' 42 | }, 43 | index: { 44 | unique: true 45 | } 46 | }, 47 | verified: { 48 | type: Boolean, 49 | default: false 50 | }, 51 | password: { 52 | type: String, 53 | required: [true, 'Password is required.'], 54 | select: false, 55 | minlength: [10, 'Password is too short.'] 56 | }, 57 | verificationCode: { 58 | type: String, 59 | select: false 60 | }, 61 | verificationCodeExpAt: { 62 | type: Date, 63 | select: false 64 | }, 65 | resetPassword: { 66 | type: String, 67 | select: false 68 | }, 69 | resetPasswordExpAt: { 70 | type: Date, 71 | select: false 72 | } 73 | }, { timestamps: true }) 74 | 75 | export default mongoose.model('User', userSchema) 76 | -------------------------------------------------------------------------------- /server/plugins/mongoDB.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | export default async () => { 4 | try { 5 | mongoose.set('strictQuery', false) 6 | await mongoose.connect(useRuntimeConfig().MONGO_URI) 7 | // eslint-disable-next-line no-console 8 | console.log('Successfully connected to DB.') 9 | } catch (error: unknown) { 10 | return createError({ 11 | statusCode: 500, 12 | statusMessage: 'Something went wrong.' 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /server/utils/check-access-token.ts: -------------------------------------------------------------------------------- 1 | import { H3Event } from 'h3' 2 | import userModel from '../models/user.model' 3 | async function checkAccessToken(event: H3Event): Promise { 4 | try { 5 | const authorizationHeader = getHeader(event, 'authorization') 6 | if (!authorizationHeader) { 7 | return false 8 | } 9 | if (!authorizationHeader.startsWith('Bearer')) { 10 | return false 11 | } 12 | const authorization = authorizationHeader.split(' ')[1] 13 | const payload = verifyToken(authorization, useRuntimeConfig().ACCESS_TOKEN) 14 | if (!payload) { 15 | return false 16 | } 17 | const user = await userModel.findOne({ uuid: payload.uuid }) 18 | if (!user) { 19 | return false 20 | } 21 | return user.uuid 22 | } catch (error) { 23 | return false 24 | } 25 | } 26 | 27 | export default checkAccessToken 28 | -------------------------------------------------------------------------------- /server/utils/compare-strings.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt' 2 | 3 | async function compareStrings(plainString: string, encryptedString: string) { 4 | return await bcrypt.compare(plainString, encryptedString) 5 | } 6 | 7 | export default compareStrings 8 | -------------------------------------------------------------------------------- /server/utils/generate-uuid.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid' 2 | import nanoidDictionary from 'nanoid-dictionary' 3 | 4 | function generateUUID(length: number) { 5 | const generateUUID = customAlphabet(nanoidDictionary.numbers, length) 6 | return generateUUID() 7 | } 8 | 9 | export default generateUUID 10 | -------------------------------------------------------------------------------- /server/utils/get-email-template.ts: -------------------------------------------------------------------------------- 1 | import { EmailBody, EmailTemplate } from '../../types/email' 2 | 3 | async function getEmailTemplate(templateName: string): Promise { 4 | try { 5 | const template: EmailTemplate = await $fetch(`${useRuntimeConfig().public.ELASTIC_URL}/templates/${templateName}`, { 6 | headers: { 7 | 'X-ElasticEmail-ApiKey': useRuntimeConfig().ELASTIC_API 8 | } 9 | }) 10 | return template.Body[0] 11 | } catch (error) { 12 | return false 13 | } 14 | } 15 | 16 | export default getEmailTemplate 17 | -------------------------------------------------------------------------------- /server/utils/hash-strings.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt' 2 | 3 | async function hashStrings(data: string): Promise { 4 | const hash = await bcrypt.hash(data, 12) 5 | return hash 6 | } 7 | 8 | export default hashStrings 9 | -------------------------------------------------------------------------------- /server/utils/hide-email.ts: -------------------------------------------------------------------------------- 1 | function hideEmail(plainEmail: string): string { 2 | const [username, domain] = plainEmail.split('@') 3 | const truncateUsername = username.slice(0, 3) + '***' 4 | return `${truncateUsername}@${domain}` 5 | } 6 | 7 | export default hideEmail 8 | -------------------------------------------------------------------------------- /server/utils/sanitize.ts: -------------------------------------------------------------------------------- 1 | import mongoSanitize from 'mongo-sanitize' 2 | import xss from 'xss' 3 | 4 | function sanitize(data: string): string { 5 | return xss(mongoSanitize(data)) 6 | } 7 | 8 | export default sanitize 9 | -------------------------------------------------------------------------------- /server/utils/send-email.ts: -------------------------------------------------------------------------------- 1 | import { EmailBody } from '../../types/email' 2 | 3 | interface EmailData { 4 | email: string[] 5 | body: EmailBody 6 | mergeFields: object 7 | from: string 8 | replyTo: string 9 | subject: string 10 | } 11 | async function sendEmail(emailData: EmailData): Promise { 12 | const emails = emailData.email.map((email) => { 13 | return { 14 | Email: email, 15 | Fields: emailData.mergeFields 16 | 17 | } 18 | }) 19 | try { 20 | await $fetch(`${useRuntimeConfig().public.ELASTIC_URL}/emails`, { 21 | method: 'POST', 22 | headers: { 23 | 'X-ElasticEmail-ApiKey': useRuntimeConfig().ELASTIC_API 24 | }, 25 | body: { 26 | Recipients: emails, 27 | Content: { 28 | Body: [emailData.body], 29 | Headers: emailData.mergeFields, 30 | EnvelopeFrom: emailData.from, 31 | From: emailData.from, 32 | ReplyTo: emailData.replyTo, 33 | Subject: emailData.subject 34 | } 35 | } 36 | }) 37 | return true 38 | } catch (error) { 39 | return false 40 | } 41 | } 42 | 43 | export default sendEmail 44 | -------------------------------------------------------------------------------- /server/utils/sign-token.ts: -------------------------------------------------------------------------------- 1 | import JWT from 'jsonwebtoken' 2 | 3 | function signToken(payload: object, secretKey: string, expiresIn: string): string { 4 | const sign = JWT.sign(payload, secretKey, { 5 | expiresIn 6 | }) 7 | return sign 8 | } 9 | export default signToken 10 | -------------------------------------------------------------------------------- /server/utils/verify-token.ts: -------------------------------------------------------------------------------- 1 | import JWT from 'jsonwebtoken' 2 | 3 | interface VerifyPayload { 4 | uuid: number 5 | iat: number 6 | exp: number 7 | } 8 | 9 | function verifyToken(tokenString: string, tokenKey: string): VerifyPayload | false { 10 | try { 11 | return JWT.verify(tokenString, tokenKey) as unknown as VerifyPayload 12 | } catch (error) { 13 | return false 14 | } 15 | } 16 | 17 | export default verifyToken 18 | -------------------------------------------------------------------------------- /stores/useToken.ts: -------------------------------------------------------------------------------- 1 | interface State { 2 | accessToken: string | null 3 | } 4 | 5 | export const useToken = defineStore('useToken', { 6 | state: (): State => ({ 7 | accessToken: null 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import formKitTailwind from '@formkit/themes/tailwindcss' 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [ './src/**.{html,js},vue.json', './formkit.config.ts' ], 6 | theme: { 7 | extend: {} 8 | }, 9 | plugins: [ formKitTailwind ] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /types/email.ts: -------------------------------------------------------------------------------- 1 | export interface EmailBody { 2 | ContentType: string 3 | Content: string 4 | Charset: string 5 | } 6 | 7 | export interface EmailTemplate { 8 | TemplateType: string 9 | Name: string 10 | DateAdded: string 11 | Subject: string 12 | Body: EmailBody[] 13 | TemplateScope: string 14 | } 15 | -------------------------------------------------------------------------------- /types/user.ts: -------------------------------------------------------------------------------- 1 | export interface UserSchema { 2 | uuid: number 3 | firstName: string 4 | lastName: string 5 | email: string 6 | password: string 7 | verified: boolean 8 | verificationCode: string | undefined 9 | verificationCodeExpAt: Date | undefined 10 | resetPassword: string | undefined 11 | resetPasswordExpAt: Date | undefined 12 | } 13 | export interface UserRequestBody { 14 | firstName: string 15 | lastName: string 16 | email: string 17 | password: string 18 | } 19 | export interface SignInRequestBody { 20 | email: string 21 | password: string 22 | } 23 | export interface VerifyUserPayload { 24 | uuid: number 25 | code: number 26 | } 27 | 28 | export interface ReverifyUserPayload { 29 | uuid: number 30 | } 31 | 32 | export interface UserData { 33 | uuid: number 34 | firstName: string 35 | lastName: string 36 | email: string 37 | verified: true 38 | createdAt: Date 39 | updatedAt: Date 40 | } 41 | --------------------------------------------------------------------------------