├── .gitignore ├── .travis.yml ├── LICENSE ├── Procfile ├── README.md ├── client ├── .browserslistrc ├── .eslintrc.js ├── .gitignore ├── .postcssrc.js ├── .prettierrc ├── App.vue ├── assets │ ├── config │ │ └── particlesjs-config.json │ ├── img │ │ ├── BackgroundPath.svg │ │ ├── astro-chat.gif │ │ ├── icons8-account-64.png │ │ ├── icons8-businessman.svg │ │ ├── undraw_empty_xct9.svg │ │ ├── undraw_group_chat_v059.svg │ │ └── undraw_programmer_imem.svg │ └── scss │ │ ├── abstract │ │ ├── animations.scss │ │ ├── keyframes.scss │ │ ├── mixins.scss │ │ ├── util.scss │ │ └── variables.scss │ │ ├── base │ │ └── base.scss │ │ ├── components │ │ ├── feature.scss │ │ ├── footer.scss │ │ ├── form.scss │ │ ├── infobox.scss │ │ ├── modal.scss │ │ ├── navbar.scss │ │ └── social.scss │ │ ├── vendors │ │ └── animate.scss │ │ └── views │ │ ├── chat.scss │ │ ├── profile.scss │ │ └── rooms.scss ├── babel.config.js ├── components │ ├── auth │ │ ├── Login.vue │ │ └── Register.vue │ ├── chat │ │ ├── ChatInput.vue │ │ └── MessageList.vue │ ├── error │ │ ├── Error.vue │ │ └── NotFound.vue │ ├── layout │ │ ├── Footer.vue │ │ ├── Modal.vue │ │ ├── Navbar.vue │ │ ├── Particle.vue │ │ ├── Sidebar.vue │ │ └── SignedInLinks.vue │ ├── profile │ │ └── Profile.vue │ ├── room │ │ ├── Room.vue │ │ └── RoomList.vue │ ├── social │ │ └── OAuth.vue │ └── user │ │ ├── EditUserProfile.vue │ │ └── UserProfile.vue ├── helpers │ └── user.js ├── jest.config.js ├── main.js ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── router.js ├── store.js ├── tests │ └── unit │ │ ├── .eslintrc.js │ │ └── Home.spec.js ├── utils │ └── authToken.js ├── views │ ├── About.vue │ └── Home.vue └── vue.config.js ├── package.json └── server ├── .env.example ├── .eslintrc ├── .gitignore ├── .prettierrc ├── actions ├── socialAuthActions.js └── socketio.js ├── config ├── config.js ├── logModule.js └── passport.js ├── db └── mongoose.js ├── helpers └── socketEvents.js ├── jest.config.js ├── middleware └── authenticate.js ├── models ├── Message.js ├── Room.js └── User.js ├── package.json ├── routes ├── auth.js ├── messages.js ├── profile.js ├── room.js └── user.js ├── server.js └── tests ├── auth.test.js ├── messages.test.js ├── middleware └── authenticate.test.js ├── profile.test.js ├── room.test.js ├── seed ├── seed.js ├── seedData.js └── seedFunctions.js ├── setup.js └── user.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/package-lock.json 3 | .vscode 4 | .idea 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | - '8' 5 | 6 | services: 7 | - mongodb 8 | 9 | env: 10 | - DATABASE_URL=mongodb://localhost:27017/testdb JWT_SECRET=testsecret PORT=5000 11 | 12 | install: 13 | - npm install 14 | - npm install --prefix client 15 | - npm install --prefix server 16 | 17 | script: 18 | - npm run test 19 | 20 | cache: 21 | directories: 22 | - 'node_modules' 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lu-Vuong Le 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌠 Astro Chat 🌠 2 | 3 | [![Build Status](https://travis-ci.org/luvuong-le/node-vue-chat.svg?branch=master)](https://travis-ci.org/luvuong-le/node-vue-chat) 4 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 5 | [![devDependencies Status](https://david-dm.org/luvuong-le/astro-chat/dev-status.svg)](https://david-dm.org/luvuong-le/astro-chat?type=dev) 6 | [![dependencies Status](https://david-dm.org/luvuong-le/astro-chat/status.svg)](https://david-dm.org/luvuong-le/astro-chat) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 8 | 9 | Real Time Chat Application created with VueJS, Express, Socket IO and MongoDB/Mongoose/Mongo Altas. 10 | 11 | ![Astro Chat Demo](/client/assets/img/astro-chat.gif) 12 | 13 | 14 | ## Contents 15 | 16 | - [Demo](#demo) 17 | - [Tech Stack](#tech-stack) 18 | - [Features](#features) 19 | - [Installation](#installation) 20 | - [Seeding Data](#seeding-data) 21 | - [Running Tests](#running-tests) 22 | - [Configuration Setup](#configuration-setup) 23 | - [Contribute](#contribute) 24 | 25 | ## Demo 26 | 27 | View the application at [https://astro-chat-io.herokuapp.com/](https://astro-chat-io.herokuapp.com/) 28 | 29 | View the project management cycle here [Astro Chat Project Kanban (Trello)](https://trello.com/b/V04pQQV3/astro-chat) 30 | 31 | ### Tech Stack 32 | 33 | | Technology | Description | Link ↘️ | 34 | | ---------- | ------------------------------------------------------------------------------------- | ----------------------- | 35 | | HTML5 | Hyper Text Markup Language | ---- | 36 | | CSS3 | Cascading Style Sheets | ---- | 37 | | JavaScript | High Level, Dynamic, Interpreted Language | ---- | 38 | | SASS | Syntactically Awesome Style Sheets | https://sass-lang.com/ | 39 | | Babel | Javascript Compiler | https://babeljs.io/ | 40 | | Webpack | Javascript Module Bundler | https://webpack.js.org/ | 41 | | NodeJS | Open Source, Javascript Run Time Environment, Execute Javascript code for server side | https://nodejs.org/en/ | 42 | | VueJS | Progressive Javascript Framework | https://vuejs.org/ | 43 | | Jest | Javascript Testing Framework | https://jestjs.io/ | 44 | | Express | Web Framework for Node.js | https://expressjs.com/ | 45 | | MongoDB | NoSQL Database | https://www.mongodb.com/ | 46 | 47 | ## Features 48 | 49 | - [Express](https://expressjs.com/) as the web framework on the server 50 | - Implements stateless authentication with [JWT](https://jwt.io/) tokens 51 | - Authenticates [JWT](https://jwt.io/) and social authentication using [Passport](http://www.passportjs.org/) 52 | - Hashes passwords using the [bcryptjs](https://www.npmjs.com/package/bcryptjs) package 53 | - Enables real time communication to the server using [Socket IO](https://socket.io/) 54 | - [MongoDB](https://www.mongodb.com/) and [Mongo Atlas](https://www.mongodb.com/cloud/atlas) is used for storing and querying data 55 | - Server logging is done with [Winston](https://www.npmjs.com/package/winston) and [Morgan](https://www.npmjs.com/package/morgan) 56 | - [Concurrently](https://www.npmjs.com/package/concurrently) is used to run both the server and client at the same time 57 | - [Vue JS](https://vuejs.org/) is used as the frontend framework 58 | - [Travis CI](https://travis-ci.org/) is incorporated for continuous integration 59 | - [Heroku](https://www.heroku.com) is used for production deployment 60 | 61 | ## Installation 62 | 63 | ### Running Locally 64 | 65 | _Ensure [Node.js](https://nodejs.org/en/) and [NPM](https://www.npmjs.com/) are installed_ 66 | 67 | 1. Clone or Download the repository (Depending on whether you are using SSH or HTTPS) 68 | 69 | ```bash 70 | $ git clone git@github.com:luvuong-le/astro-chat.git 71 | $ cd astro-chat 72 | ``` 73 | 74 | 2. Install dependencies for root, client and server 75 | 76 | > You will need to npm install in each directory in order to install the node module needed for each part of the project 77 | 78 | > Directories Include: Root, Server & Client 79 | 80 | 81 | 82 | 3. Add .env file to server folder and fill out details according to the .env.example. See [configuration details](#configuration-setup) for social auth and database setup: **Note, this is mandatory for the application to run** 83 | 84 | 4. Set **NODE_ENV=development** and **HEROKU_DEPLOYMENT=false** 85 | 86 | 5. Start the application 87 | 88 | ```bash 89 | $ npm run dev 90 | ``` 91 | 92 | Your app should now be running on [localhost:8080](localhost:8080). 93 | 94 | ### Run [Production Ready] Mode 95 | 96 | _Ensure [Node.js](https://nodejs.org/en/) and [NPM](https://www.npmjs.com/) are installed_ 97 | 98 | This runs the application with the built production ready Vue files as well as running the express server in production mode serving up the compiled files. 99 | 100 | 1. Clone or Download the repository (Depending on whether you are using SSH or HTTPS) 101 | 102 | ```bash 103 | $ git clone git@github.com:luvuong-le/astro-chat.git 104 | $ cd astro-chat 105 | ``` 106 | 107 | 2. Install dependencies for root, client and server 108 | 109 | > You will need to npm install in each directory in order to install the node module needed for each part of the project 110 | 111 | > Directories Include: Root, Server & Client 112 | 113 | 114 | 3. Add .env file to server folder and fill out details according to the .env.example. See [configuration details](#configuration-setup) for social auth and database setup. **Note, this is mandatory for the application to run** 115 | 116 | 4. Ensure you set **NODE_ENV=production** and **HEROKU_DEPLOYMENT=false** 117 | 118 | 5. Start the application in the root folder of the project. Since it's running in production mode, you should not see any message such as: **_"Server started at port 5000"_** 119 | 120 | ```bash 121 | $ npm run start 122 | ``` 123 | 124 | Your app should now be running on the port you specified in the .env file. If none was specified it will default to **port 5000**. 125 | 126 | Eg. [localhost:5000](localhost:5000). 127 | 128 | ### Deploying to Heroku 129 | 130 | _Ensure you have [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) installed_ 131 | 132 | 1. Login to heroku via the CLI 133 | 134 | ```bash 135 | $ heroku login 136 | ``` 137 | 138 | 2. Create a new Heroku Application 139 | 140 | ```bash 141 | $ heroku create 142 | ``` 143 | 144 | 3. Before pushing to heroku, you need to set up the config variables in other words the env variables you would use if you were doing this locally 145 | 146 | i. Go to Settings -> Reveal Config Vars 147 | 148 | ii. Add the config variables according to the .env.example 149 | 150 | iii. These Include 151 | 152 | ```bash 153 | HEROKU_DEPLOYMENT=true 154 | DATABASE_URL 155 | FACEBOOK_CLIENT_ID 156 | FACEBOOK_CLIENT_SECRET 157 | GOOGLE_CLIENT_ID 158 | GOOGLE_CLIENT_SECRET 159 | JWT_SECRET 160 | NPM_CONFIG_PRODUCTION (Must be false) 161 | PORT (Optional) 162 | ``` 163 | 164 | iv. Ensure that you add NPM_CONFIG_PRODUCTION to false to allow installation of dev dependencies for post build to work correctly 165 | 166 | 4. Commit any changes and push your code from local repo to your git 167 | ```bash 168 | $ git add -A 169 | $ git commit -m "insert message here" 170 | $ git push heroku master 171 | ``` 172 | 173 | 5. Open the heroku app 174 | 175 | ```bash 176 | $ heroku open 177 | ``` 178 | 179 | _Note: You may also connect your github repo to the heroku and add automatic deployment on push to the github repo_ 180 | 181 | ## Seeding Data 182 | 183 | If at anytime in development you'd like to quickly seed some dummy data you use the command below 184 | 185 | ```bash 186 | $ npm run seed:data 187 | ``` 188 | 189 | ## Running Tests 190 | 191 | Tests should be run before every commit to ensure the build is not broken by any code changes. 192 | 193 | Running Both Client and Server Tests 194 | ```javascript 195 | In the root directory 196 | $ npm run test 197 | ``` 198 | 199 | Running Client Tests 200 | ```javascript 201 | $ npm run client:test 202 | ``` 203 | 204 | Watching Server Tests 205 | ```javascript 206 | $ npm run server:test:watch 207 | ``` 208 | 209 | ## Configuration Setup 210 | 211 | These configuration setups are necessary for the app to function correctly as intended. These configuration setups will be required to be added as environment variables for the server to make use of. 212 | 213 | ### Local Environment Variables (.env file) 214 | For development you will need a .env file for environmental variables. 215 | 216 | **_Note: These are required for the application to be setup correctly_** 217 | 218 | ```bash 219 | NODE_ENV=development 220 | HEROKU_DEPLOYMENT=false 221 | 222 | DATABASE_URL=DATABASE_URL 223 | 224 | EXPRESS_SESSION_KEY=EXPRESS_SESSION_KEY 225 | JWT_SECRET=JWT_SECRET 226 | 227 | GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID 228 | GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET 229 | 230 | FACEBOOK_CLIENT_ID=FACEBOOK_CLIENT_ID 231 | FACEBOOK_CLIENT_SECRET=FACEBOOK_CLIENT_SECRET 232 | 233 | PORT=PORT 234 | ``` 235 | 236 | ### MongoDB & Mongo Atlas 237 | 238 | A MongoDB URI is needed to connect to a MongoDB connection. The easiest way to do this is to use [Mongo Atlas](https://www.mongodb.com/cloud/atlas). If you'd like to do this locally you can follow the instructions at (https://docs.mongodb.com/manual/installation/) 239 | 240 | #### Mongo Atlas 241 | 242 | 1. Select 'Build a New Cluster' and follow the prompts 243 | 244 | 2. When the Cluster has been created, click on 'Connect' 245 | 246 | 3. Choose your connection method, for the purposes of this application we will use 'Connect Your Application' 247 | 248 | 4. Next you will need to grab this connection string (Standard connection string). This is the URI that will be used as an environment variable 249 | 250 | ### JWT Secret 251 | 252 | The JWT Secret is required as a way to keep the JWT Token secure when the signature is hashed. This secret key should be secret to you and should be updated periodically. 253 | 254 | ### Google 255 | 256 | To setup google oauth, you'll need to configure some details through Google Cloud Platform 257 | 258 | 1. Navigate to https://console.cloud.google.com/ 259 | 260 | 2. Using 'APIs & Services', you'll need to enable the 'Google+ API' 261 | 262 | 3. Once enabled, click on 'Credentials' 263 | 264 | 4. Go to 'OAuth Consent Screen', you will need to add the 'Authorized Domains' to authorize your domain with Google 265 | 266 | 5. You will need to save the Client ID and Client Secret for use in the environment variables 267 | 268 | 6. You will also need to add the domain you are using ie. localhost or heroku to both 'Authorized Javascript Origins' and 'Authorized Redirect URIs' 269 | 270 | i. The redirect URIs are in the format of domain/api/auth/provider/redirect 271 | 272 | ### Facebook 273 | 274 | To setup facebook oauth, you'll need to configure some details through Facebook for Developers 275 | 276 | 1. Login at https://developers.facebook.com/ 277 | 278 | 2. Go to 'My Apps' and create a new app 279 | 280 | 3. Navigate to Settings -> basic 281 | 282 | 4. Save the App ID and App Secret for use in environment variables 283 | 284 | 5. Add your app domain in 'App Domains' 285 | 286 | 6. Under Products -> Facebook Login -> Settings, Add your redirect URIs under 'Valid OAuth Redirect URIs' 287 | 288 | i. The redirect URIs are in the format of domain/api/auth/provider/redirect 289 | 290 | ## Contribute 291 | 292 | Built as a personal project for learning experience. Please feel free to contribute by creating issues, submitting new pull requests! 293 | -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | jest: true 6 | }, 7 | extends: ['plugin:vue/essential', '@vue/prettier'], 8 | rules: { 9 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 10 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 11 | 'indent': 'off', 12 | 'no-console': 'off', 13 | 'vue/script-indent': [ { 14 | 'baseIndent': 1 15 | }] 16 | }, 17 | parserOptions: { 18 | parser: 'babel-eslint' 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | .env 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | server/logs/* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw* 24 | -------------------------------------------------------------------------------- /client/.postcssrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "semi": true, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /client/App.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 73 | 74 | 77 | -------------------------------------------------------------------------------- /client/assets/config/particlesjs-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "particles": { 3 | "number": { 4 | "value": 30, 5 | "density": { 6 | "enable": true, 7 | "value_area": 800 8 | } 9 | }, 10 | "color": { 11 | "value": "#ffffff" 12 | }, 13 | "shape": { 14 | "type": "circle", 15 | "stroke": { 16 | "width": 0, 17 | "color": "#000000" 18 | }, 19 | "polygon": { 20 | "nb_sides": 5 21 | }, 22 | "image": { 23 | "src": "img/github.svg", 24 | "width": 100, 25 | "height": 100 26 | } 27 | }, 28 | "opacity": { 29 | "value": 0.5, 30 | "random": false, 31 | "anim": { 32 | "enable": false, 33 | "speed": 1, 34 | "opacity_min": 0.1, 35 | "sync": false 36 | } 37 | }, 38 | "size": { 39 | "value": 3, 40 | "random": true, 41 | "anim": { 42 | "enable": false, 43 | "speed": 40, 44 | "size_min": 0.1, 45 | "sync": false 46 | } 47 | }, 48 | "line_linked": { 49 | "enable": false, 50 | "distance": 150, 51 | "color": "#ffffff", 52 | "opacity": 0.1, 53 | "width": 1 54 | }, 55 | "move": { 56 | "enable": true, 57 | "speed": 6, 58 | "direction": "none", 59 | "random": false, 60 | "straight": false, 61 | "out_mode": "out", 62 | "bounce": false, 63 | "attract": { 64 | "enable": false, 65 | "rotateX": 600, 66 | "rotateY": 1200 67 | } 68 | } 69 | }, 70 | "interactivity": { 71 | "detect_on": "canvas", 72 | "events": { 73 | "onhover": { 74 | "enable": true, 75 | "mode": "repulse" 76 | }, 77 | "onclick": { 78 | "enable": true, 79 | "mode": "push" 80 | }, 81 | "resize": true 82 | }, 83 | "modes": { 84 | "grab": { 85 | "distance": 400, 86 | "line_linked": { 87 | "opacity": 1 88 | } 89 | }, 90 | "bubble": { 91 | "distance": 400, 92 | "size": 40, 93 | "duration": 2, 94 | "opacity": 8, 95 | "speed": 3 96 | }, 97 | "repulse": { 98 | "distance": 200, 99 | "duration": 0.4 100 | }, 101 | "push": { 102 | "particles_nb": 4 103 | }, 104 | "remove": { 105 | "particles_nb": 2 106 | } 107 | } 108 | }, 109 | "retina_detect": true 110 | } 111 | -------------------------------------------------------------------------------- /client/assets/img/BackgroundPath.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /client/assets/img/astro-chat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luvuong-le/node-vue-chat/86efe50c8f688e3859d2986056b2fc95e3a6a7e5/client/assets/img/astro-chat.gif -------------------------------------------------------------------------------- /client/assets/img/icons8-account-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luvuong-le/node-vue-chat/86efe50c8f688e3859d2986056b2fc95e3a6a7e5/client/assets/img/icons8-account-64.png -------------------------------------------------------------------------------- /client/assets/img/icons8-businessman.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /client/assets/img/undraw_programmer_imem.svg: -------------------------------------------------------------------------------- 1 | programmer -------------------------------------------------------------------------------- /client/assets/scss/abstract/animations.scss: -------------------------------------------------------------------------------- 1 | /** Fade Animation */ 2 | .fade-enter, 3 | .fade-leave-to { 4 | opacity: 0; 5 | } 6 | 7 | .fade-leave, 8 | .fade-enter-to { 9 | opacity: 1; 10 | } 11 | 12 | /** Slide In Animation */ 13 | .slideDown-enter, 14 | .slideDown-leave-to { 15 | opacity: 0; 16 | transform: translateY(-50px); 17 | } 18 | 19 | .slideDown-leave, 20 | .slideDown-enter-to { 21 | opacity: 1; 22 | transform: translateY(0px); 23 | } 24 | 25 | /** Slide Up Animation */ 26 | .slideUp-enter, 27 | .slideUp-leave-to { 28 | opacity: 0; 29 | transform: translateY(-50px); 30 | } 31 | 32 | .slideUp-leave, 33 | .slideUp-enter-to { 34 | opacity: 1; 35 | transform: translateY(0px); 36 | } 37 | 38 | /** Slide Left Animation */ 39 | .slideLeft-enter, 40 | .slideLeft-leave-to { 41 | opacity: 0; 42 | transform: translateX(-100%); 43 | } 44 | 45 | .slideLeft-leave, 46 | .slideLeft-enter-to { 47 | opacity: 1; 48 | transform: translateX(0); 49 | } 50 | -------------------------------------------------------------------------------- /client/assets/scss/abstract/keyframes.scss: -------------------------------------------------------------------------------- 1 | @keyframes hover { 2 | 0% { 3 | transform: translateY(0); 4 | } 5 | 20% { 6 | transform: translateY(5px); 7 | } 8 | 40% { 9 | transform: translateY(10px); 10 | } 11 | 60% { 12 | transform: translateY(15px); 13 | } 14 | 80% { 15 | transform: translateY(10px); 16 | } 17 | 100% { 18 | transform: translateY(0px); 19 | } 20 | } 21 | 22 | @keyframes fadeIn { 23 | from { 24 | opacity: 0; 25 | } 26 | to { 27 | opacity: 1; 28 | } 29 | } 30 | 31 | @keyframes zoomIn { 32 | 0% { 33 | opacity: 0; 34 | transform: translate(-20px, 20px); 35 | } 36 | 50% { 37 | opacity: 0.5; 38 | transform: translate(-10px, 10px); 39 | } 40 | 100% { 41 | opacity: 1; 42 | transform: translate(0) rotate(45deg); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client/assets/scss/abstract/mixins.scss: -------------------------------------------------------------------------------- 1 | // Media Query Breakpoints 2 | 3 | /* 4 | 5 | 0 - 768px: Smartphones 6 | 768 - 1024px: Tablet Landscape 7 | 1224 - 1824px : Desktops and Laptops 8 | 1824+ Larger Screens 9 | 10 | */ 11 | 12 | @mixin respond($breakpoint) { 13 | @if $breakpoint == phone { 14 | @media only screen and (min-width: 20em) and (max-width: 48em) { 15 | @content; 16 | } 17 | } 18 | 19 | @if $breakpoint == tablet-portrait { 20 | @media only screen and (min-width: 48em) and (max-width: 64em) and (orientation: portrait) { 21 | @content; 22 | } 23 | } 24 | 25 | @if $breakpoint == tablet-landscape { 26 | @media only screen and (min-width: 48em) and (max-width: 64em) { 27 | @content; 28 | } 29 | } 30 | 31 | @if $breakpoint == desktop { 32 | @media only screen and (min-width: 64em) and (max-width: 114em) { 33 | @content; 34 | } 35 | } 36 | } 37 | 38 | @mixin center() { 39 | position: absolute; 40 | top: 50%; 41 | left: 50%; 42 | transform: translate(-50%, -50%); 43 | } 44 | 45 | @mixin flex-center() { 46 | display: flex; 47 | justify-content: center; 48 | align-content: center; 49 | align-items: center; 50 | } 51 | -------------------------------------------------------------------------------- /client/assets/scss/abstract/util.scss: -------------------------------------------------------------------------------- 1 | /* Utility Classes */ 2 | 3 | .p-0 { 4 | padding: 0 !important; 5 | } 6 | 7 | .pt-3 { 8 | padding-top: 15px !important; 9 | } 10 | 11 | .m-0 { 12 | margin: 0 !important; 13 | } 14 | 15 | .mt-3 { 16 | margin-top: 30px; 17 | } 18 | 19 | .mt-6 { 20 | margin-top: 60px; 21 | } 22 | 23 | .mt-10 { 24 | margin-top: 100px; 25 | } 26 | 27 | .mb-3 { 28 | margin-bottom: 30px; 29 | } 30 | 31 | .mb-6 { 32 | margin-bottom: 60px; 33 | } 34 | 35 | .mb-10 { 36 | margin-bottom: 100px; 37 | } 38 | 39 | .mlzero { 40 | margin-left: 0 !important; 41 | } 42 | 43 | .icon-sm { 44 | font-size: 1.5em !important; 45 | } 46 | 47 | .icon-md { 48 | font-size: 3em !important; 49 | } 50 | 51 | .icon-lg { 52 | font-size: 4.5em !important; 53 | } 54 | 55 | .text-upper { 56 | text-transform: uppercase; 57 | } 58 | 59 | .u-max-height { 60 | height: 100%; 61 | max-height: 100%; 62 | } 63 | 64 | .u-border-rad-0 { 65 | border-radius: 0 !important; 66 | } 67 | 68 | .u-flex-right { 69 | justify-content: flex-end; 70 | } 71 | 72 | .u-flex-center { 73 | justify-content: center; 74 | } 75 | 76 | .heading-lg { 77 | font-size: 2.5rem; 78 | font-family: 'Russo One', sans-serif; 79 | } 80 | 81 | .center { 82 | text-align: center; 83 | } 84 | -------------------------------------------------------------------------------- /client/assets/scss/abstract/variables.scss: -------------------------------------------------------------------------------- 1 | $primary-container-background: #18191c; 2 | $primary-container-box-shadow: 1px 1px 1px 1px #000; 3 | -------------------------------------------------------------------------------- /client/assets/scss/base/base.scss: -------------------------------------------------------------------------------- 1 | @import '../abstract/keyframes.scss'; 2 | @import '../abstract/variables.scss'; 3 | @import '../abstract/util.scss'; 4 | @import '../abstract/animations.scss'; 5 | @import '../vendors/animate.scss'; 6 | 7 | @import '../components/social.scss'; 8 | 9 | *, 10 | *::before, 11 | *::after { 12 | padding: 0; 13 | margin: 0; 14 | box-sizing: border-box; 15 | } 16 | 17 | html, 18 | body { 19 | height: 100%; 20 | background: #18191c; 21 | font-family: 'Work Sans', sans-serif; 22 | } 23 | 24 | header { 25 | background: #18191c; 26 | border-bottom: 1px solid #fff; 27 | width: 100%; 28 | position: relative; 29 | z-index: 10; 30 | 31 | @include respond(phone) { 32 | position: fixed; 33 | top: 0; 34 | } 35 | } 36 | 37 | img { 38 | width: 100%; 39 | } 40 | 41 | nav a { 42 | text-decoration: none; 43 | color: #fff; 44 | margin: 0 0.5rem; 45 | } 46 | 47 | li .nav__link.router-link-exact-active { 48 | font-weight: bold; 49 | color: #fff; 50 | opacity: 1; 51 | } 52 | 53 | .app { 54 | overflow-x: hidden; 55 | height: auto; 56 | width: 100%; 57 | } 58 | 59 | .public, 60 | .private { 61 | font-weight: bold; 62 | } 63 | 64 | .public { 65 | color: lightgreen; 66 | } 67 | 68 | .private { 69 | color: crimson; 70 | } 71 | 72 | .lead { 73 | display: flex; 74 | justify-content: center; 75 | align-items: center; 76 | width: 100%; 77 | margin-bottom: 1.3em; 78 | text-align: center; 79 | font-size: 0.9em; 80 | line-height: 1.5em; 81 | color: rgba(255, 255, 255, 0.8); 82 | font-family: 'Russo One'; 83 | text-transform: uppercase; 84 | } 85 | 86 | .btn { 87 | text-decoration: none; 88 | color: #fff; 89 | text-align: center; 90 | font-size: 0.9em; 91 | margin: 0 1em; 92 | padding: 12px 50px; 93 | display: inline-block; 94 | cursor: pointer; 95 | transition: transform 1s ease, opacity 0.5s ease; 96 | 97 | &:hover { 98 | opacity: 0.5; 99 | } 100 | 101 | &--clear { 102 | border: 0; 103 | font-family: 'Work Sans'; 104 | } 105 | 106 | &--rounded { 107 | border-radius: 20px; 108 | border: 2px solid #fff; 109 | padding: 5px 17px 7px; 110 | } 111 | 112 | &--anim { 113 | animation: hover 1s linear infinite; 114 | } 115 | 116 | &--white { 117 | color: #000; 118 | background: #fff; 119 | border-radius: 5px; 120 | } 121 | 122 | &--coral { 123 | color: #fff; 124 | background: lightcoral; 125 | border-radius: 5px; 126 | } 127 | 128 | &--purple { 129 | color: #fff; 130 | background: #9070b4; 131 | border-radius: 5px; 132 | } 133 | 134 | &--success { 135 | color: #fff; 136 | background: lightgreen; 137 | border-radius: 5px; 138 | } 139 | 140 | &--danger { 141 | color: #fff; 142 | background: crimson; 143 | border-radius: 5px; 144 | } 145 | 146 | &--info { 147 | color: #fff; 148 | background: #3c9adf; 149 | border-radius: 5px; 150 | animation-delay: 0.8s; 151 | } 152 | 153 | &--modal { 154 | width: 100%; 155 | margin: 0; 156 | height: 100%; 157 | } 158 | } 159 | 160 | .badge { 161 | display: flex; 162 | border-radius: 15px; 163 | padding: 5px; 164 | height: auto; 165 | width: auto; 166 | justify-content: center; 167 | align-items: center; 168 | 169 | &--info { 170 | color: #fff; 171 | background: #3c9adf; 172 | } 173 | 174 | &--danger { 175 | color: #fff; 176 | background: crimson; 177 | } 178 | 179 | &--success { 180 | color: #fff; 181 | background: green; 182 | } 183 | } 184 | 185 | .icon { 186 | font-size: 1.5em; 187 | margin: 0 0.4em; 188 | } 189 | 190 | .page { 191 | min-height: 85vh; 192 | width: 100%; 193 | background: transparent; 194 | color: #fff; 195 | background: url(./assets/img/BackgroundPath.svg) no-repeat; 196 | background-size: 100%; 197 | background-position: bottom center; 198 | background-attachment: fixed; 199 | position: relative; 200 | z-index: 2; 201 | overflow: hidden; 202 | 203 | @include respond(phone) { 204 | height: 100%; 205 | } 206 | } 207 | 208 | .section { 209 | width: 100%; 210 | position: relative; 211 | 212 | &__heading { 213 | font-size: 2.5rem; 214 | text-align: center; 215 | } 216 | 217 | &__title { 218 | width: auto; 219 | display: inline-block; 220 | font-family: 'Russo One', sans-serif; 221 | } 222 | 223 | &__lead { 224 | text-align: center; 225 | width: 50%; 226 | margin: 1em auto 3em auto; 227 | font-size: 0.9em; 228 | line-height: 1.5em; 229 | color: rgba(255, 255, 255, 0.8); 230 | 231 | @include respond(phone) { 232 | width: 100%; 233 | } 234 | } 235 | 236 | // Not required?? 237 | &:first-child { 238 | @include respond(phone) { 239 | height: 100%; 240 | } 241 | } 242 | 243 | &--mmt { 244 | @include respond(phone) { 245 | margin-top: 100px; 246 | } 247 | } 248 | 249 | &__landing { 250 | margin: 4em 0 10em 0; 251 | 252 | @include respond(phone) { 253 | margin: 8em 0 0 0; 254 | } 255 | } 256 | 257 | &__content { 258 | text-align: center; 259 | 260 | @include respond(phone) { 261 | padding: 0 20px 100px 20px; 262 | } 263 | } 264 | 265 | &__graphic { 266 | width: 80%; 267 | margin: 4em auto; 268 | 269 | & > img { 270 | width: 70%; 271 | @include respond(phone) { 272 | width: 100%; 273 | } 274 | } 275 | } 276 | 277 | &--room { 278 | height: calc(100vh - 68px); 279 | @include respond(phone) { 280 | margin-top: 70px; 281 | } 282 | } 283 | 284 | &--profile { 285 | height: 100vh; 286 | } 287 | } 288 | 289 | .particle { 290 | position: absolute; 291 | top: 0; 292 | left: 0; 293 | right: 0; 294 | bottom: 0; 295 | z-index: 1; 296 | overflow: hidden; 297 | } 298 | -------------------------------------------------------------------------------- /client/assets/scss/components/feature.scss: -------------------------------------------------------------------------------- 1 | .features { 2 | margin: 1rem 0; 3 | &__item { 4 | padding: 1.5rem 0; 5 | width: 70%; 6 | margin: 0.8rem auto; 7 | border-radius: 5px; 8 | background: rgba(255, 255, 255, 0.9); 9 | 10 | @include respond(phone) { 11 | width: 90%; 12 | } 13 | 14 | & > span { 15 | color: #000; 16 | font-size: 1.1rem; 17 | font-weight: 700; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /client/assets/scss/components/footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | color: #fff; 3 | width: 100%; 4 | padding: 1em; 5 | margin: 0 auto; 6 | background: transparent; 7 | text-align: center; 8 | border-top: 1px solid #fff; 9 | font-weight: bold; 10 | text-transform: uppercase; 11 | 12 | &__content { 13 | display: flex; 14 | width: 85%; 15 | margin: 0 auto; 16 | 17 | @include respond(phone) { 18 | flex-flow: column; 19 | } 20 | } 21 | 22 | &__left { 23 | width: 30%; 24 | text-align: left; 25 | align-self: center; 26 | 27 | @include respond(phone) { 28 | width: 100%; 29 | text-align: center; 30 | } 31 | } 32 | 33 | &__right { 34 | flex-grow: 1; 35 | text-align: right; 36 | display: flex; 37 | justify-content: flex-end; 38 | align-items: center; 39 | 40 | @include respond(phone) { 41 | margin: 0.5rem 0; 42 | flex-flow: column; 43 | 44 | & > span { 45 | margin: 0 0 5px 0; 46 | } 47 | } 48 | } 49 | 50 | &__icon { 51 | font-size: 2.5em; 52 | color: #fff; 53 | margin: 0 1rem; 54 | 55 | &--logo { 56 | transform: rotate(45deg); 57 | font-size: 1.8em; 58 | color: #fff; 59 | animation: zoomIn 0.5s linear; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/assets/scss/components/form.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | position: relative; 3 | z-index: 10; 4 | width: 480px; 5 | margin: 0 auto; 6 | padding: 3em 1em; 7 | background: #18191c; 8 | box-shadow: 1px 1px 1px 1px #000; 9 | transform: translateY(30px); 10 | 11 | &--nbs { 12 | background: none; 13 | box-shadow: none; 14 | } 15 | 16 | @include respond(phone) { 17 | width: 95%; 18 | } 19 | 20 | &__input-group { 21 | position: relative; 22 | width: auto; 23 | } 24 | 25 | &__info-group { 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | } 30 | 31 | &__label { 32 | position: absolute; 33 | top: 50%; 34 | left: 10px; 35 | opacity: 0.5; 36 | font-weight: bold; 37 | margin: -10px 22px; 38 | text-transform: uppercase; 39 | transition: top 0.5s ease, left 0.5s ease, opacity 1s ease; 40 | } 41 | 42 | &__control { 43 | position: relative; 44 | padding: 1em; 45 | border-radius: 3px; 46 | border: 1px solid #fff; 47 | background: transparent; 48 | margin: 1.5em 0 2.5em 0; 49 | z-index: 1; 50 | width: 90%; 51 | color: #fff; 52 | font-weight: bold; 53 | font-family: 'Work Sans', sans-serif; 54 | outline: none; 55 | 56 | &:focus + label, 57 | &:focus:valid + label, 58 | &:valid + label, 59 | &:focus:invalid + label { 60 | opacity: 1; 61 | top: 0; 62 | left: 0; 63 | } 64 | 65 | &:invalid + label { 66 | opacity: 0; 67 | } 68 | 69 | &:focus:invalid { 70 | border: 1px solid crimson; 71 | } 72 | 73 | &:focus:valid { 74 | border: 1px solid lightgreen; 75 | } 76 | } 77 | 78 | &__submit { 79 | text-decoration: none; 80 | color: #fff; 81 | text-align: center; 82 | margin: 1em 1em 0 1em; 83 | padding: 12px; 84 | width: 90%; 85 | display: inline-block; 86 | cursor: pointer; 87 | color: #fff; 88 | background: #3c9adf; 89 | border-radius: 3px; 90 | border: 0; 91 | font-weight: bold; 92 | text-transform: uppercase; 93 | font-size: 1em; 94 | font-family: 'Work Sans', sans-serif; 95 | } 96 | 97 | &__link { 98 | text-decoration: none; 99 | color: #fff; 100 | font-weight: bold; 101 | 102 | @include respond(phone) { 103 | margin: 0.5rem; 104 | } 105 | } 106 | 107 | &__icon { 108 | position: absolute; 109 | top: 30px; 110 | right: 15px; 111 | font-size: 1.5em; 112 | margin: 0 0.8em; 113 | z-index: 2; 114 | } 115 | 116 | &__lead { 117 | display: flex; 118 | justify-content: center; 119 | align-items: center; 120 | width: 100%; 121 | margin-bottom: 1.3em; 122 | text-align: center; 123 | font-size: 0.9em; 124 | line-height: 1.5em; 125 | color: rgba(255, 255, 255, 0.8); 126 | } 127 | 128 | &__error { 129 | display: inline-block; 130 | width: 90%; 131 | background: rgba(79%, 19%, 17%, 0.9); 132 | border-radius: 3px; 133 | margin: 1em; 134 | padding: 12px; 135 | color: #fff; 136 | font-weight: bold; 137 | 138 | & span { 139 | display: block; 140 | } 141 | } 142 | 143 | &__actions { 144 | width: 100%; 145 | 146 | @include respond(phone) { 147 | & > * { 148 | width: 100%; 149 | margin: 0.5em 0; 150 | } 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /client/assets/scss/components/infobox.scss: -------------------------------------------------------------------------------- 1 | .infobox { 2 | position: relative; 3 | padding: 0em 1em; 4 | 5 | &__container { 6 | position: relative; 7 | z-index: 10; 8 | width: 480px; 9 | margin: 0 auto 3rem auto; 10 | padding: 3em 1em; 11 | background: #18191c; 12 | box-shadow: 1px 1px 1px 1px #000; 13 | transform: translateY(30px); 14 | 15 | @include respond(phone) { 16 | width: 95%; 17 | overflow: auto; 18 | } 19 | } 20 | 21 | &__item { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | padding: 0.5rem; 26 | margin: 0.5em 0; 27 | 28 | @include respond(phone) { 29 | flex-flow: column; 30 | } 31 | 32 | & span { 33 | font-weight: bold; 34 | } 35 | 36 | &--left { 37 | text-align: left; 38 | text-transform: uppercase; 39 | margin: 0 2rem 0 0; 40 | display: inline-block; 41 | width: 80px; 42 | 43 | @include respond(phone) { 44 | width: 100%; 45 | text-align: left; 46 | margin: 8px 0; 47 | } 48 | } 49 | 50 | &--right { 51 | flex-grow: 1; 52 | display: inline-block; 53 | background: rgba(117, 121, 138, 0.1); 54 | border-radius: 5px; 55 | padding: 0.6em; 56 | text-align: right; 57 | 58 | @include respond(phone) { 59 | width: 100%; 60 | text-align: left; 61 | } 62 | } 63 | } 64 | 65 | &__actions { 66 | & a { 67 | @include respond(phone) { 68 | width: 100%; 69 | margin: 0.5em 0; 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /client/assets/scss/components/modal.scss: -------------------------------------------------------------------------------- 1 | @import '@/assets/scss/abstract/variables.scss'; 2 | 3 | .modal { 4 | height: 100vh; 5 | width: 100%; 6 | position: fixed; 7 | top: 0; 8 | bottom: 0; 9 | right: 0; 10 | left: 0; 11 | background: rgba(0, 0, 0, 0.8); 12 | z-index: 20; 13 | transition: opacity 1s ease; 14 | @include flex-center(); 15 | 16 | &__content { 17 | font-family: 'Work Sans'; 18 | width: 500px; 19 | height: 500px; 20 | background: #18191c; 21 | border-radius: 5px; 22 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33); 23 | color: #fff; 24 | position: absolute; 25 | @include center(); 26 | transition: opacity 1s ease, transform 1s cubic-bezier(0.42, 0, 0.2, 1.51); 27 | 28 | @include respond(phone) { 29 | width: 90%; 30 | margin-top: 2rem; 31 | } 32 | } 33 | 34 | &__header { 35 | position: relative; 36 | bottom: 0; 37 | padding: 2rem 1rem; 38 | height: 15%; 39 | text-align: center; 40 | } 41 | 42 | &__body { 43 | height: 75%; 44 | text-align: center; 45 | 46 | @include respond(phone) { 47 | overflow: auto; 48 | } 49 | } 50 | 51 | &__footer { 52 | position: absolute; 53 | bottom: 0; 54 | width: 100%; 55 | height: 10%; 56 | border-top: 1px solid white; 57 | z-index: 3; 58 | 59 | &:hover { 60 | background: rgba(0, 0, 0, 0.6); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/assets/scss/components/navbar.scss: -------------------------------------------------------------------------------- 1 | .nav { 2 | display: flex; 3 | align-items: center; 4 | width: 85%; 5 | padding: 1em 0; 6 | margin: 0 auto; 7 | 8 | @include respond(tablet-landscape) { 9 | width: 90%; 10 | } 11 | 12 | &__link { 13 | color: rgba(255, 255, 255, 0.7); 14 | transition: opacity 0.5s ease; 15 | 16 | &--rounded { 17 | border-radius: 20px; 18 | border: 2px solid #fff; 19 | padding: 5px 17px 7px; 20 | 21 | &:hover { 22 | opacity: 0.5; 23 | } 24 | } 25 | 26 | &--btn { 27 | background: transparent; 28 | font-family: 'Work Sans', sans-serif; 29 | color: #fff; 30 | cursor: pointer; 31 | } 32 | } 33 | 34 | &__item { 35 | list-style: none; 36 | font-size: 0.8em; 37 | display: flex; 38 | align-items: center; 39 | } 40 | } 41 | 42 | .navbar { 43 | &__brand { 44 | font-weight: bold; 45 | text-transform: uppercase; 46 | letter-spacing: 0.1em; 47 | font-size: 1.2em; 48 | display: flex; 49 | align-items: center; 50 | } 51 | 52 | &__textbrand { 53 | font-family: 'Russo One', sans-serif; 54 | animation: fadeIn 0.5s ease; 55 | } 56 | 57 | &__icon { 58 | font-size: 2.5em; 59 | color: #fff; 60 | 61 | &--logo { 62 | transform: rotate(45deg); 63 | font-size: 1.8em; 64 | color: #fff; 65 | animation: zoomIn 0.5s linear; 66 | } 67 | } 68 | 69 | &__nav { 70 | display: flex; 71 | margin: 0 1em; 72 | align-items: center; 73 | 74 | @include respond(phone) { 75 | display: none; 76 | } 77 | 78 | &--right { 79 | flex-grow: 1; 80 | text-align: right; 81 | justify-content: flex-end; 82 | } 83 | } 84 | 85 | &__toggle { 86 | position: relative; 87 | z-index: 10; 88 | display: none; 89 | flex-grow: 1; 90 | text-align: right; 91 | justify-content: flex-end; 92 | cursor: pointer; 93 | 94 | @include respond(phone) { 95 | display: initial; 96 | } 97 | 98 | &--icon { 99 | font-size: 2em; 100 | } 101 | } 102 | } 103 | 104 | .snav { 105 | position: fixed; 106 | width: 100%; 107 | height: 100%; 108 | z-index: 99; 109 | padding: 1em; 110 | right: 0; 111 | border-top: 1px solid #fff; 112 | background: url(../../assets/img/BackgroundPath.svg) no-repeat, rgba(24, 25, 28, 1); 113 | background-size: 100%; 114 | background-position: bottom center; 115 | transform: translateX(100%); 116 | transition: transform 1s cubic-bezier(0.19, 1, 0.22, 1); 117 | 118 | &--shown { 119 | transform: translateX(0); 120 | } 121 | 122 | &__nav { 123 | position: relative; 124 | z-index: 50; 125 | display: flex; 126 | flex-flow: column; 127 | margin: 0 1em; 128 | align-items: flex-start; 129 | 130 | @include respond(phone) { 131 | } 132 | } 133 | 134 | &__item { 135 | list-style: none; 136 | width: 100%; 137 | padding: 1em; 138 | 139 | & > a { 140 | color: #fff; 141 | font-weight: bold; 142 | } 143 | 144 | @include respond(phone) { 145 | text-align: center; 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /client/assets/scss/components/social.scss: -------------------------------------------------------------------------------- 1 | .social { 2 | display: flex; 3 | justify-content: space-between; 4 | width: 480px; 5 | margin: 0 auto; 6 | 7 | @include respond(phone) { 8 | flex-flow: column; 9 | justify-content: center; 10 | align-content: center; 11 | align-items: center; 12 | width: 100%; 13 | } 14 | 15 | &__btn { 16 | margin: 0; 17 | padding: 0; 18 | 19 | @include respond(phone) { 20 | width: 300px; 21 | margin: 5px auto; 22 | padding: 0 1em; 23 | } 24 | } 25 | 26 | &__item { 27 | padding: 1rem; 28 | display: flex; 29 | align-items: center; 30 | 31 | &--facebook { 32 | background: #294c7c; 33 | } 34 | 35 | &--google { 36 | background: #ea4436; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/assets/scss/views/chat.scss: -------------------------------------------------------------------------------- 1 | .chat { 2 | height: 100%; 3 | position: relative; 4 | display: flex; 5 | 6 | @include respond(phone) { 7 | height: calc(100vh - 70px); 8 | } 9 | 10 | &__content { 11 | height: 100%; 12 | width: 100%; 13 | display: flex; 14 | flex-flow: column; 15 | background: rgba(24, 25, 28, 0.6); 16 | margin-left: 300px; 17 | overflow: hidden; 18 | transition: all 0.5s ease; 19 | 20 | @include respond(phone) { 21 | margin-left: 0px; 22 | } 23 | } 24 | 25 | &__header { 26 | text-align: left; 27 | padding: 1rem 0; 28 | margin: 0 1rem; 29 | display: flex; 30 | align-items: center; 31 | background: rgba($color: #18191c, $alpha: 0.8); 32 | border-bottom: 1px solid #ccc; 33 | } 34 | 35 | &__actions { 36 | flex-grow: 1; 37 | text-align: right; 38 | 39 | & > * { 40 | cursor: pointer; 41 | } 42 | } 43 | 44 | /** User List */ 45 | 46 | /** User List Container */ 47 | &__c-userlist { 48 | position: relative; 49 | } 50 | 51 | /** User List [UL]*/ 52 | &__userlist { 53 | display: flex; 54 | flex-flow: column; 55 | margin: 0; 56 | padding: 1rem; 57 | list-style-type: none; 58 | } 59 | 60 | &__user { 61 | display: flex; 62 | width: 100%; 63 | margin: 1rem 0; 64 | transition: all 0.5s ease; 65 | 66 | &-item { 67 | display: flex; 68 | align-items: center; 69 | padding: 0 0.5rem; 70 | width: 100%; 71 | } 72 | 73 | &-image { 74 | height: 60px; 75 | width: 60px; 76 | } 77 | 78 | &-avatar { 79 | height: 60px; 80 | width: 60px; 81 | border-radius: 100%; 82 | border: 2px solid green; 83 | } 84 | 85 | &-details { 86 | display: flex; 87 | align-items: center; 88 | margin: 0 2rem; 89 | width: 80%; 90 | font-family: 'Russo One'; 91 | } 92 | } 93 | 94 | /** Message List */ 95 | &__c-messagelist { 96 | height: 100%; 97 | padding: 0; 98 | background: rgba($color: #18191c, $alpha: 0.8); 99 | overflow: hidden; 100 | 101 | @include respond(phone) { 102 | height: 100%; 103 | padding: 0; 104 | } 105 | } 106 | 107 | &__messages { 108 | margin: 0; 109 | list-style: none; 110 | padding: 0 1rem; 111 | overflow: auto; 112 | height: 100%; 113 | 114 | @include respond(phone) { 115 | height: 100%; 116 | } 117 | } 118 | 119 | &__message { 120 | margin: 2rem 0; 121 | transition: opacity 0.5s ease, transform 0.5s ease; 122 | 123 | &-item { 124 | display: flex; 125 | } 126 | 127 | &-details { 128 | margin: 0 1rem; 129 | display: flex; 130 | color: rgb(158, 158, 158); 131 | 132 | & span { 133 | margin: 0 5px; 134 | } 135 | } 136 | 137 | &-content { 138 | position: relative; 139 | background: #3c9adf; 140 | padding: 0.5rem; 141 | border-radius: 8px; 142 | text-align: left; 143 | margin: 0 1rem; 144 | align-self: center; 145 | font-family: 'Work Sans'; 146 | white-space: pre-line; 147 | 148 | &--left::before { 149 | content: ''; 150 | position: absolute; 151 | top: 50%; 152 | left: -6px; 153 | border-radius: 10px; 154 | border-color: transparent #3c9adf transparent transparent; 155 | border-width: 10px; 156 | border-style: solid; 157 | transform: translate(-50%, -50%); 158 | } 159 | 160 | &--right::before { 161 | content: ''; 162 | position: absolute; 163 | top: 50%; 164 | bottom: 0px; 165 | right: -26px; 166 | border-radius: 10px; 167 | border-color: transparent transparent transparent #3c9adf; 168 | border-width: 10px; 169 | border-style: solid; 170 | transform: translate(-50%, -50%); 171 | } 172 | } 173 | } 174 | 175 | &__utyping { 176 | border-radius: 5px; 177 | padding: 0.5rem 0 0.5rem 1rem; 178 | margin: 10px 1rem; 179 | text-align: left; 180 | text-overflow: ellipsis; 181 | white-space: nowrap; 182 | overflow: hidden; 183 | transition: all 0.5s ease; 184 | background: #101114; 185 | 186 | & > span { 187 | font-family: 'Work Sans'; 188 | transition: all 0.5s ease; 189 | } 190 | } 191 | 192 | /** Input List */ 193 | &__input { 194 | overflow: hidden; 195 | display: flex; 196 | padding: 1rem 0; 197 | margin: 0 1rem; 198 | border-top: 1px solid #fff; 199 | 200 | &-control { 201 | position: relative; 202 | padding: 1rem 0 1rem 0.5rem; 203 | font-family: 'Russo One'; 204 | font-size: 0.9rem !important; 205 | width: 100%; 206 | resize: none; 207 | box-sizing: border-box; 208 | } 209 | } 210 | } 211 | 212 | .userlist { 213 | &__actions { 214 | width: 100%; 215 | display: flex; 216 | align-items: center; 217 | 218 | & div:last-child { 219 | flex-grow: 1; 220 | text-align: right; 221 | } 222 | 223 | @include respond(phone) { 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /client/assets/scss/views/profile.scss: -------------------------------------------------------------------------------- 1 | .profile { 2 | height: 100%; 3 | 4 | &__content { 5 | @include respond(phone) { 6 | margin-top: 100px; 7 | } 8 | } 9 | 10 | &__image { 11 | width: 100px; 12 | height: 100px; 13 | border-radius: 100%; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/assets/scss/views/rooms.scss: -------------------------------------------------------------------------------- 1 | @import '../abstract/variables.scss'; 2 | 3 | .rooms { 4 | background: $primary-container-background; 5 | box-shadow: $primary-container-box-shadow; 6 | width: 60%; 7 | max-width: 600px; 8 | margin: 1rem auto; 9 | 10 | @include respond(phone) { 11 | width: 100%; 12 | } 13 | 14 | &__header { 15 | padding: 1rem 0; 16 | } 17 | 18 | &__details { 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | 23 | @include respond(phone) { 24 | margin: 0 0.8rem; 25 | } 26 | 27 | &-item { 28 | display: flex; 29 | align-items: center; 30 | font-weight: bold; 31 | 32 | @include respond(phone) { 33 | text-align: left; 34 | } 35 | } 36 | 37 | & span { 38 | margin: 0 10px; 39 | } 40 | } 41 | 42 | &__list { 43 | margin: 0; 44 | padding: 0 1rem; 45 | height: 500px; 46 | max-height: 500px; 47 | overflow: auto; 48 | 49 | &-item { 50 | list-style: none; 51 | margin: 0 0 1rem 0; 52 | transition: opacity 0.5s ease, transform 0.5s ease; 53 | 54 | &:hover { 55 | opacity: 0.6; 56 | } 57 | 58 | &-link { 59 | text-decoration: none; 60 | color: #fff; 61 | } 62 | } 63 | } 64 | 65 | &__item { 66 | &-container { 67 | display: flex; 68 | padding: 1rem; 69 | border-radius: 3px; 70 | border: 1px solid #eeeeee; 71 | 72 | @include respond(phone) { 73 | flex-flow: column; 74 | } 75 | } 76 | 77 | &-details { 78 | width: 50%; 79 | text-align: left; 80 | 81 | @include respond(phone) { 82 | width: 100%; 83 | text-align: center; 84 | } 85 | } 86 | 87 | /** List Item Actions */ 88 | &-actions { 89 | display: flex; 90 | justify-content: flex-end; 91 | 92 | @include respond(phone) { 93 | justify-content: center; 94 | } 95 | } 96 | 97 | &-action { 98 | width: 50%; 99 | align-self: center; 100 | text-align: right; 101 | 102 | @include respond(phone) { 103 | width: initial; 104 | margin: 0.5rem 0; 105 | } 106 | } 107 | } 108 | 109 | &__actions { 110 | padding: 1rem; 111 | 112 | width: 100%; 113 | 114 | @include respond(phone) { 115 | & > * { 116 | width: 100%; 117 | margin: 0.5em 0; 118 | } 119 | } 120 | } 121 | 122 | &__search-input { 123 | font-family: 'Work Sans'; 124 | position: relative; 125 | padding: 1rem; 126 | border-radius: 3px; 127 | border: 1px solid #fff; 128 | background: transparent; 129 | margin: 1.5rem 0 0.5rem 0; 130 | z-index: 1; 131 | width: 95%; 132 | color: #fff; 133 | font-weight: bold; 134 | font-family: 'Work Sans', sans-serif; 135 | 136 | @include respond(phone) { 137 | width: 90%; 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/app'] 3 | }; 4 | -------------------------------------------------------------------------------- /client/components/auth/Login.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 133 | 134 | 135 | 138 | -------------------------------------------------------------------------------- /client/components/auth/Register.vue: -------------------------------------------------------------------------------- 1 | 71 | 72 | 143 | 144 | 145 | 148 | -------------------------------------------------------------------------------- /client/components/chat/ChatInput.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 66 | 67 | 68 | 70 | -------------------------------------------------------------------------------- /client/components/chat/MessageList.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 87 | 118 | 119 | 120 | 122 | -------------------------------------------------------------------------------- /client/components/error/Error.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 | 38 | 39 | 40 | 42 | -------------------------------------------------------------------------------- /client/components/error/NotFound.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | 23 | 58 | -------------------------------------------------------------------------------- /client/components/layout/Footer.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 26 | 27 | 30 | -------------------------------------------------------------------------------- /client/components/layout/Modal.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 55 | 56 | 59 | -------------------------------------------------------------------------------- /client/components/layout/Navbar.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 132 | 133 | 136 | -------------------------------------------------------------------------------- /client/components/layout/Particle.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 28 | -------------------------------------------------------------------------------- /client/components/layout/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 36 | 37 | 38 | 77 | -------------------------------------------------------------------------------- /client/components/layout/SignedInLinks.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 39 | 40 | 43 | -------------------------------------------------------------------------------- /client/components/profile/Profile.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 62 | 63 | 64 | 67 | -------------------------------------------------------------------------------- /client/components/social/OAuth.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 86 | 87 | 90 | -------------------------------------------------------------------------------- /client/components/user/EditUserProfile.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 164 | 165 | 166 | 170 | -------------------------------------------------------------------------------- /client/components/user/UserProfile.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 120 | 121 | 122 | 126 | -------------------------------------------------------------------------------- /client/helpers/user.js: -------------------------------------------------------------------------------- 1 | import store from '../store'; 2 | import _ from 'lodash'; 3 | import axios from 'axios'; 4 | 5 | export const checkUserData = async next => { 6 | if (localStorage.getItem('authToken')) { 7 | if (_.isEmpty(store.getters.getUserData)) { 8 | const res = await axios.get('/api/user/current'); 9 | 10 | if (res.data) { 11 | await store.dispatch('saveUserData', res.data); 12 | await store.dispatch('toggleAuthState', true); 13 | 14 | next(); 15 | } 16 | } else { 17 | next(); 18 | } 19 | } else { 20 | next(); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['js', 'jsx', 'json', 'vue'], 3 | transform: { 4 | '^.+\\.vue$': 'vue-jest', 5 | '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', 6 | '^.+\\.jsx?$': 'babel-jest' 7 | }, 8 | moduleNameMapper: { 9 | '^@/(.*)$': '/$1' 10 | }, 11 | snapshotSerializers: ['jest-serializer-vue'], 12 | testMatch: ['**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'], 13 | testURL: 'http://localhost/' 14 | }; 15 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | import store from './store'; 5 | import axios from 'axios'; 6 | import io from 'socket.io-client'; 7 | import setAuthToken from './utils/authToken'; 8 | import moment from 'moment'; 9 | 10 | Vue.config.productionTip = false; 11 | Vue.config.ignoredElements = ['ion-icons', /^ion-/]; 12 | Vue.prototype.moment = moment; 13 | 14 | let socket = null; 15 | 16 | /** Socket IO Client - Store in Vuex State for use in components */ 17 | if (process.env.NODE_ENV === 'development') { 18 | socket = io('http://localhost:5000'); 19 | } else { 20 | socket = io('/'); 21 | } 22 | 23 | store.dispatch('assignSocket', socket); 24 | 25 | /** Check for auth token on refresh and set authorization header for incoming requests */ 26 | if (localStorage.authToken) { 27 | setAuthToken(localStorage.authToken); 28 | } else { 29 | setAuthToken(null); 30 | } 31 | 32 | /** Axios Request Intercept */ 33 | axios.interceptors.request.use( 34 | function(config) { 35 | return config; 36 | }, 37 | function(err) { 38 | return Promise.reject(err); 39 | } 40 | ); 41 | 42 | /** Axios Response Intercept */ 43 | axios.interceptors.response.use( 44 | function(response) { 45 | return response; 46 | }, 47 | function(err) { 48 | if (err.response.status === 401) { 49 | localStorage.removeItem('authToken'); 50 | store.dispatch('toggleAuthState', false); 51 | router.push({ 52 | name: 'Login', 53 | params: { message: 'Session has expired, please login again' } 54 | }); 55 | } 56 | return Promise.reject(err); 57 | } 58 | ); 59 | 60 | new Vue({ 61 | router, 62 | store, 63 | render: h => h(App) 64 | }).$mount('#app'); 65 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-chat-client", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "test": "vue-cli-service test:unit" 10 | }, 11 | "author": "Lu-Vuong ", 12 | "license": "MIT", 13 | "dependencies": { 14 | "axios": "^0.18.0", 15 | "lodash": "^4.17.11", 16 | "moment": "^2.22.2", 17 | "particles.js": "^2.0.0", 18 | "slugify": "^1.3.4", 19 | "socket.io-client": "^2.2.0", 20 | "typed.js": "^2.0.9", 21 | "vue": "^2.5.17", 22 | "vue-router": "^3.0.1", 23 | "vuex": "^3.0.1" 24 | }, 25 | "devDependencies": { 26 | "@vue/cli-plugin-babel": "^3.0.0-rc.12", 27 | "@vue/cli-plugin-eslint": "^3.0.0-rc.12", 28 | "@vue/cli-plugin-unit-jest": "^3.1.0", 29 | "@vue/cli-service": "^3.0.0-rc.12", 30 | "@vue/eslint-config-prettier": "^3.0.5", 31 | "@vue/test-utils": "^1.0.0-beta.20", 32 | "babel-core": "7.0.0-bridge.0", 33 | "babel-jest": "^23.0.1", 34 | "node-sass": "^4.9.0", 35 | "sass-loader": "^7.0.1", 36 | "vue-template-compiler": "^2.5.17" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luvuong-le/node-vue-chat/86efe50c8f688e3859d2986056b2fc95e3a6a7e5/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Astro Chat 11 | 12 | 13 | 14 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /client/router.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import Vue from 'vue'; 3 | import Router from 'vue-router'; 4 | import { checkUserData } from './helpers/user'; 5 | import store from './store'; 6 | 7 | Vue.use(Router); 8 | 9 | const router = new Router({ 10 | mode: 'history', 11 | base: process.env.BASE_URL, 12 | routes: [ 13 | { 14 | path: '/', 15 | name: 'Home', 16 | component: () => import('@/views/Home.vue'), 17 | meta: { 18 | requiresAuth: false 19 | } 20 | }, 21 | { 22 | path: '/about', 23 | name: 'About', 24 | component: () => import('@/views/About.vue'), 25 | meta: { 26 | requiresAuth: false 27 | } 28 | }, 29 | { 30 | path: '/login', 31 | name: 'Login', 32 | component: () => import('@/components/auth/Login.vue'), 33 | props: true, 34 | meta: { 35 | requiresAuth: false 36 | } 37 | }, 38 | { 39 | path: '/register', 40 | name: 'Register', 41 | component: () => import('@/components/auth/Register.vue'), 42 | props: true, 43 | meta: { 44 | requiresAuth: false 45 | } 46 | }, 47 | { 48 | path: '/profile/:handle', 49 | name: 'Profile', 50 | component: () => import('@/components/profile/Profile.vue'), 51 | meta: { 52 | requiresAuth: true, 53 | transitionName: 'router-anim', 54 | enterActive: 'animated fadeIn' 55 | } 56 | }, 57 | { 58 | path: '/user/:handle', 59 | name: 'UserProfile', 60 | component: () => import('@/components/user/UserProfile.vue'), 61 | props: true, 62 | meta: { 63 | requiresAuth: true, 64 | transitionName: 'router-anim', 65 | enterActive: 'animated fadeIn' 66 | } 67 | }, 68 | { 69 | path: '/user/:handle/edit', 70 | name: 'EditUserProfile', 71 | component: () => import('@/components/user/EditUserProfile.vue'), 72 | props: true, 73 | meta: { 74 | requiresAuth: true, 75 | transitionName: 'router-anim', 76 | enterActive: 'animated fadeIn' 77 | } 78 | }, 79 | { 80 | path: '/rooms', 81 | name: 'RoomList', 82 | component: () => import('@/components/room/RoomList.vue'), 83 | props: true, 84 | meta: { 85 | requiresAuth: true, 86 | transitionName: 'router-anim', 87 | enterActive: 'animated fadeIn' 88 | } 89 | }, 90 | { 91 | path: '/room/:id', 92 | name: 'Room', 93 | component: () => import('@/components/room/Room.vue'), 94 | meta: { 95 | requiresAuth: true, 96 | transitionName: 'router-anim', 97 | enterActive: 'animated fadeIn' 98 | } 99 | }, 100 | { 101 | path: '*', 102 | component: () => import('@/components/error/NotFound.vue') 103 | } 104 | ] 105 | }); 106 | 107 | router.beforeEach(async (to, from, next) => { 108 | await checkUserData(next); 109 | if (to.meta.requiresAuth) { 110 | if (localStorage.getItem('authToken') === null) { 111 | localStorage.clear(); 112 | next({ 113 | name: 'Login', 114 | params: { message: 'You are unauthorized, Please login to access' } 115 | }); 116 | } else { 117 | next(); 118 | } 119 | } else if (!_.isEmpty(to.meta) && !to.meta.requiresAuth) { 120 | if (localStorage.getItem('authToken')) { 121 | next({ 122 | name: 'UserProfile', 123 | params: { handle: store.getters.getUserData.handle } 124 | }); 125 | } else { 126 | next(); 127 | } 128 | } else { 129 | next(); 130 | } 131 | next(); 132 | }); 133 | 134 | export default router; 135 | -------------------------------------------------------------------------------- /client/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import axios from 'axios'; 4 | import router from './router'; 5 | 6 | Vue.use(Vuex); 7 | 8 | export default new Vuex.Store({ 9 | state: { 10 | authState: false, 11 | authUser: {}, 12 | currentRoom: null, 13 | rooms: [], 14 | socket: null 15 | }, 16 | getters: { 17 | getUserData: state => state.authUser, 18 | getRoomData: state => state.rooms, 19 | isAuthorized: state => state.authState, 20 | getSocket: state => state.socket, 21 | getCurrentRoom: state => state.currentRoom 22 | }, 23 | mutations: { 24 | ASSIGN_USER_DATA: (state, payload) => { 25 | state.authUser = payload; 26 | }, 27 | ASSIGN_ROOM_DATA: (state, payload) => { 28 | state.rooms = payload; 29 | }, 30 | ADD_ROOM: (state, payload) => { 31 | state.rooms = [...state.rooms, payload]; 32 | }, 33 | SAVE_CURRENT_ROOM: (state, payload) => { 34 | state.currentRoom = payload; 35 | }, 36 | DELETE_ROOM: (state, payload) => { 37 | state.currentRoom = null; 38 | state.rooms = state.rooms.filter(room => room._id !== payload._id); 39 | }, 40 | TOGGLE_AUTH_STATE: (state, payload) => { 41 | state.authState = payload; 42 | }, 43 | ASSIGN_SOCKET: (state, payload) => { 44 | state.socket = payload; 45 | }, 46 | LEAVE_ROOM: (state, payload) => { 47 | state.currentRoom.users = payload; 48 | }, 49 | REMOVE_ACCESS_ID: (state, payload) => { 50 | state.currentRoom = payload; 51 | }, 52 | RESET_STATE: state => { 53 | state.authState = false; 54 | state.authUser = {}; 55 | state.currentRoom = null; 56 | state.rooms = []; 57 | } 58 | }, 59 | actions: { 60 | saveUserData: (context, payload) => { 61 | context.commit('ASSIGN_USER_DATA', payload); 62 | }, 63 | updateRoomData: (context, payload) => { 64 | context.commit('ASSIGN_ROOM_DATA', payload); 65 | }, 66 | addRoom: (context, payload) => { 67 | context.commit('ADD_ROOM', payload); 68 | }, 69 | deleteRoom: (context, payload) => { 70 | context.commit('DELETE_ROOM', payload); 71 | }, 72 | toggleAuthState: (context, payload) => { 73 | context.commit('TOGGLE_AUTH_STATE', payload); 74 | }, 75 | assignSocket: (context, payload) => { 76 | context.commit('ASSIGN_SOCKET', payload); 77 | }, 78 | saveCurrentRoom: (context, payload) => { 79 | context.commit('SAVE_CURRENT_ROOM', payload); 80 | }, 81 | leaveRoom: (context, payload) => { 82 | context.commit('REMOVE_USER_ID', payload); 83 | }, 84 | removeAccessId: (context, payload) => { 85 | context.commit('REMOVE_ACCESS_ID', payload); 86 | }, 87 | deleteUserAccount: context => { 88 | axios.delete('/api/user/current').then(() => { 89 | context.commit('RESET_STATE'); 90 | localStorage.clear(); 91 | router.push({ name: 'Login' }); 92 | }); 93 | } 94 | } 95 | }); 96 | -------------------------------------------------------------------------------- /client/tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /client/tests/unit/Home.spec.js: -------------------------------------------------------------------------------- 1 | // import { shallowMount } from "@vue/test-utils"; 2 | import Home from '@/views/Home.vue'; 3 | 4 | describe('Home.vue', () => { 5 | it('Should have a mounted lifecycle hook', () => { 6 | expect(typeof Home.mounted).toBe('function'); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /client/utils/authToken.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const setAuthToken = token => { 4 | if (token) { 5 | // Apply to every axios request 6 | axios.defaults.headers.common['Authorization'] = token; 7 | } else { 8 | // Delete Auth Header 9 | delete axios.defaults.headers.common['Authorization']; 10 | } 11 | }; 12 | 13 | export default setAuthToken; 14 | -------------------------------------------------------------------------------- /client/views/About.vue: -------------------------------------------------------------------------------- 1 | 112 | 113 | 122 | 123 | 126 | -------------------------------------------------------------------------------- /client/views/Home.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 44 | -------------------------------------------------------------------------------- /client/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | configureWebpack: { 3 | resolve: { 4 | alias: { 5 | '@': __dirname 6 | } 7 | }, 8 | entry: { 9 | app: './main.js' 10 | }, 11 | optimization: { 12 | splitChunks: { 13 | chunks: 'all' 14 | } 15 | } 16 | }, 17 | css: { 18 | loaderOptions: { 19 | sass: { 20 | data: `@import "@/assets/scss/abstract/mixins.scss";` 21 | } 22 | } 23 | }, 24 | devServer: { 25 | proxy: { 26 | '/api': { 27 | target: 'http://localhost:5000', 28 | changeOrigin: true 29 | } 30 | } 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-chat-app", 3 | "version": "1.0.1", 4 | "description": "Astro Chat Application", 5 | "scripts": { 6 | "seed:data": "npm run seed --prefix server", 7 | "client:test": "npm run test --prefix client", 8 | "server:test:watch": "npm run test:watch --prefix server", 9 | "server:test:ci": "npm run test:ci --prefix server", 10 | "test": "npm run test --prefix client && npm run server:test:ci", 11 | "dev": "concurrently \"npm run dev --prefix server\" \"npm run dev --prefix client\"", 12 | "build": "npm run build --prefix client", 13 | "start": "npm run start --prefix server", 14 | "heroku-postbuild": "npm install --prefix client && npm install --prefix server && npm run build --prefix client" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/luvuong-le/astro-chat.git" 19 | }, 20 | "author": "Lu-Vuong ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/luvuong-le/astro-chat/issues" 24 | }, 25 | "homepage": "https://github.com/luvuong-le/astro-chat#readme", 26 | "devDependencies": { 27 | "concurrently": "^4.1.0" 28 | }, 29 | "dependencies": {} 30 | } 31 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | HEROKU_DEPLOYMENT=false 3 | 4 | DATABASE_URL=DATABASE_URL 5 | 6 | EXPRESS_SESSION_KEY=EXPRESS_SESSION_KEY 7 | JWT_SECRET=JWT_SECRET 8 | 9 | GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID 10 | GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET 11 | 12 | FACEBOOK_CLIENT_ID=FACEBOOK_CLIENT_ID 13 | FACEBOOK_CLIENT_SECRET=FACEBOOK_CLIENT_SECRET 14 | 15 | PORT=PORT -------------------------------------------------------------------------------- /server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parserOptions": { "ecmaVersion": 2017 }, 4 | "env": { 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": ["plugin:prettier/recommended"], 9 | "rules": { 10 | "indent": ["error", 4], 11 | "quotes": ["error", "single"], 12 | "semi": ["error", "always"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .vscode 3 | 4 | # local env files 5 | .env.local 6 | .env.*.local 7 | .env 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | logs -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "singleQuote": true, 4 | "semi": true, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /server/actions/socialAuthActions.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | module.exports = { 4 | google: (req, res) => { 5 | const io = req.app.get('io'); 6 | const token = jwt.sign(req.user.details.toObject(), process.env.JWT_SECRET, { 7 | expiresIn: 18000 8 | }); 9 | io.to(req.user._socket).emit( 10 | 'google', 11 | JSON.stringify({ 12 | auth: true, 13 | token: `Bearer ${token}`, 14 | user: req.user.details 15 | }) 16 | ); 17 | }, 18 | facebook: (req, res) => { 19 | const io = req.app.get('io'); 20 | const token = jwt.sign(req.user.details.toObject(), process.env.JWT_SECRET, { 21 | expiresIn: 18000 22 | }); 23 | io.to(req.user._socket).emit( 24 | 'facebook', 25 | JSON.stringify({ 26 | auth: true, 27 | token: `Bearer ${token}`, 28 | user: req.user.details 29 | }) 30 | ); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /server/actions/socketio.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const { Message } = require('../models/Message'); 3 | const { Room } = require('../models/Room'); 4 | 5 | module.exports = { 6 | ADD_MESSAGE: async data => { 7 | const newMessage = await new Message({ 8 | content: data.content, 9 | admin: data.admin ? true : false, 10 | user: data.user ? data.user._id : null, 11 | room: data.room._id 12 | }).save(); 13 | 14 | return Message.populate(newMessage, { 15 | path: 'user', 16 | select: 'username social handle image' 17 | }); 18 | }, 19 | GET_MESSAGES: async data => { 20 | return await Message.find({ room: data.room._id }).populate('user', [ 21 | 'username', 22 | 'social', 23 | 'handle', 24 | 'image' 25 | ]); 26 | }, 27 | CREATE_MESSAGE_CONTENT: (room, socketId) => { 28 | const user = room.previous.users.find(user => user.socketId === socketId); 29 | 30 | return user && user.lookup.handle 31 | ? `${user.lookup.handle} has left ${room.updated.name}` 32 | : `Unknown User has left ${room.updated.name}`; 33 | }, 34 | GET_ROOMS: async () => { 35 | return await Room.find({}) 36 | .populate('user users.lookup', ['username', 'social', 'handle', 'image']) 37 | .select('-password'); 38 | }, 39 | GET_ROOM_USERS: async data => { 40 | return await Room.findById(data.room._id) 41 | .populate('user users.lookup', ['username', 'social', 'handle', 'image']) 42 | .select('-password'); 43 | }, 44 | UPDATE_ROOM_USERS: async data => { 45 | const room = await Room.findOne({ name: data.room.name }) 46 | .select('-password') 47 | .populate('users.lookup', ['username', 'social', 'handle', 'image']); 48 | 49 | if (room) { 50 | if ( 51 | room.users && 52 | !room.users.find(user => user.lookup._id.toString() === data.user._id) 53 | ) { 54 | room.users.push({ 55 | lookup: mongoose.Types.ObjectId(data.user._id), 56 | socketId: data.socketId 57 | }); 58 | const updatedRoom = await room.save(); 59 | return await Room.populate(updatedRoom, { 60 | path: 'user users.lookup', 61 | select: 'username social image handle' 62 | }); 63 | } else { 64 | // Update user socket id if the user already exists 65 | const existingUser = room.users.find( 66 | user => user.lookup._id.toString() === data.user._id 67 | ); 68 | if (existingUser.socketId != data.socketId) { 69 | existingUser.socketId = data.socketId; 70 | await room.save(); 71 | } 72 | return await Room.populate(room, { 73 | path: 'user users.lookup', 74 | select: 'username social image handle' 75 | }); 76 | } 77 | } else { 78 | return; 79 | } 80 | }, 81 | FILTER_ROOM_USERS: async data => { 82 | const room = await Room.findById(mongoose.Types.ObjectId(data.roomId)) 83 | .select('-password') 84 | .populate('users.lookup', ['username', 'social', 'handle', 'image']); 85 | if (room) { 86 | let previousUserState = Object.assign({}, room._doc); 87 | room.users = room.users.filter(user => user.socketId !== data.socketId); 88 | const updatedRoom = await room.save(); 89 | return { 90 | previous: previousUserState, 91 | updated: await Room.populate(updatedRoom, { 92 | path: 'user users.lookup', 93 | select: 'username social image handle' 94 | }) 95 | }; 96 | } 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /server/config/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | GOOGLE_CONFIG: { 3 | clientID: process.env.GOOGLE_CLIENT_ID, 4 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 5 | callbackURL: '/api/auth/google/redirect', 6 | passReqToCallback: true, 7 | scope: [ 8 | 'https://www.googleapis.com/auth/plus.login', 9 | 'https://www.googleapis.com/auth/userinfo.email' 10 | ] 11 | }, 12 | FACEBOOK_CONFIG: { 13 | clientID: process.env.FACEBOOK_CLIENT_ID, 14 | clientSecret: process.env.FACEBOOK_CLIENT_SECRET, 15 | callbackURL: '/api/auth/facebook/redirect', 16 | passReqToCallback: true, 17 | profileFields: ['id', 'displayName', 'name', 'gender', 'emails', 'picture.type(large)'] 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /server/config/logModule.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const fs = require('fs'); 3 | const logDirectory = 'logs'; 4 | 5 | // Create the log directory if it does not exist 6 | if (!fs.existsSync(logDirectory)) { 7 | fs.mkdirSync(logDirectory); 8 | } 9 | 10 | /** Logging Configurations */ 11 | const logger = winston.createLogger({ 12 | level: 'info', 13 | format: winston.format.json(), 14 | transports: [ 15 | new winston.transports.File({ filename: `${logDirectory}/error.log`, level: 'error' }), 16 | new winston.transports.File({ filename: `${logDirectory}/info.log` }) 17 | ] 18 | }); 19 | 20 | module.exports = { logger }; 21 | -------------------------------------------------------------------------------- /server/config/passport.js: -------------------------------------------------------------------------------- 1 | const JwtStrategy = require('passport-jwt').Strategy; 2 | const ExtractJwt = require('passport-jwt').ExtractJwt; 3 | const GoogleStrategy = require('passport-google-oauth').OAuth2Strategy; 4 | const FacebookStrategy = require('passport-facebook').Strategy; 5 | const slugify = require('slugify'); 6 | 7 | const { GOOGLE_CONFIG, FACEBOOK_CONFIG } = require('../config/config'); 8 | const { User } = require('../models/User'); 9 | 10 | let opts = { 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 12 | secretOrKey: process.env.JWT_SECRET 13 | }; 14 | 15 | module.exports = function(passport) { 16 | passport.serializeUser((user, done) => done(null, { id: user.id, _socket: user._socket })); 17 | 18 | passport.deserializeUser((user, done) => { 19 | User.findById(user.id) 20 | .select('-password -googleId -facebookId') 21 | .then(user => { 22 | done(null, { details: user, _socket: user._socket }); 23 | }); 24 | }); 25 | 26 | passport.use( 27 | new JwtStrategy(opts, (payload, done) => { 28 | User.findById(payload._id) 29 | .select('-password') 30 | .then(user => { 31 | if (user) { 32 | return done(null, user); 33 | } else { 34 | return done(null, false); 35 | } 36 | }); 37 | }) 38 | ); 39 | 40 | if (process.env.NODE_ENV !== 'test') { 41 | passport.use( 42 | new GoogleStrategy(GOOGLE_CONFIG, function( 43 | req, 44 | accessToken, 45 | refreshToken, 46 | profile, 47 | done 48 | ) { 49 | User.findOne({ handle: slugify(profile.displayName.toLowerCase()) }) 50 | .then(user => { 51 | if (user) { 52 | user.social.id = profile.id; 53 | user.social.email = profile.emails[0].value; 54 | user.social.image = profile.photos[0].value.replace('?sz=50', ''); 55 | 56 | user.save().then(user => { 57 | return done(null, { 58 | details: user, 59 | _socket: JSON.parse(req.query.state)._socket 60 | }); 61 | }); 62 | } else { 63 | new User({ 64 | social: { 65 | id: profile.id, 66 | email: profile.emails[0].value, 67 | image: profile.photos[0].value.replace('?sz=50', '') 68 | }, 69 | handle: profile.displayName 70 | ? slugify(profile.displayName.toLowerCase()) 71 | : profile.emails[0].value 72 | }) 73 | .save() 74 | .then(user => { 75 | return done(null, { 76 | details: user, 77 | _socket: JSON.parse(req.query.state)._socket 78 | }); 79 | }); 80 | } 81 | }) 82 | .catch(err => console.log(err)); 83 | }) 84 | ); 85 | 86 | passport.use( 87 | new FacebookStrategy(FACEBOOK_CONFIG, function( 88 | req, 89 | accessToken, 90 | refreshToken, 91 | profile, 92 | done 93 | ) { 94 | User.findOne({ handle: slugify(profile.displayName.toLowerCase()) }) 95 | .then(user => { 96 | if (user) { 97 | user.social.id = profile.id; 98 | user.social.image = profile.photos[0].value; 99 | user.social.email = profile.emails[0].value; 100 | 101 | user.save().then(user => { 102 | return done(null, { 103 | details: user, 104 | _socket: JSON.parse(req.query.state)._socket 105 | }); 106 | }); 107 | } else { 108 | new User({ 109 | social: { 110 | id: profile.id, 111 | image: profile.photos[0].value, 112 | email: profile.emails[0].value 113 | }, 114 | handle: profile.displayName 115 | ? slugify(profile.displayName.toLowerCase()) 116 | : profile.emails[0].value 117 | }) 118 | .save() 119 | .then(user => { 120 | return done(null, { 121 | details: user, 122 | _socket: JSON.parse(req.query.state)._socket 123 | }); 124 | }); 125 | } 126 | }) 127 | .catch(err => console.log(err)); 128 | }) 129 | ); 130 | } 131 | }; 132 | -------------------------------------------------------------------------------- /server/db/mongoose.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const { logger } = require('../config/logModule'); 3 | 4 | mongoose.Promise = global.Promise; 5 | 6 | const connect = () => { 7 | mongoose.connect( 8 | process.env.DATABASE_URL, 9 | { useNewUrlParser: true, useCreateIndex: true, useUnifiedTopology: true }, 10 | err => { 11 | if (err) { 12 | logger.error(err); 13 | return; 14 | } 15 | 16 | if (process.env.NODE_ENV !== 'test') { 17 | logger.info('[LOG=DB] Successfully connected to MongoDB'); 18 | } 19 | } 20 | ); 21 | }; 22 | 23 | connect(); 24 | 25 | module.exports = { mongoose, connect }; 26 | -------------------------------------------------------------------------------- /server/helpers/socketEvents.js: -------------------------------------------------------------------------------- 1 | const { 2 | ADD_MESSAGE, 3 | GET_MESSAGES, 4 | UPDATE_ROOM_USERS, 5 | GET_ROOMS, 6 | GET_ROOM_USERS 7 | } = require('../actions/socketio'); 8 | 9 | module.exports = { 10 | JOIN_ROOM: (socket, data) => { 11 | socket.join(data.room._id, async () => { 12 | /** Get list of messages to send back to client */ 13 | socket.emit( 14 | 'updateRoomData', 15 | JSON.stringify({ 16 | messages: await GET_MESSAGES(data), 17 | room: await UPDATE_ROOM_USERS(data) 18 | }) 19 | ); 20 | 21 | /** Get Room to update user list for all other clients */ 22 | socket.broadcast 23 | .to(data.room._id) 24 | .emit('updateUserList', JSON.stringify(await GET_ROOM_USERS(data))); 25 | 26 | /** Emit event to all clients in the roomlist view except the sender */ 27 | socket.broadcast.emit( 28 | 'updateRooms', 29 | JSON.stringify({ 30 | room: await GET_ROOMS() 31 | }) 32 | ); 33 | 34 | /** Emit back the message */ 35 | socket.broadcast.to(data.room._id).emit( 36 | 'receivedNewMessage', 37 | JSON.stringify( 38 | await ADD_MESSAGE({ 39 | room: data.room, 40 | user: false, 41 | content: data.content, 42 | admin: data.admin 43 | }) 44 | ) 45 | ); 46 | }); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /server/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['js', 'json'], 3 | globals: { 4 | NODE_ENV: 'test' 5 | }, 6 | globalSetup: './tests/setup.js', 7 | testEnvironment: 'node' 8 | }; 9 | -------------------------------------------------------------------------------- /server/middleware/authenticate.js: -------------------------------------------------------------------------------- 1 | const passport = require('passport'); 2 | const { User } = require('../models/User'); 3 | 4 | const createErrorObject = errors => { 5 | const errorObject = []; 6 | errors.forEach(error => { 7 | let err = { 8 | [error.param]: error.msg 9 | }; 10 | errorObject.push(err); 11 | }); 12 | 13 | return errorObject; 14 | }; 15 | 16 | const checkRegistrationFields = async (req, res, next) => { 17 | req.check('email').isEmail(); 18 | req.check('username') 19 | .isString() 20 | .isLength({ min: 5, max: 15 }) 21 | .withMessage('Username must be between 5 and 15 characters'); 22 | req.check('password') 23 | .isString() 24 | .isLength({ min: 5, max: 15 }) 25 | .withMessage('Password must be between 5 and 15 characters'); 26 | 27 | let errors = req.validationErrors() || []; 28 | 29 | const user = await User.findOne({ username: req.body.username }); 30 | 31 | if (user) { 32 | errors.push({ param: 'username', msg: 'Username already taken' }); 33 | } 34 | 35 | if (errors.length > 0) { 36 | res.send({ 37 | errors: createErrorObject(errors) 38 | }); 39 | } else { 40 | next(); 41 | } 42 | }; 43 | 44 | const checkLoginFields = async (req, res, next) => { 45 | let errors = []; 46 | const user = await User.findOne({ email: req.body.email }); 47 | if (!user) { 48 | errors.push({ param: 'email', msg: 'Invalid Details Entered' }); 49 | } else { 50 | if (req.body.password !== null && !(await user.isValidPassword(req.body.password))) { 51 | errors.push({ param: 'password', msg: 'Invalid Details Entered' }); 52 | } 53 | } 54 | 55 | if (errors.length !== 0) { 56 | res.send({ 57 | errors: createErrorObject(errors) 58 | }); 59 | } else { 60 | next(); 61 | } 62 | }; 63 | 64 | const checkEditProfileFields = async (req, res, next) => { 65 | let errors = []; 66 | 67 | if (req.body.email) { 68 | if (await User.findOne({ email: req.body.email })) { 69 | errors.push({ param: 'email', msg: 'Email is already taken' }); 70 | } 71 | } 72 | 73 | if (req.body.handle) { 74 | if (await User.findOne({ handle: req.body.handle })) { 75 | errors.push({ param: 'handle', msg: 'Handle is already taken' }); 76 | } 77 | } 78 | if (errors.length !== 0) { 79 | res.send({ 80 | errors: createErrorObject(errors) 81 | }); 82 | } else { 83 | next(); 84 | } 85 | }; 86 | 87 | const checkCreateRoomFields = async (req, res, next) => { 88 | if (!req.body.room_name) { 89 | req.check('room_name') 90 | .not() 91 | .isEmpty() 92 | .withMessage('Room name is required'); 93 | } else { 94 | req.check('room_name') 95 | .isString() 96 | .isLength({ min: 3, max: 20 }) 97 | .withMessage('Room name must be between 5 and 20 characters'); 98 | } 99 | 100 | if (req.body.password) { 101 | req.check('password') 102 | .not() 103 | .isEmpty() 104 | .isLength({ min: 5, max: 15 }) 105 | .withMessage('Password should be between 5 and 15 characters'); 106 | } 107 | 108 | const errors = req.validationErrors(); 109 | 110 | if (errors) { 111 | res.send({ 112 | errors: createErrorObject(errors) 113 | }); 114 | } else { 115 | next(); 116 | } 117 | }; 118 | 119 | const customSocialAuthenticate = socialAuth => { 120 | return (req, res, next) => { 121 | passport.authenticate(socialAuth, { 122 | state: JSON.stringify({ _socket: req.query.socketId }) 123 | })(req, res, next); 124 | }; 125 | }; 126 | 127 | module.exports = { 128 | checkLoginFields, 129 | checkRegistrationFields, 130 | checkEditProfileFields, 131 | checkCreateRoomFields, 132 | customSocialAuthenticate, 133 | createErrorObject 134 | }; 135 | -------------------------------------------------------------------------------- /server/models/Message.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | const MessageSchema = new Schema( 5 | { 6 | content: { 7 | type: String, 8 | required: true, 9 | trim: true 10 | }, 11 | room: { 12 | type: Schema.Types.ObjectId, 13 | required: true, 14 | ref: 'Room' 15 | }, 16 | user: { 17 | type: Schema.Types.ObjectId, 18 | ref: 'User' 19 | }, 20 | admin: { 21 | type: Boolean, 22 | default: false 23 | } 24 | }, 25 | { 26 | timestamps: { 27 | createdAt: 'created_at' 28 | } 29 | } 30 | ); 31 | 32 | const Message = mongoose.model('Message', MessageSchema); 33 | 34 | module.exports = { Message }; 35 | -------------------------------------------------------------------------------- /server/models/Room.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const bcrypt = require('bcryptjs'); 3 | const Schema = mongoose.Schema; 4 | 5 | const RoomSchema = new Schema( 6 | { 7 | name: { 8 | type: String, 9 | required: true, 10 | trim: true, 11 | unique: true, 12 | minlength: ['3', 'Room name should be greater than 3 characters'], 13 | maxlength: ['20', 'Room name should be less than 20 characters'] 14 | }, 15 | user: { 16 | type: Schema.Types.ObjectId, 17 | ref: 'User', 18 | default: null 19 | }, 20 | password: { 21 | type: String, 22 | default: '' 23 | }, 24 | access: { 25 | type: Boolean, 26 | default: true 27 | }, 28 | accessIds: { 29 | type: Array, 30 | default: [] 31 | }, 32 | users: [ 33 | { 34 | _id: false, 35 | lookup: { 36 | type: Schema.Types.ObjectId, 37 | required: true, 38 | ref: 'User' 39 | }, 40 | socketId: { 41 | type: String, 42 | required: true 43 | } 44 | } 45 | ] 46 | }, 47 | { 48 | timestamps: { 49 | createdAt: 'created_at', 50 | updatedAt: 'updated_at' 51 | } 52 | } 53 | ); 54 | 55 | RoomSchema.methods.isValidPassword = function(password) { 56 | return bcrypt.compare(password, this.password); 57 | }; 58 | 59 | RoomSchema.pre('save', function(next) { 60 | if (this.password !== '' && this.isModified('password')) { 61 | bcrypt.genSalt(10, (err, salt) => { 62 | bcrypt.hash(this.password, salt, (err, res) => { 63 | this.password = res; 64 | next(); 65 | }); 66 | }); 67 | } else { 68 | next(); 69 | } 70 | }); 71 | 72 | const Room = mongoose.model('Room', RoomSchema); 73 | 74 | module.exports = { Room }; 75 | -------------------------------------------------------------------------------- /server/models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const bcrypt = require('bcryptjs'); 3 | 4 | const Schema = mongoose.Schema; 5 | 6 | const UserSchema = new Schema( 7 | { 8 | handle: { 9 | type: String, 10 | required: true, 11 | trim: true, 12 | unique: true 13 | }, 14 | username: { 15 | type: String, 16 | trim: true, 17 | unique: true, 18 | maxlength: ['15', 'Username should be less than 15 characters'] 19 | }, 20 | social: { 21 | id: { 22 | type: String, 23 | default: null 24 | }, 25 | image: { 26 | type: String, 27 | default: null 28 | }, 29 | email: { 30 | type: String, 31 | default: null 32 | } 33 | }, 34 | email: { 35 | type: String, 36 | trim: true, 37 | unique: true, 38 | sparse: true 39 | }, 40 | password: { 41 | type: String, 42 | default: null 43 | }, 44 | image: { 45 | type: String, 46 | default: null 47 | }, 48 | location: { 49 | type: String, 50 | default: null 51 | } 52 | }, 53 | { 54 | timestamps: { 55 | createdAt: 'created_at', 56 | updatedAt: 'updated_at' 57 | } 58 | } 59 | ); 60 | 61 | UserSchema.methods.isValidPassword = function(password) { 62 | return bcrypt.compare(password, this.password); 63 | }; 64 | 65 | // Before Saving hash the password with bcrypt, using the default 10 rounds for salt 66 | UserSchema.pre('save', function(next) { 67 | if (this.password !== '' && this.isModified('password')) { 68 | bcrypt.genSalt(10, (err, salt) => { 69 | bcrypt.hash(this.password, salt, (err, res) => { 70 | this.password = res; 71 | next(); 72 | }); 73 | }); 74 | } else { 75 | next(); 76 | } 77 | }); 78 | 79 | const User = mongoose.model('User', UserSchema); 80 | 81 | module.exports = { User }; 82 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-chat-backend", 3 | "version": "1.0.0", 4 | "description": "Backend for Astro Chat", 5 | "main": "index.js", 6 | "private": true, 7 | "scripts": { 8 | "seed": "node ./tests/seed/seed.js", 9 | "test:watch": "jest --coverage --config ./jest.config.js --notify --watchAll", 10 | "test:local": "jest --coverage --config ./jest.config.js --notify", 11 | "test:ci": "jest --coverage --config ./jest.config.js --forceExit", 12 | "dev": "nodemon server.js", 13 | "start": "node server.js" 14 | }, 15 | "author": "Lu-Vuong ", 16 | "license": "MIT", 17 | "dependencies": { 18 | "bcryptjs": "^2.4.3", 19 | "body-parser": "^1.18.3", 20 | "compression": "^1.7.4", 21 | "cors": "^2.8.4", 22 | "dotenv": "^6.0.0", 23 | "express": "^4.16.3", 24 | "express-sslify": "^1.2.0", 25 | "express-validator": "^5.3.0", 26 | "gravatar": "^1.8.0", 27 | "helmet": "^3.15.0", 28 | "jsonwebtoken": "^8.3.0", 29 | "mongoose": "^5.3.1", 30 | "passport": "^0.4.0", 31 | "passport-facebook": "^2.1.1", 32 | "passport-google-oauth": "^1.0.0", 33 | "passport-jwt": "^4.0.0", 34 | "slugify": "^1.3.4", 35 | "socket.io": "^2.2.0", 36 | "vue": "^2.6.10", 37 | "vue-server-renderer": "^2.6.10" 38 | }, 39 | "devDependencies": { 40 | "eslint": "^5.9.0", 41 | "eslint-config-prettier": "^3.3.0", 42 | "eslint-plugin-prettier": "^3.0.0", 43 | "jest": "^23.6.0", 44 | "morgan": "^1.9.1", 45 | "nodemon": "^1.18.9", 46 | "prettier": "^1.15.3", 47 | "supertest": "^3.3.0", 48 | "winston": "^3.1.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /server/routes/auth.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const express = require('express'); 3 | const router = express.Router(); 4 | const passport = require('passport'); 5 | const jwt = require('jsonwebtoken'); 6 | const { User } = require('../models/User'); 7 | const gravatar = require('gravatar'); 8 | const socialAuthActions = require('../actions/socialAuthActions'); 9 | 10 | /** Middleware */ 11 | const { 12 | checkRegistrationFields, 13 | checkLoginFields, 14 | createErrorObject, 15 | customSocialAuthenticate 16 | } = require('../middleware/authenticate'); 17 | 18 | /** 19 | * @description POST /register 20 | * @param {} [checkRegistrationFields] 21 | * @param {} request 22 | * @param {} response 23 | * @access public 24 | */ 25 | router.post('/register', [checkRegistrationFields], (req, res) => { 26 | let errors = []; 27 | 28 | User.findOne({ email: req.body.email }).then(user => { 29 | if (user) { 30 | errors.push({ param: 'email', msg: 'Email is already taken' }); 31 | 32 | if (user.username === req.body.username) { 33 | errors.push({ param: 'username', msg: 'Username is already taken' }); 34 | } 35 | 36 | res.send({ 37 | errors: createErrorObject(errors) 38 | }).end(); 39 | } else { 40 | /** Assign Gravatar */ 41 | const avatar = gravatar.url(req.body.email, { 42 | s: '220', 43 | r: 'pg', 44 | d: 'identicon' 45 | }); 46 | 47 | const newUser = new User({ 48 | handle: req.body.handle, 49 | username: req.body.username, 50 | email: req.body.email, 51 | password: req.body.password, 52 | image: avatar 53 | }); 54 | 55 | newUser 56 | .save() 57 | .then(userData => { 58 | const user = _.omit(userData.toObject(), ['password']); 59 | 60 | const token = jwt.sign(user, process.env.JWT_SECRET, { 61 | expiresIn: 18000 62 | }); 63 | 64 | res.status(200).send({ 65 | auth: true, 66 | token: `Bearer ${token}`, 67 | user 68 | }); 69 | }) 70 | .catch(err => { 71 | res.send({ 72 | err, 73 | error: 'Something went wrong, Please check the fields again' 74 | }); 75 | }); 76 | } 77 | }); 78 | }); 79 | 80 | /** 81 | * @description POST /login 82 | * @param {} checkLoginFields 83 | * @param {} request 84 | * @param {} response 85 | * @access public 86 | */ 87 | router.post('/login', checkLoginFields, async (req, res) => { 88 | const user = await User.findOne({ email: req.body.email }).select('-password'); 89 | 90 | if (!user) { 91 | return res.status(404).send({ 92 | error: 'No User Found' 93 | }); 94 | } 95 | 96 | const token = jwt.sign(user.toObject(), process.env.JWT_SECRET, { expiresIn: 18000 }); 97 | 98 | res.status(200).send({ auth: true, token: `Bearer ${token}`, user }); 99 | }); 100 | 101 | /** 102 | * @description POST /logout 103 | * @param {} request 104 | * @param {} response 105 | * @access public 106 | */ 107 | router.post('/logout', async (req, res) => { 108 | const user = await User.findOne({ username: req.body.username }).select('-password'); 109 | 110 | if (!user) { 111 | return res.status(404).send({ 112 | error: 'No User Found' 113 | }); 114 | } 115 | 116 | res.status(200).send({ success: true }); 117 | }); 118 | 119 | /** Social Auth Routes */ 120 | router.get('/google', customSocialAuthenticate('google')); 121 | router.get('/facebook', customSocialAuthenticate('facebook')); 122 | 123 | /** Social Auth Callbacks */ 124 | router.get('/google/redirect', passport.authenticate('google'), socialAuthActions.google); 125 | router.get('/facebook/redirect', passport.authenticate('facebook'), socialAuthActions.facebook); 126 | 127 | module.exports = router; 128 | -------------------------------------------------------------------------------- /server/routes/messages.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | const passport = require('passport'); 5 | 6 | const { Message } = require('../models/Message'); 7 | 8 | const { createErrorObject } = require('../middleware/authenticate'); 9 | 10 | /** 11 | * @description GET /api/messages/:room_id 12 | */ 13 | router.get('/:room_id', passport.authenticate('jwt', { session: false }), async (req, res) => { 14 | const messages = await Message.find({ room: req.params.room_id }); 15 | 16 | if (messages) { 17 | return res.status(200).json(messages); 18 | } else { 19 | return res.status(404).json({ error: 'No messages found' }); 20 | } 21 | }); 22 | 23 | /** 24 | * @description POST /api/messages/ 25 | */ 26 | router.post('/', passport.authenticate('jwt', { session: false }), async (req, res) => { 27 | let errors = []; 28 | 29 | if (!req.body.content) { 30 | errors.push({ param: 'no_content', msg: 'Message cannot be empty' }); 31 | return res.json({ errors: createErrorObject(errors) }); 32 | } 33 | 34 | const newMessage = new Message({ 35 | content: req.body.content, 36 | admin: req.body.admin ? true : false, 37 | user: req.user.id, 38 | room: req.body.roomId 39 | }).save(); 40 | 41 | return res.status(200).json(newMessage); 42 | }); 43 | 44 | module.exports = router; 45 | -------------------------------------------------------------------------------- /server/routes/profile.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | const passport = require('passport'); 5 | 6 | const { User } = require('../models/User'); 7 | 8 | /** 9 | * @description GET api/profile/:handle 10 | * @param {String} id 11 | * @param {Middleware} passport.authenticate 12 | * @param {false} session 13 | * @param {Object} request 14 | * @param {Object} response 15 | */ 16 | router.get('/:handle', passport.authenticate('jwt', { session: false }), async (req, res) => { 17 | const user = await User.findOne({ handle: req.params.handle }) 18 | .select('-password -session_id') 19 | .exec(); 20 | 21 | if (user) { 22 | return res 23 | .status(200) 24 | .json(user) 25 | .end(); 26 | } else { 27 | return res 28 | .status(404) 29 | .json({ error: `No User Found called ${req.params.username}` }) 30 | .end(); 31 | } 32 | }); 33 | 34 | module.exports = router; 35 | -------------------------------------------------------------------------------- /server/routes/room.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const passport = require('passport'); 4 | 5 | const { Room } = require('../models/Room'); 6 | 7 | const { createErrorObject, checkCreateRoomFields } = require('../middleware/authenticate'); 8 | 9 | /** 10 | * @description GET /api/room 11 | */ 12 | router.get('/', passport.authenticate('jwt', { session: false }), async (req, res) => { 13 | const rooms = await Room.find({}) 14 | .populate('user', ['handle']) 15 | .populate('users.lookup', ['handle']) 16 | .select('-password') 17 | .exec(); 18 | 19 | if (rooms) { 20 | return res.status(200).json(rooms); 21 | } else { 22 | return res.status(404).json({ error: 'No Rooms Found' }); 23 | } 24 | }); 25 | 26 | /** 27 | * @description GET /api/room/:room_id 28 | */ 29 | router.get('/:room_id', passport.authenticate('jwt', { session: false }), async (req, res) => { 30 | const room = await Room.findById(req.params.room_id) 31 | .populate('user', ['username', 'social', 'image', 'handle']) 32 | .populate('users.lookup', ['username', 'social', 'image', 'handle']) 33 | .exec(); 34 | 35 | if (room) { 36 | return res.status(200).json(room); 37 | } else { 38 | return res.status(404).json({ error: `No room with name ${req.params.room_name} found` }); 39 | } 40 | }); 41 | 42 | /** 43 | * @description POST /api/room 44 | */ 45 | router.post( 46 | '/', 47 | [passport.authenticate('jwt', { session: false }), checkCreateRoomFields], 48 | async (req, res) => { 49 | let errors = []; 50 | 51 | const room = await Room.findOne({ name: req.body.room_name }).exec(); 52 | if (room) { 53 | if (room.name === req.body.room_name) { 54 | errors.push({ param: 'room_taken', msg: 'Roomname already taken' }); 55 | } 56 | return res.json({ errors: createErrorObject(errors) }); 57 | } else { 58 | const newRoom = new Room({ 59 | name: req.body.room_name, 60 | user: req.user.id, 61 | access: req.body.password ? false : true, 62 | password: req.body.password 63 | }); 64 | 65 | if (newRoom.access === false) { 66 | newRoom.accessIds.push(req.user.id); 67 | } 68 | 69 | newRoom 70 | .save() 71 | .then(room => { 72 | Room.populate(room, { path: 'user', select: 'username' }, (err, room) => { 73 | if (err) { 74 | console.log(err); 75 | } 76 | return res.status(200).json(room); 77 | }); 78 | }) 79 | .catch(err => { 80 | return res.json(err); 81 | }); 82 | } 83 | } 84 | ); 85 | 86 | /** 87 | * @description POST /api/room/verify 88 | */ 89 | router.post('/verify', passport.authenticate('jwt', { session: false }), async (req, res) => { 90 | if (!req.body.password === true) { 91 | return res.json({ 92 | errors: createErrorObject([ 93 | { 94 | param: 'password_required', 95 | msg: 'Password is required' 96 | } 97 | ]) 98 | }); 99 | } 100 | 101 | const room = await Room.findOne({ name: req.body.room_name }).exec(); 102 | 103 | if (room) { 104 | const verified = await room.isValidPassword(req.body.password); 105 | 106 | if (verified === true) { 107 | room.accessIds.push(req.user.id); 108 | await room.save(); 109 | return res.status(200).json({ success: true }); 110 | } else { 111 | return res.json({ 112 | errors: createErrorObject([ 113 | { 114 | param: 'invalid_password', 115 | msg: 'Invalid Password' 116 | } 117 | ]) 118 | }); 119 | } 120 | } else { 121 | return res.status(404).json({ errors: `No room with name ${req.params.room_name} found` }); 122 | } 123 | }); 124 | 125 | /** 126 | * @description DELETE /api/room/:room_name 127 | */ 128 | router.delete('/:room_name', passport.authenticate('jwt', { session: false }), async (req, res) => { 129 | try { 130 | const room = await Room.findOneAndDelete({ name: req.params.room_name }) 131 | .populate('user', ['username']) 132 | .select('-password') 133 | .lean(); 134 | 135 | if (room) { 136 | return res.status(200).json(room); 137 | } else { 138 | return res.status(404).json({ 139 | errors: `No room with name ${req.params.room_name} found, You will now be redirected` 140 | }); 141 | } 142 | } catch (err) { 143 | return res.status(404).json(err); 144 | } 145 | }); 146 | 147 | /** 148 | * @description PUT /api/room/update/name 149 | */ 150 | router.post('/update/name', passport.authenticate('jwt', { session: false }), async (req, res) => { 151 | req.check('new_room_name') 152 | .isString() 153 | .isLength({ min: 3, max: 20 }) 154 | .withMessage('New Room Name must be between 3 and 20 characters'); 155 | 156 | let errors = req.validationErrors(); 157 | 158 | if (errors.length > 0) { 159 | return res.send({ 160 | errors: createErrorObject(errors) 161 | }); 162 | } 163 | 164 | const room = await Room.findOneAndUpdate( 165 | { name: req.body.room_name }, 166 | { name: req.body.new_room_name }, 167 | { fields: { password: 0 }, new: true } 168 | ) 169 | .populate('user', ['username']) 170 | .populate('users.lookup', ['username']); 171 | 172 | if (room) { 173 | return res.status(200).json(room); 174 | } else { 175 | return res.status(404).json({ errors: `No room with name ${req.params.room_name} found` }); 176 | } 177 | }); 178 | 179 | /** 180 | * @description PUT /api/room/remove/users 181 | */ 182 | router.post('/remove/users', passport.authenticate('jwt', { session: false }), async (req, res) => { 183 | const room = await Room.findOne({ name: req.body.room_name }); 184 | 185 | if (room) { 186 | if (room.users.find(user => user.lookup.toString() === req.user.id)) { 187 | room.users = room.users.filter(user => user.lookup.toString() !== req.user.id); 188 | await room.save(); 189 | } 190 | const returnRoom = await Room.populate(room, { 191 | path: 'user users.lookup', 192 | select: 'username social image handle' 193 | }); 194 | return res.status(200).json(returnRoom); 195 | } else { 196 | return res.status(404).json({ errors: `No room with name ${req.params.room_name} found` }); 197 | } 198 | }); 199 | 200 | /** 201 | * @description PUT /api/room/remove/users/:id/all 202 | */ 203 | router.put( 204 | '/remove/users/all', 205 | passport.authenticate('jwt', { session: false }), 206 | async (req, res) => { 207 | await Room.updateMany({ $pull: { users: { $in: [req.body.user_id] } } }); 208 | 209 | const rooms = await Room.find({}) 210 | .populate('user', ['username']) 211 | .populate('users.lookup', ['username']) 212 | .select('-password') 213 | .exec(); 214 | 215 | if (rooms) { 216 | return res.status(200).json(rooms); 217 | } else { 218 | return res.status(404).json({ error: 'No Rooms Found' }); 219 | } 220 | } 221 | ); 222 | 223 | module.exports = router; 224 | -------------------------------------------------------------------------------- /server/routes/user.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | const passport = require('passport'); 5 | 6 | const { User } = require('../models/User'); 7 | 8 | const { checkEditProfileFields } = require('../middleware/authenticate'); 9 | 10 | /** 11 | * @description GET /api/user/users 12 | * @param {Middleware} passport.authenticate 13 | * @param {false} session 14 | * @param {Object} request 15 | * @param {Object} response 16 | * @access private 17 | */ 18 | 19 | router.get('/users', passport.authenticate('jwt', { session: false }), async (req, res) => { 20 | const users = await User.find({}, 'image email username location').exec(); 21 | 22 | if (users) { 23 | return res 24 | .status(200) 25 | .json(users) 26 | .end(); 27 | } else { 28 | return res.status(404).json({ error: 'No Users Found' }); 29 | } 30 | }); 31 | 32 | /** 33 | * @description PUT /api/user/current 34 | * @param {String} id 35 | * @param {Middleware} passport.authenticate 36 | * @param {false} session 37 | * @param {Object} request 38 | * @param {Object} response 39 | */ 40 | router.put( 41 | '/current', 42 | [passport.authenticate('jwt', { session: false }), checkEditProfileFields], 43 | async (req, res) => { 44 | const updateFields = {}; 45 | 46 | for (let key of Object.keys(req.body)) { 47 | if (req.body[key] !== null) { 48 | updateFields[key] = req.body[key]; 49 | } 50 | } 51 | 52 | User.findOneAndUpdate({ _id: req.user.id }, { $set: updateFields }, { new: true }) 53 | .select('-password') 54 | .then(doc => res.json({ success: true, user: doc })) 55 | .catch(err => res.json({ error: err })); 56 | } 57 | ); 58 | 59 | /** 60 | * @description GET api/user/current 61 | * @param {String} id 62 | * @param {Middleware} passport.authenticate 63 | * @param {false} session 64 | * @param {Object} request 65 | * @param {Object} response 66 | */ 67 | router.get('/current', passport.authenticate('jwt', { session: false }), (req, res) => { 68 | res.json(req.user); 69 | }); 70 | 71 | /** 72 | * @description DELETE api/user/current 73 | * @param {String} id 74 | * @param {Middleware} passport.authenticate 75 | * @param {false} session 76 | * @param {Object} request 77 | * @param {Object} response 78 | */ 79 | router.delete('/current', passport.authenticate('jwt', { session: false }), async (req, res) => { 80 | /** Delete the user */ 81 | await User.findOneAndDelete({ _id: req.user.id }); 82 | 83 | res.json({ success: true }); 84 | }); 85 | 86 | module.exports = router; 87 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | /** Dotenv Environment Variables */ 2 | if (process.env.HEROKU_DEPLOYMENT !== 'true') { 3 | // Skip loading the .env file if deploying with heroku 4 | require('dotenv').config(); 5 | } 6 | 7 | /** Connect to MongoDB */ 8 | const mongoose = require('mongoose'); 9 | require('./db/mongoose'); 10 | 11 | /** Built In Node Dependencies */ 12 | const path = require('path'); 13 | const fs = require('fs'); 14 | 15 | /** Logging Dependencies */ 16 | const morgan = require('morgan'); 17 | const winston = require('winston'); 18 | const { logger } = require('./config/logModule'); 19 | 20 | /** Passport Configuration */ 21 | const passport = require('passport'); 22 | require('./config/passport')(passport); 23 | 24 | /** Express */ 25 | const express = require('express'); 26 | const bodyParser = require('body-parser'); 27 | const expressValidator = require('express-validator'); 28 | const cors = require('cors'); 29 | const helmet = require('helmet'); 30 | const enforce = require('express-sslify'); 31 | const compression = require('compression'); 32 | 33 | /** Socket IO */ 34 | const app = express(); 35 | const server = require('http').Server(app); 36 | const io = require('socket.io')(server); 37 | const { 38 | ADD_MESSAGE, 39 | UPDATE_ROOM_USERS, 40 | GET_ROOMS, 41 | GET_ROOM_USERS, 42 | FILTER_ROOM_USERS, 43 | CREATE_MESSAGE_CONTENT 44 | } = require('./actions/socketio'); 45 | 46 | const { JOIN_ROOM } = require('./helpers/socketEvents'); 47 | 48 | /** Routes */ 49 | const authRoutes = require('./routes/auth'); 50 | const userRoutes = require('./routes/user'); 51 | const profileRoutes = require('./routes/profile'); 52 | const roomRoutes = require('./routes/room'); 53 | const messageRoutes = require('./routes/messages'); 54 | 55 | /** Middleware */ 56 | app.use( 57 | morgan('combined', { 58 | stream: fs.createWriteStream('logs/access.log', { flags: 'a' }) 59 | }) 60 | ); 61 | app.use(morgan('dev')); 62 | 63 | if (process.env.HEROKU_DEPLOYMENT === 'true') { 64 | /** Trust Proto Header for heroku */ 65 | app.enable('trust proxy'); 66 | app.use(enforce.HTTPS({ trustProtoHeader: true })); 67 | } 68 | 69 | app.use(helmet()); 70 | app.use(compression()); 71 | 72 | app.use(bodyParser.urlencoded({ extended: true })); 73 | app.use(bodyParser.json()); 74 | app.use(passport.initialize()); 75 | app.use(expressValidator()); 76 | app.use(cors()); 77 | app.set('io', io); 78 | 79 | /** Routes Definitions */ 80 | app.use('/api/auth', authRoutes); 81 | app.use('/api/user', userRoutes); 82 | app.use('/api/profile', profileRoutes); 83 | app.use('/api/room', roomRoutes); 84 | app.use('/api/messages', messageRoutes); 85 | 86 | if (process.env.NODE_ENV !== 'production') { 87 | logger.add( 88 | new winston.transports.Console({ 89 | format: winston.format.simple() 90 | }) 91 | ); 92 | } 93 | 94 | let userTypings = {}; 95 | 96 | /** Socket IO Connections */ 97 | io.on('connection', socket => { 98 | let currentRoomId = null; 99 | 100 | /** Socket Events */ 101 | socket.on('disconnect', async () => { 102 | logger.info('User Disconnected'); 103 | 104 | if (currentRoomId) { 105 | /** Filter through users and remove user from user list in that room */ 106 | const roomState = await FILTER_ROOM_USERS({ 107 | roomId: currentRoomId, 108 | socketId: socket.id 109 | }); 110 | 111 | socket.broadcast.to(currentRoomId).emit( 112 | 'updateUserList', 113 | JSON.stringify( 114 | await GET_ROOM_USERS({ 115 | room: { 116 | _id: mongoose.Types.ObjectId(currentRoomId) 117 | } 118 | }) 119 | ) 120 | ); 121 | 122 | socket.broadcast.emit( 123 | 'updateRooms', 124 | JSON.stringify({ 125 | room: await GET_ROOMS() 126 | }) 127 | ); 128 | 129 | socket.broadcast.to(currentRoomId).emit( 130 | 'receivedNewMessage', 131 | JSON.stringify( 132 | await ADD_MESSAGE({ 133 | room: { _id: roomState.previous._id }, 134 | user: null, 135 | content: CREATE_MESSAGE_CONTENT(roomState, socket.id), 136 | admin: true 137 | }) 138 | ) 139 | ); 140 | } 141 | }); 142 | 143 | /** Join User in Room */ 144 | socket.on('userJoined', data => { 145 | currentRoomId = data.room._id; 146 | data.socketId = socket.id; 147 | JOIN_ROOM(socket, data); 148 | }); 149 | 150 | /** User Exit Room */ 151 | socket.on('exitRoom', data => { 152 | currentRoomId = null; 153 | socket.leave(data.room._id, async () => { 154 | socket.to(data.room._id).emit( 155 | 'updateRoomData', 156 | JSON.stringify({ 157 | room: data.room 158 | }) 159 | ); 160 | 161 | /** Update room list count */ 162 | socket.broadcast.emit( 163 | 'updateRooms', 164 | JSON.stringify({ 165 | room: await GET_ROOMS() 166 | }) 167 | ); 168 | 169 | io.to(data.room._id).emit('receivedUserExit', data.room); 170 | 171 | /** Send Exit Message back to room */ 172 | socket.broadcast 173 | .to(data.room._id) 174 | .emit('receivedNewMessage', JSON.stringify(await ADD_MESSAGE(data))); 175 | }); 176 | }); 177 | 178 | /** User Typing Events */ 179 | socket.on('userTyping', data => { 180 | if (!userTypings[data.room._id]) { 181 | userTypings[data.room._id] = []; 182 | } else { 183 | if (!userTypings[data.room._id].includes(data.user.handle)) { 184 | userTypings[data.room._id].push(data.user.handle); 185 | } 186 | } 187 | 188 | socket.broadcast 189 | .to(data.room._id) 190 | .emit('receivedUserTyping', JSON.stringify(userTypings[data.room._id])); 191 | }); 192 | 193 | socket.on('removeUserTyping', data => { 194 | if (userTypings[data.room._id]) { 195 | if (userTypings[data.room._id].includes(data.user.handle)) { 196 | userTypings[data.room._id] = userTypings[data.room._id].filter( 197 | handle => handle !== data.user.handle 198 | ); 199 | } 200 | } 201 | 202 | socket.broadcast 203 | .to(data.room._id) 204 | .emit('receivedUserTyping', JSON.stringify(userTypings[data.room._id])); 205 | }); 206 | 207 | /** New Message Event */ 208 | socket.on('newMessage', async data => { 209 | const newMessage = await ADD_MESSAGE(data); 210 | 211 | // Emit data back to the client for display 212 | io.to(data.room._id).emit('receivedNewMessage', JSON.stringify(newMessage)); 213 | }); 214 | 215 | /** Room Deleted Event */ 216 | socket.on('roomDeleted', async data => { 217 | io.to(data.room._id).emit('receivedNewMessage', JSON.stringify(data)); 218 | io.to(data.room._id).emit('roomDeleted', JSON.stringify(data)); 219 | io.emit('roomListUpdated', JSON.stringify(data)); 220 | }); 221 | 222 | /** Room Added Event */ 223 | socket.on('roomAdded', async data => { 224 | io.emit('roomAdded', JSON.stringify(data)); 225 | }); 226 | 227 | /** Room Updated Event */ 228 | socket.on('roomUpdateEvent', async data => { 229 | io.in(data.room._id).emit('roomUpdated', JSON.stringify(data)); 230 | io.emit('roomNameUpdated', JSON.stringify(data)); 231 | }); 232 | 233 | /** Reconnected: Update Reconnected User in Room */ 234 | socket.on('reconnectUser', data => { 235 | currentRoomId = data.room._id; 236 | data.socketId = socket.id; 237 | if (socket.request.headers.referer.split('/').includes('room')) { 238 | socket.join(currentRoomId, async () => { 239 | socket.emit('reconnected'); 240 | await UPDATE_ROOM_USERS(data); 241 | }); 242 | } 243 | }); 244 | }); 245 | 246 | /** Serve static assets if production */ 247 | if (process.env.NODE_ENV === 'production') { 248 | app.use(express.static(path.resolve(__dirname, '../client', 'dist'))); 249 | app.get('*', (req, res) => { 250 | res.sendFile(path.resolve(__dirname, '../client', 'dist', 'index.html')); 251 | }); 252 | } 253 | 254 | if (process.env.NODE_ENV !== 'test') { 255 | server.listen(process.env.PORT || 5000, () => { 256 | logger.info(`[LOG=SERVER] Server started on port ${process.env.PORT}`); 257 | }); 258 | } 259 | 260 | module.exports = { app }; 261 | -------------------------------------------------------------------------------- /server/tests/auth.test.js: -------------------------------------------------------------------------------- 1 | const { app } = require('../server'); 2 | const supertest = require('supertest'); 3 | const { userSeedData } = require('./seed/seedData'); 4 | const slugify = require('slugify'); 5 | 6 | beforeAll(() => { 7 | jest.setTimeout(30000); 8 | }); 9 | 10 | describe('POST /auth', () => { 11 | let request = supertest(app); 12 | 13 | it('should register a user and return a token', async () => { 14 | const response = await request.post('/api/auth/register').send({ 15 | handle: slugify('newUser100'), 16 | email: 'newUser@gmail.com', 17 | username: 'newUser100', 18 | password: 'newUserTest' 19 | }); 20 | 21 | expect(response.status).toEqual(200); 22 | expect(response.body.user.session_id).not.toBeNull(); 23 | expect(response.body.token).not.toBeNull(); 24 | expect(response.body.auth).not.toBeNull(); 25 | }); 26 | 27 | it('shouldnt register with invalid details', async () => { 28 | const response = await request.post('/api/auth/register').send({ 29 | email: 'test100@hotmail.com', 30 | username: 'test', 31 | password: 'testing100' 32 | }); 33 | 34 | expect(response.status).toEqual(200); 35 | expect(typeof response.body).toBe('object'); 36 | expect(response.body).not.toBeNull(); 37 | }); 38 | 39 | it('should login a user and return a token', async () => { 40 | const response = await request 41 | .post('/api/auth/login') 42 | .send({ email: userSeedData[0].email, password: userSeedData[0].password }); 43 | 44 | expect(response.status).toEqual(200); 45 | expect(response.body.token).not.toBeNull(); 46 | expect(response.body.user.session_id).not.toBeNull(); 47 | expect(response.body.auth).not.toBeNull(); 48 | }); 49 | 50 | it('should return an object given the wrong login details', async () => { 51 | const response = await request 52 | .post('/api/auth/login') 53 | .send({ email: 'testwrongemail@hotmail.com', password: 'testing123' }); 54 | 55 | expect(response.status).toEqual(200); 56 | expect(typeof response.body).toBe('object'); 57 | }); 58 | }); 59 | 60 | /** Watch Mode: Repopulate UserData after auth tests finish running (Due to auth testing for registration) */ 61 | // afterAll(async () => { 62 | // await populateData(); 63 | // }) 64 | -------------------------------------------------------------------------------- /server/tests/messages.test.js: -------------------------------------------------------------------------------- 1 | const { app } = require('../server'); 2 | const { userSeedData, messageSeedData } = require('./seed/seedData'); 3 | 4 | const supertest = require('supertest'); 5 | 6 | let token; 7 | let request = supertest(app); 8 | 9 | beforeAll(async () => { 10 | jest.setTimeout(30000); 11 | const response = await request 12 | .post('/api/auth/login') 13 | .send({ email: userSeedData[0].email, password: userSeedData[0].password }); 14 | 15 | token = response.body.token; 16 | }); 17 | 18 | describe('GET /api/messages', () => { 19 | it('should return an array of messages for a room', async () => { 20 | let response = await request.get('/api/room').set('Authorization', token); 21 | 22 | let room_id = response.body[response.body.length - 1]._id; 23 | 24 | response = await request.get(`/api/messages/${room_id}`).set('Authorization', token); 25 | 26 | expect(response.status).toEqual(200); 27 | expect(response.body[0].content).toEqual('Test Message 11'); 28 | expect(response.body[0].room).toEqual(room_id); 29 | expect(response.body.length).toBeGreaterThan(0); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /server/tests/middleware/authenticate.test.js: -------------------------------------------------------------------------------- 1 | const { createErrorObject } = require("../../middleware/authenticate"); 2 | 3 | describe("Authenticate Middleware", () => { 4 | it("should create a valid error object", () => { 5 | const errorArray = [ 6 | { 7 | param: "username", 8 | msg: "Username is not valid!" 9 | } 10 | ]; 11 | 12 | const errorObject = createErrorObject(errorArray); 13 | 14 | expect(typeof errorObject).toBe("object"); 15 | expect(errorObject).not.toBeNull(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /server/tests/profile.test.js: -------------------------------------------------------------------------------- 1 | const { app } = require('../server'); 2 | const { userSeedData } = require('./seed/seedData'); 3 | const supertest = require('supertest'); 4 | const slugify = require('slugify'); 5 | 6 | let token; 7 | let request = supertest(app); 8 | 9 | beforeAll(async () => { 10 | jest.setTimeout(30000); 11 | const response = await request 12 | .post('/api/auth/login') 13 | .send({ email: userSeedData[0].email, password: userSeedData[0].password }); 14 | 15 | token = response.body.token; 16 | }); 17 | 18 | describe('GET /profile', () => { 19 | it('should return the user data based on user handle', async () => { 20 | const response = await request 21 | .get(`/api/profile/${slugify(userSeedData[0].username)}`) 22 | .set('Authorization', token); 23 | 24 | expect(response.status).toBe(200); 25 | expect(response.body).not.toBeNull(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /server/tests/room.test.js: -------------------------------------------------------------------------------- 1 | const { app } = require('../server'); 2 | const { userSeedData } = require('./seed/seedData'); 3 | 4 | const supertest = require('supertest'); 5 | 6 | let token; 7 | let request = supertest(app); 8 | let roomId; 9 | 10 | beforeAll(async () => { 11 | jest.setTimeout(30000); 12 | const response = await request 13 | .post('/api/auth/login') 14 | .send({ email: userSeedData[0].email, password: userSeedData[0].password }); 15 | 16 | token = response.body.token; 17 | }); 18 | 19 | describe('POST /api/room', () => { 20 | it('should create a new room', async () => { 21 | const response = await request 22 | .post('/api/room') 23 | .send({ 24 | room_name: 'Jests Test Room', 25 | password: '' 26 | }) 27 | .set('Authorization', token); 28 | 29 | roomId = response.body._id; 30 | 31 | expect(response.status).toEqual(200); 32 | expect(response.body).not.toBeNull(); 33 | expect(Object.keys(response.body).length).toBeGreaterThan(0); 34 | }); 35 | 36 | it('should verify a private room password', async () => { 37 | const response = await request 38 | .post('/api/room/verify') 39 | .send({ room_name: 'Private Room', password: 'private' }) 40 | .set('Authorization', token); 41 | 42 | expect(response.status).toEqual(200); 43 | expect(response.body).not.toBeNull(); 44 | expect(response.body.success).toBeTruthy(); 45 | }); 46 | }); 47 | 48 | describe('GET /api/room', () => { 49 | it('should return an array of rooms', async () => { 50 | const response = await request.get('/api/room').set('Authorization', token); 51 | 52 | expect(response.status).toEqual(200); 53 | expect(response.body.length).toBeGreaterThan(0); 54 | }); 55 | 56 | it('should get the room by room id', async () => { 57 | const response = await request 58 | .get(`/api/room/${roomId.toString()}`) 59 | .set('Authorization', token); 60 | 61 | expect(response.status).toEqual(200); 62 | expect(response.body).not.toBeNull(); 63 | expect(Object.keys(response.body).length).toBeGreaterThan(0); 64 | }); 65 | }); 66 | 67 | describe('PUT /api/room/:room_name', () => { 68 | it('should update the room name', async () => { 69 | const response = await request 70 | .post('/api/room/update/name') 71 | .send({ room_name: 'Jests Test Room', new_room_name: 'Jest Test Room' }) 72 | .set('Authorization', token); 73 | 74 | expect(response.status).toEqual(200); 75 | expect(response.body).not.toBeNull(); 76 | expect(response.body.name).toEqual('Jest Test Room'); 77 | expect(Object.keys(response.body).length).toBeGreaterThan(0); 78 | }); 79 | }); 80 | 81 | describe('DELETE /api/room/:room_name', () => { 82 | it('should delete a room based on the name', async () => { 83 | const room_name = 'Jest Test Room'; 84 | let response = await request.delete(`/api/room/${room_name}`).set('Authorization', token); 85 | 86 | expect(response.status).toEqual(200); 87 | expect(Object.keys(response.body).length).toBeGreaterThan(0); 88 | 89 | response = await request.get('/api/room').set('Authorization', token); 90 | 91 | expect(response.body).not.toContain(room_name); 92 | }); 93 | 94 | it('should return an error with a unknown room name', async () => { 95 | const room_name = 'Jest sTest Room'; 96 | let response = await request.delete(`/api/room/${room_name}`).set('Authorization', token); 97 | 98 | expect(response.status).toEqual(404); 99 | expect(Object.keys(response.body).length).toBeGreaterThan(0); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /server/tests/seed/seed.js: -------------------------------------------------------------------------------- 1 | const { populateData } = require('./seedFunctions'); 2 | 3 | populateData(); 4 | -------------------------------------------------------------------------------- /server/tests/seed/seedData.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | userSeedData: [ 3 | { 4 | email: 'test1@hotmail.com', 5 | username: 'testing1', 6 | password: 'testing123' 7 | }, 8 | { 9 | email: 'test2@hotmail.com', 10 | username: 'testing2', 11 | password: 'testing1234' 12 | }, 13 | { 14 | email: 'test3@hotmail.com', 15 | username: 'testing 3', 16 | password: 'testing33' 17 | }, 18 | { 19 | email: 'test4@hotmail.com', 20 | username: 'testing4', 21 | password: 'testing44' 22 | }, 23 | { 24 | email: 'test5@hotmail.com', 25 | username: 'testing5', 26 | password: 'testing55' 27 | } 28 | ], 29 | roomSeedData: [ 30 | { 31 | name: 'Test Room #1', 32 | password: 'test' 33 | }, 34 | { 35 | name: 'Test Room #2', 36 | password: '' 37 | }, 38 | { 39 | name: 'Test Room', 40 | password: '' 41 | }, 42 | { 43 | name: 'Private Room', 44 | password: 'private' 45 | } 46 | ], 47 | messageSeedData: [ 48 | { 49 | content: 'Test Message 11' 50 | }, 51 | { 52 | content: 'Test Message 2' 53 | }, 54 | { 55 | content: 'Test Message 3' 56 | } 57 | ] 58 | }; 59 | -------------------------------------------------------------------------------- /server/tests/seed/seedFunctions.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { mongoose, connect } = require('../../db/mongoose'); 3 | const { User } = require('../../models/User'); 4 | const { Room } = require('../../models/Room'); 5 | const { Message } = require('../../models/Message'); 6 | const gravatar = require('gravatar'); 7 | const { userSeedData, roomSeedData, messageSeedData } = require('./seedData'); 8 | const slugify = require('slugify'); 9 | 10 | const populateData = async () => { 11 | if (mongoose.connection.readyState === 0) { 12 | connect(); 13 | } 14 | 15 | let userId; 16 | let roomId; 17 | 18 | console.log('\n[PROCESS:SEED] Seeding User Data'); 19 | 20 | await User.deleteMany({}).exec(); 21 | 22 | for (let user of userSeedData) { 23 | const userData = await new User({ 24 | handle: slugify(user.username), 25 | username: user.username, 26 | email: user.email, 27 | password: user.password, 28 | image: gravatar.url(user.email, { s: '220', r: 'pg', d: 'identicon' }) 29 | }).save(); 30 | userId = userData._id; 31 | } 32 | 33 | console.log('[PROCESS:FIN] Completed Seeding User Data'); 34 | 35 | console.log('[PROCESS:SEED] Seeding Room Data'); 36 | 37 | await Room.deleteMany({}).exec(); 38 | 39 | for (let room of roomSeedData) { 40 | const roomData = await new Room({ 41 | name: room.name, 42 | user: userId, 43 | access: room.password ? false : true, 44 | password: room.password 45 | }).save(); 46 | roomId = roomData._id; 47 | } 48 | 49 | console.log('[PROCESS:FIN] Completed Seeding Room Data'); 50 | 51 | console.log('[PROCESS:SEED] Seeding Message Data'); 52 | 53 | await Message.deleteMany({}).exec(); 54 | 55 | for (let message of messageSeedData) { 56 | await new Message({ 57 | content: message.content, 58 | user: userId, 59 | room: roomId 60 | }).save(); 61 | } 62 | 63 | console.log('[PROCESS:FIN] Completed Seeding Message Data'); 64 | 65 | await mongoose.connection.close(); 66 | }; 67 | 68 | module.exports = { populateData }; 69 | -------------------------------------------------------------------------------- /server/tests/setup.js: -------------------------------------------------------------------------------- 1 | const { populateData } = require('./seed/seedFunctions'); 2 | 3 | module.exports = async () => { 4 | await populateData(); 5 | }; 6 | -------------------------------------------------------------------------------- /server/tests/user.test.js: -------------------------------------------------------------------------------- 1 | const { app } = require('../server'); 2 | const { userSeedData } = require('./seed/seedData'); 3 | const supertest = require('supertest'); 4 | 5 | let token; 6 | let request = supertest(app); 7 | 8 | beforeAll(async () => { 9 | jest.setTimeout(30000); 10 | const response = await request 11 | .post('/api/auth/login') 12 | .send({ email: userSeedData[0].email, password: userSeedData[0].password }); 13 | 14 | token = response.body.token; 15 | }); 16 | 17 | describe('GET /users', () => { 18 | it('should return an array of users', async () => { 19 | const response = await request.get('/api/user/users').set('Authorization', token); 20 | 21 | expect(response.status).toEqual(200); 22 | expect(response.body.length).toBeGreaterThan(0); 23 | }); 24 | 25 | it('should return 401 if not authorized', async () => { 26 | const response = await request 27 | .get('/api/user/users') 28 | .set('Authorization', 'bearer testing'); 29 | 30 | expect(response.status).toEqual(401); 31 | }); 32 | 33 | it('should return the user data from the current user', async () => { 34 | const response = await request.get('/api/user/current').set('Authorization', token); 35 | 36 | expect(response.status).toBe(200); 37 | expect(response.body).not.toBeNull(); 38 | }); 39 | 40 | it('should return an error if invalid username is entered', async () => { 41 | const response = await request.get('/api/user/unknown').set('Authorization', token); 42 | 43 | expect(response.status).toBe(404); 44 | expect(response.body.error).not.toBeNull(); 45 | }); 46 | }); 47 | --------------------------------------------------------------------------------