diff --git a/.eslintrc.json b/.eslintrc.json index 48e35cb..8f2d3ac 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,8 @@ "env": { "node": true, "browser": true, - "es2021": true + "es2021": true, + "jest": true }, "plugins": ["import", "react", "jsx-a11y", "css-modules"], "extends": [ @@ -39,4 +40,4 @@ } } } -} \ No newline at end of file +} diff --git a/.gitignore b/.gitignore index d241c34..c3f62c9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,7 @@ package-lock.json npm-debug.log* # output from webpack -dist/ \ No newline at end of file +dist/ + +#environment file +.env \ No newline at end of file diff --git a/__tests__/routes.js b/__tests__/routes.js new file mode 100644 index 0000000..368b611 --- /dev/null +++ b/__tests__/routes.js @@ -0,0 +1,324 @@ +const request = require('supertest'); +const mongoose = require('mongoose'); +const Users = require('../server/models/userModel'); +const Snippets = require('../server/models/snippetModel'); + +require('dotenv').config(); + +const server = 'http://localhost:3000'; +const mongoURI = process.env.MONGO_URI; + +describe('Snippets route', () => { + let user; + let snippet_id; + const username = '__DummyData__'; + const password = 'codesmith'; + const snippet = { + title: 'FAKE DATA', + comments: 'FUN COMMENTS!', + storedCode: 'cry()', + tags: ['1', '2', '3'], + language: 'Klingon', + }; + const newSnippet = { + title: 'NEW FAKE DATA', + comments: 'MORE FUN COMMENTS!', + storedCode: 'cryAgain()', + tags: ['4', '5', '6'], + language: 'Huttese', + }; + describe('GET', () => { + //before all GET test: + beforeAll(async () => { + console.log('Connecting to the database!'); + await mongoose.connect(mongoURI); + + console.log('Creating dummy data!'); + user = await Users.create({ username, password }); + + const fakeSnippet = await Snippets.create(snippet); + snippet_id = fakeSnippet._id; + user.snippets.push(fakeSnippet._id); + + return user.save(); + }); + + afterAll(async () => { + console.log('Deleting dummy data!'); + await Users.findByIdAndDelete(user._id); + await Snippets.findByIdAndDelete(snippet_id); + + console.log('Disconnecting from the database!'); + return await mongoose.connection.close(); + }); + it('responds with 200 status and json', () => { + return request(server) + .get(`/snippets/?userId=${user._id}`) + .expect(200) + .expect('Content-Type', 'application/json; charset=utf-8'); + }); + it('responds with data that has keys: title, comments, storedCode, language', () => { + return request(server) + .get(`/snippets/?userId=${user._id}`) + .expect((res) => { + if (!res.body[0].hasOwnProperty('title')) { + throw new Error("Expected 'title' key!"); + } + if (!res.body[0].hasOwnProperty('comments')) { + throw new Error("Expected 'comments' key!"); + } + if (!res.body[0].hasOwnProperty('language')) { + throw new Error("Expected 'language' key!"); + } + if (!res.body[0].hasOwnProperty('storedCode')) { + throw new Error("Expected 'storedCode' key!"); + } + }); + }); + it('responds with data that has key, tags, and value of an array', () => { + return request(server) + .get(`/snippets/?userId=${user._id}`) + .expect((res) => { + if (!res.body[0].hasOwnProperty('tags')) { + throw new Error("Expected 'tags' key!"); + } + if (!Array.isArray(res.body[0].tags)) { + throw new Error("Expected 'tags' to be an array!"); + } + }); + }); + }); + describe('POST', () => { + beforeAll(async () => { + console.log('Connecting to the database!'); + await mongoose.connect(mongoURI); + + console.log('Creating dummy data!'); + user = await Users.create({ username, password }); + + return user.save(); + }); + afterAll(async () => { + console.log('Deleting dummy data!'); + + await Users.findByIdAndDelete(user._id); + + console.log('Disconnecting from the database!'); + return await mongoose.connection.close(); + }); + afterEach(async () => { + user = await Users.findById(user._id); + snippet_id = user.snippets.pop(); + await user.save(); + return await Snippets.findByIdAndDelete(snippet_id); + }); + it('responds with 200 status and json', () => { + return request(server) + .post(`/snippets/?userId=${user._id}`) + .send(snippet) + .expect(200) + .expect('Content-Type', 'application/json; charset=utf-8'); + }); + it('responds with the newly created document', () => { + return request(server) + .post(`/snippets/?userId=${user._id}`) + .send(snippet) + .expect((res) => { + expect(res.body.title).toBe(snippet.title); + expect(res.body.comments).toBe(snippet.comments); + expect(res.body.storedCode).toBe(snippet.storedCode); + expect(res.body.language).toBe(snippet.language); + expect(res.body.tags).toEqual(snippet.tags); + }); + }); + it("pushes newly created document to user's snippets array", () => { + return request(server) + .post(`/snippets/?userId=${user._id}`) + .send(snippet) + .expect(async (res) => { + user = await Users.findById(user._id); + expect(user.snippets.length).toEqual(1); + }); + }); + }); + describe('PUT', () => { + beforeAll(async () => { + console.log('Connecting to the database!'); + await mongoose.connect(mongoURI); + + console.log('Creating dummy data!'); + user = await Users.create({ username, password }); + + const fakeSnippet = await Snippets.create(snippet); + snippet_id = fakeSnippet._id; + + return; + }); + afterEach(async () => { + return await Snippets.findByIdAndUpdate(snippet_id, snippet); + }); + afterAll(async () => { + console.log('Deleting dummy data!'); + await Snippets.findByIdAndDelete(snippet_id); + await Users.findByIdAndDelete(user._id); + + console.log('Disconnecting from the database!'); + return await mongoose.connection.close(); + }); + it('responds with 200 status and json', () => { + return request(server) + .put(`/snippets/?snippetId=${snippet_id}&userId=${user._id}`) + .send(newSnippet) + .expect(200) + .expect('Content-Type', 'application/json; charset=utf-8'); + }); + it('responds with unupdated document', () => { + return request(server) + .put(`/snippets/?snippetId=${snippet_id}&userId=${user._id}`) + .send(newSnippet) + .expect((res) => { + console.log(res.body); + expect(res.body.snippet.title).toEqual(snippet.title); + expect(res.body.snippet.comments).toEqual(snippet.comments); + expect(res.body.snippet.language).toEqual(snippet.language); + }); + }); + }); + describe('DELETE', () => { + beforeAll(async () => { + console.log('Connecting to the database!'); + await mongoose.connect(mongoURI); + + console.log('Creating dummy data!'); + return (user = await Users.create({ username, password })); + }); + beforeEach(async () => { + const fakeSnippet = await Snippets.create(snippet); + snippet_id = fakeSnippet._id; + user.snippets.push(fakeSnippet._id); + return user.save(); + }); + afterAll(async () => { + console.log('Deleting dummy data!'); + await Users.findByIdAndDelete(user._id); + + console.log('Disconnecting from the database!'); + return await mongoose.connection.close(); + }); + it('responds with 200 status and json', () => { + return request(server) + .delete(`/snippets/?userId=${user._id}&snippetId=${snippet_id}`) + .expect(200) + .expect('Content-Type', 'application/json; charset=utf-8'); + }); + it('responds with the deleted document', () => { + return request(server) + .delete(`/snippets/?userId=${user._id}&snippetId=${snippet_id}`) + .expect((res) => { + expect(res.body.snippet.title).toBe(snippet.title); + expect(res.body.snippet.comments).toBe(snippet.comments); + expect(res.body.snippet.storedCode).toBe(snippet.storedCode); + expect(res.body.snippet.language).toBe(snippet.language); + expect(res.body.snippet.tags).toEqual(snippet.tags); + }); + }); + it("removes delete document from user's snippets array", () => { + return request(server) + .delete(`/snippets/?userId=${user._id}&snippetId=${snippet_id}`) + .expect(async () => { + user = await Users.findById(user._id); + expect(user.snippets.length).toEqual(0); + }); + }); + }); +}); + +describe('Authentication route', () => { + let user; + const username = '__DummyData__'; + const password = 'codesmith'; + const languages = ['klingon']; + const tags = ['1', '2', '3']; + describe('GET', () => { + beforeAll(async () => { + console.log('Connecting to the database!'); + await mongoose.connect(mongoURI); + + console.log('Creating dummy data!'); + return (user = await Users.create({ + username, + password, + languages, + tags, + })); + }); + afterAll(async () => { + console.log('Deleting dummy data!'); + await Users.findByIdAndDelete(user._id); + + console.log('Disconnecting from database!'); + return await mongoose.connection.close(); + }); + it('responds with 200 status and json', () => { + return request(server) + .get(`/authentication/?_id=${user._id}`) + .expect(200) + .expect('Content-Type', 'application/json; charset=utf-8'); + }); + it('responds with the correct user data', () => { + return request(server) + .get(`/authentication/?_id=${user._id}`) + .expect((res) => { + expect(res.body.username).toEqual(username); + expect(res.body.languages[0]).toBe(languages[0]); + expect(res.body.tags[0]).toBe(tags[0]); + }); + }); + }); + describe('POST', () => { + beforeAll(async () => { + console.log('Connecting to the database!'); + return await mongoose.connect(mongoURI); + }); + afterEach(async () => { + console.log('Deleting dummy data!'); + return await Users.findByIdAndDelete(user._id); + }); + afterAll(async () => { + console.log('Disconnecting from database!'); + return await mongoose.connection.close(); + }); + it('responds with 200 status and json', () => { + return request(server) + .post('/authentication/signup') + .send({ username, password }) + .expect(201) + .expect('Content-Type', 'application/json; charset=utf-8') + .expect((res) => { + user = res.body; + }); + }); + it('responds with newly created user document', () => { + return request(server) + .post('/authentication/signup') + .send({ username, password }) + .expect((res) => { + user = res.body; + expect(res.body.username).toEqual(username); + }); + }); + }); +}); + +describe('Error handling', () => { + describe('Invalid route', () => { + it('it returns a 404 status and error message', () => { + return request(server) + .get('/lorem_ipsum') + .expect(404) + .expect((res) => { + expect(res.text).toEqual('Invalid endpoint'); + }); + }); + }); +}); diff --git a/client/App.jsx b/client/App.jsx index c9b1e1c..1c4a51f 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -1,10 +1,13 @@ import React from 'react'; + +// importing child components import MainContainer from './src/containers/MainContainer/MainContainer.jsx'; const App = () => ( -
+
+

CODESNIPPET

); -export default App; \ No newline at end of file +export default App; diff --git a/client/src/assets/arrow.png b/client/src/assets/arrow.png index 0302fec..79dbf61 100644 Binary files a/client/src/assets/arrow.png and b/client/src/assets/arrow.png differ diff --git a/client/src/components/AddSnippet/AddSnippet.jsx b/client/src/components/AddSnippet/AddSnippet.jsx index efebf01..c1aa27b 100644 --- a/client/src/components/AddSnippet/AddSnippet.jsx +++ b/client/src/components/AddSnippet/AddSnippet.jsx @@ -1,21 +1,30 @@ -import CodeMirror from '@uiw/react-codemirror'; -import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; -import { languages } from '@codemirror/language-data'; -import { langs } from '@uiw/codemirror-extensions-langs'; -import styles from './AddSnippet.module.scss'; import React, { useState } from 'react'; + +// importing child components import SaveModal from '../../components/AddSnippet/SaveModal.jsx'; import TagInput from '../../components/ui/TagInput/TagInput'; + +// importing external functionality +import CodeMirror from '@uiw/react-codemirror'; +import PropTypes from 'prop-types'; +import { langs } from '@uiw/codemirror-extensions-langs'; + +// importing utils import Modal from 'react-bootstrap/Modal'; import Button from 'react-bootstrap/Button'; + +// importing styles +import styles from './AddSnippet.module.scss'; + +// importing data import { LANGUAGES } from '../../data/data.js'; -const AddSnippet = ({ closeModal }) => { +const AddSnippet = ({ closeModal, getUserData }) => { const [title, setTitle] = useState(''); const [language, setLanguage] = useState(''); const [comments, setComments] = useState(''); const [storedCode, setStoredCode] = useState(''); - const [tagList, setTags] = useState(''); + const [tagList, setTags] = useState([]); const [error, setError] = useState(false); const [openModal, setOpenModal] = useState(false); @@ -32,27 +41,25 @@ const AddSnippet = ({ closeModal }) => { fetch('/snippets', { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title, language: language, comments: comments, tags: tagList, - storedCode: storedCode, - }), + storedCode: storedCode + }) }) - .then((data) => data.json()) + .then((data) => { + getUserData(); + closeModal(false); + data.json(); + }) .catch((err) => { console.log(err); console.log('failed saving snippet'); }); - - - // setTitle(''); - // setLanguage(''); - // setComments(''); - // setStoredCode(''); } // wrapper function for setTags to send to TagInput @@ -72,26 +79,30 @@ const AddSnippet = ({ closeModal }) => { centered > - Add a snippet + + Add a snippet +
- - + { setTitle(e.target.value); }} > - {error && Title is required!} + {error && Title is required!}

- - setLanguage(e.target.value)} > @@ -104,8 +115,9 @@ const AddSnippet = ({ closeModal }) => {

- + { @@ -115,8 +127,12 @@ const AddSnippet = ({ closeModal }) => {

- - + +
Enter code:
@@ -127,11 +143,10 @@ const AddSnippet = ({ closeModal }) => { // value={storedCode} extensions={[langs.tsx()]} placeholder={ - "const sayHi = () => {\n console.log('Hello World!)\n}" + "const sayHi = () => {\n console.log('Hello World!')\n}" } onChange={(e) => setStoredCode(e)} - > - + > {/* {
- {openModal && } + {openModal && } @@ -154,11 +169,15 @@ const AddSnippet = ({ closeModal }) => { Save -
); }; +AddSnippet.propTypes = { + closeModal: PropTypes.func, + getUserData: PropTypes.func +}; + export default AddSnippet; diff --git a/client/src/components/AddSnippet/SaveModal.jsx b/client/src/components/AddSnippet/SaveModal.jsx index d5c8e25..43a8625 100644 --- a/client/src/components/AddSnippet/SaveModal.jsx +++ b/client/src/components/AddSnippet/SaveModal.jsx @@ -1,13 +1,7 @@ -import React, { useState} from 'react'; -import Modal from 'react-bootstrap/Modal'; +import React from 'react'; function SaveModal() { - - return ( -
- You have successfully saved a snippet! -
- ) + return
You have successfully saved a snippet!
; } export default SaveModal; diff --git a/client/src/components/SnippetDisplay/SnippetDisplay.jsx b/client/src/components/SnippetDisplay/SnippetDisplay.jsx index 2bba1ca..5c98f9b 100644 --- a/client/src/components/SnippetDisplay/SnippetDisplay.jsx +++ b/client/src/components/SnippetDisplay/SnippetDisplay.jsx @@ -1,210 +1,222 @@ -import React, { useState } from 'react'; -// import Snippet from +import React, { useState, useEffect } from 'react'; + +// importing child components +import TagInput from '../../components/ui/TagInput/TagInput'; + +// importing external functionality +import PropTypes from 'prop-types'; import { CopyToClipboard } from 'react-copy-to-clipboard'; import CodeMirror from '@uiw/react-codemirror'; import styles from './SnippetDisplay.module.scss'; import { langs } from '@uiw/codemirror-extensions-langs'; -import TagInput from '../../components/ui/TagInput/TagInput'; -import {Card, Button} from 'react-bootstrap'; + +// importing utils +import { Card, Button } from 'react-bootstrap'; +import { set } from 'mongoose'; + const SnippetDisplay = ({ selectedSnippet, getSnippet }) => { - // indSnippet = this.props - // create delete method using fetch request - let snippetTitle = selectedSnippet.title ? selectedSnippet.title : ''; - let snippetLanguage = selectedSnippet.language ? selectedSnippet.language : ''; - let snippetComments = selectedSnippet.comments ? selectedSnippet.comments : ''; - let snippetStoredCode = selectedSnippet.storedCode ? selectedSnippet.storedCode : ''; - let snippetTagList = selectedSnippet.tags ? selectedSnippet.tags : []; + const defaultDisplayValues = { + title: '', + language: '', + comments: '', + storedCode: '', + tags: [] + }; - // create a state variable for each passed down state and the its setState function - // const [title, setTitle] = useState(snippetTitle); - // const [language, setLanguage] = useState(snippetLanguage); - // const [comments, setComments] = useState(snippetComments); - // const [storedCode, setStoredCode] = useState(snippetStoredCode); - // const [tagList, setTags] = useState(snippetTagList); + const [copied, setCopied] = useState(false); const [editButtonState, setEditButtonState] = useState(false); + const [currentDisplay, setCurrentDisplay] = useState(defaultDisplayValues); + + useEffect(() => { + setCurrentDisplay(selectedSnippet); + }, [selectedSnippet, getSnippet]); + + const handleCopy = () => { + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + }; - const deleteSnippet = (id) => { - fetch (`http://localhost:3000/snippets?id=${id}`, { - method: 'DELETE', + const deleteSnippet = (snippetId) => { + fetch('/snippets?' + new URLSearchParams({ snippetId }), { + method: 'DELETE' }) .then((response) => { if (response.ok) { + setCurrentDisplay(defaultDisplayValues); getSnippet(); } }) .catch((err) => { - return ({ + return { log: `SnippetDisiplay.deleteSnippet: Error: ${err}`, - status: err.status || 500, + status: err.status, message: 'There was an error deleting snippet.' - }) - }) - } + }; + }); + }; - const editSnippet = (id) => { - // const [oldState, setOldState] = React.useState([]); - // create an object (eventually will hold the updated state) - const updatedSnippet = { - id: id, - title: snippetTitle, - comments: snippetComments, - storedCode: snippetStoredCode, - tags: snippetTagList, - language: snippetLanguage - }; - // within fetch request (post) - // body: JSON.stringify(created object) - fetch (`/snippets?id=${id}`, { + const editSnippet = (snippetId) => { + fetch(`/snippets?${new URLSearchParams({ snippetId })}`, { method: 'PUT', - body: JSON.stringify(updatedSnippet) + headers: { 'Content-type': 'application/json' }, + body: JSON.stringify(currentDisplay) }) .then((response) => { - response.json(); + //Are we using this response anywhere? IF not, delete this. + return response.json(); + }) + .then((data) => { + console.log(data); getSnippet(); }) .catch((err) => { - return ({ + //What's happening here? Where is this being returned to? + return { log: `SnippetDisplay.editSnippet: Error: ${err}`, - status: err.status || 500, + status: err.status, message: 'There was an error editing code snippet.' - }); + }; }); }; - // copy code state - const [copied, setCopied] = useState(false); - - - const checkEdit = () => { - if (editButtonState === true) { - return( -
-
- Title: + const displayContent = ( +
+
+
+
+ Title: { - snippetTitle = e.target.value; + if (editButtonState) { + setCurrentDisplay({ + ...currentDisplay, + title: e.target.value + }); + } }} - > - - - Language: + > +
+
+ Language: { - snippetLanguage = e.target.value; + if (editButtonState) { + setCurrentDisplay({ + ...currentDisplay, + language: e.target.value + }); + } }} - > - - - Comments: + > +
+
+
+
+ Comments: { - snippetComments = e.target.value; + if (editButtonState) { + setCurrentDisplay({ + ...currentDisplay, + comments: e.target.value + }); + } }} - > - - - snippetTagList = e} tags={snippetTagList} /> - {/* {setTags}}> Title: {snippetTagList} */} + >
+
- {\n console.log(\'Hello World!)\n}'} - onChange={(e) => snippetStoredCode = (e)} - > - setCopied(true)} - > - - - + { + if (editButtonState) { + setCurrentDisplay({ ...currentDisplay, tags: e }); + } + }} + defaultTags={currentDisplay.tags} + readOnly={!editButtonState} + /> + {/* {setTags}}> Title: {snippetTagList} */} +
+ + {\n console.log(\'Hello World!)\n}'} + onChange={(e) => { + setCurrentDisplay({ ...currentDisplay, storedCode: e }); + }} + > + + + + {copied && copied to clipboard!} + +
+ ); + return ( + + + {displayContent} +
+ + - -
- ); - } - - if (editButtonState === false) { - return ( -
- -
-

Title: {snippetTitle}

-

Language: {snippetLanguage}

-

Comments: {snippetComments}

- - {/*
{renderTags()}
*/} -
- - {\n console.log(\'Hello World!)\n}'} - options={{ - readOnly: true, }} - onChange={(e) => snippetStoredCode = (e)} > - setCopied(true)} - > - - - - + Save Edit +
- ); - } - }; - - return ( - <> - - {checkEdit()} - -
- - -
- +
); }; +SnippetDisplay.propTypes = { + selectedSnippet: PropTypes.object, + getSnippet: PropTypes.func +}; export default SnippetDisplay; diff --git a/client/src/components/SnippetDisplay/SnippetDisplay.module.scss b/client/src/components/SnippetDisplay/SnippetDisplay.module.scss index 9044255..4d8e8ce 100644 --- a/client/src/components/SnippetDisplay/SnippetDisplay.module.scss +++ b/client/src/components/SnippetDisplay/SnippetDisplay.module.scss @@ -1,10 +1,34 @@ .editor { margin: 10px; - width: 1000px; + max-width: 100%; + min-width: 200px; + //width: 100%; // margin: auto; padding-bottom: 10px; } +.displayRow { + display: flex; + flex-direction: row; + padding-top: 10px !important; + padding-bottom: 10px !important; + gap: 10px; + input { + background-color: transparent; + color: whitesmoke; + border: none; + border-bottom: 1.5pt double whitesmoke; + } + span { + margin: 5px; + } + + .aspect-entry { + display: flex; + align-items: baseline; + } +} + .title { width: 185px; } @@ -37,22 +61,22 @@ .card { padding: 15px; - margin: 50px; + margin-left: 50px; + margin-right: 50px; display: flex; - } .addButton { border: transparent; - height: 50px; - width: 270px; - // padding-left: 7px; - // padding-right: 7px; - border-radius: 10px; - margin-left: 10px; - margin-right: 10px; - margin-bottom: 10px; - font-size: 1.5rem; - background-color: transparent; - color: white; -} \ No newline at end of file + height: 50px; + width: 270px; + // padding-left: 7px; + // padding-right: 7px; + border-radius: 10px; + margin-left: 10px; + margin-right: 10px; + margin-bottom: 10px; + font-size: 1.5rem; + background-color: transparent; + color: white; +} diff --git a/client/src/components/ui/TagInput/TagInput.jsx b/client/src/components/ui/TagInput/TagInput.jsx index 44f4a78..38cc13a 100644 --- a/client/src/components/ui/TagInput/TagInput.jsx +++ b/client/src/components/ui/TagInput/TagInput.jsx @@ -1,46 +1,42 @@ -import React, { useEffect } from 'react'; -import { TAGS } from '../../../data/data'; -import styles from './TagInput.module.scss'; +import React, { useState, useEffect } from 'react'; +import './TagInput.scss'; + +// importing utils import { WithContext as ReactTags } from 'react-tag-input'; import PropTypes from 'prop-types'; +// importing data +import { TAGS } from '../../../data/data'; + +// importing styles + const suggestions = TAGS.map((tag) => { return { id: tag, - text: tag, + text: tag }; }); const KeyCodes = { comma: 188, - enter: 13, + enter: 13 }; const delimiters = [KeyCodes.comma, KeyCodes.enter]; -const TagInput = (props) => { - - const [tags, setTags] = React.useState([]); +const TagInput = ({ onChange, defaultTags, readOnly }) => { + const [tags, setTags] = useState([]); const handleDelete = (i) => { setTags(tags.filter((tag, index) => index !== i)); }; - const initialTags = () => { - const newTagList = []; - if (props.tags) { - props.tags.forEach((tag) => newTagList.push({ id: tag, text: tag })); - setTags(newTagList); - } - }; - - // useEffect(() => { - // console.log('hello'); - // initialTags(); - // }, [tags]); - const handleAddition = (tag) => { - setTags([...tags, tag]); + console.log('Added tag:'); + console.dir(tag); + const newTags = tags.map((el) => el); + newTags.push(tag); + setTags(newTags); }; const handleDrag = (tag, currPos, newPos) => { @@ -57,16 +53,29 @@ const TagInput = (props) => { console.log('The tag at index ' + index + ' was clicked'); }; + useEffect(() => { + if (defaultTags) { + const tagArr = []; + defaultTags.forEach((tag) => { + tagArr.push({ id: tag, text: tag }); + }); + setTags(tagArr); + } + }, [defaultTags]); + useEffect(() => { const tagStringList = []; - if (props.onChange) { + if (onChange) { tags.forEach((tag) => tagStringList.push(tag.text)); - props.onChange(tagStringList); + onChange(tagStringList); } }, [tags]); return ( { TagInput.propTypes = { onChange: PropTypes.func, - tags: PropTypes.array, + defaultTags: PropTypes.array }; export default TagInput; diff --git a/client/src/components/ui/TagInput/TagInput.scss b/client/src/components/ui/TagInput/TagInput.scss new file mode 100644 index 0000000..138de40 --- /dev/null +++ b/client/src/components/ui/TagInput/TagInput.scss @@ -0,0 +1,26 @@ +.react-tags-wrapper { + display: flex; + flex-wrap: wrap; + + .ReactTags_tagInput { + display: flex; + } + .ReactTags_selected { + display: flex; + white-space: pre-wrap; + } + button { + //display: inline-block; + color: white !important; + background-color: inherit !important; + border: none; + } + + .tag-wrapper { + white-space: nowrap; + margin: 3px; + padding: 3px; + border: 1pt solid whitesmoke; + border-radius: 3px; + } +} diff --git a/client/src/components/userStart/Login.jsx b/client/src/components/userStart/Login.jsx new file mode 100644 index 0000000..4a12de1 --- /dev/null +++ b/client/src/components/userStart/Login.jsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; + +import styles from './Login.module.scss'; + +// eslint-disable-next-line react/prop-types +const Login = ({ handleLogin, handleHaveAccount, style, error }) => { + + return ( +
+
+

USER LOGIN

+
+
+     + +
+
+     + +
+ +
+ //

+ {error &&

wrong username or password!

} + +
+ +
+ ); +}; + +export default Login; diff --git a/client/src/components/userStart/Login.module.scss b/client/src/components/userStart/Login.module.scss new file mode 100644 index 0000000..796d61f --- /dev/null +++ b/client/src/components/userStart/Login.module.scss @@ -0,0 +1,8 @@ +.red { + border: 1.5px solid red; +} + +.p { + margin-top: 8px; + color: red; +} diff --git a/client/src/components/userStart/Signup.jsx b/client/src/components/userStart/Signup.jsx new file mode 100644 index 0000000..6f7fc05 --- /dev/null +++ b/client/src/components/userStart/Signup.jsx @@ -0,0 +1,43 @@ +import React, { useState } from 'react'; + +// eslint-disable-next-line react/prop-types +const Signup = ({ handleSigned, strengthMessage, handleStrength }) => { + return ( +
+

Create Your Account

+
+ + +
+
+ + handleStrength(e.target.value)} + type='password' + className='form-control' + id='psw' + /> + {strengthMessage === '' ? null : ( + + {strengthMessage} + + )} +
+ +
+ ); +}; + +export default Signup; diff --git a/client/src/containers/MainContainer/MainContainer.jsx b/client/src/containers/MainContainer/MainContainer.jsx index 2954daf..c5f8f25 100644 --- a/client/src/containers/MainContainer/MainContainer.jsx +++ b/client/src/containers/MainContainer/MainContainer.jsx @@ -1,14 +1,141 @@ -import React from 'react'; +import React, { useState } from 'react'; import Sidebar from '../Sidebar/Sidebar.jsx'; import styles from './MainContainer.module.scss'; +import Login from '../../components/userStart/Login.jsx'; +import Signup from '../../components/userStart/Signup.jsx'; +import validator from 'validator'; +const MainContainer = () => { + const [login, setLogin] = useState(false); + const [haveAccount, setHaveAccount] = useState(true); + const [strengthMessage, setStrengthMessage] = useState(''); + + //MAY NEED TO REMOVE -- DUPLICATE WITH STRENGTHMESSAGE + const [error, setError] = useState(false); + + + const handleLogin = (e) => { + e.preventDefault(); + const usernameInputValue = document.getElementById('username').value; + document.getElementById('username').value = ''; + const passwordInputValue = document.getElementById('password').value; + document.getElementById('password').value = ''; + + if(usernameInputValue === '' || passwordInputValue === ''){ + document.querySelector('.errorMessage').innerHTML = ' Username and password are required'; + return false; + }; + + + + fetch('http://localhost:3000/authentication/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + // include cookies from cross origin request + credentials: 'include', + body: JSON.stringify({ + username: usernameInputValue, + password: passwordInputValue, + }), + }) + .then((result) => result.json()) + .then((result) => { + console.log('result from login request: ', result); + setLogin(result.username); + }) + .catch((err) => { + + //MAY NEED TO REMOVE -- SEE IF TWO MESSAGES APPEAR + setError(true); + console.log(err); + + + document.querySelector('.errorMessage').innerHTML = 'Please verify your user information and try again!'; + return false; + + }); + }; + //functino to handle showing the signup page + const handleHaveAccount = () => setHaveAccount(false); + + //function to handle password strength check + const handleStrength = (input) => { + if(validator.isStrongPassword(input, { + minLength: 8, + minLowercase: 1, + minUppercase: 1, + minNumbers: 1, + minSymbols: 1 + })){ + setStrengthMessage('Strong Password'); + } else { + setStrengthMessage('Not A Strong Password'); + } + }; + + //function to handle sign-up if username was not already taken + const handleSigned = (e) => { + e.preventDefault(); + const nameValue = document.getElementById('user').value; + document.getElementById('user').value = ''; + const passwordValue = document.getElementById('psw').value; + document.getElementById('psw').value = ''; + + fetch('http://localhost:3000/authentication/signup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: nameValue, + password: passwordValue, + }), + }) + .then((result) => result.json()) + .then((result) => { + console.log('result from signup request: ', result); + setHaveAccount(true); + setLogin(result.username); + }) + .catch((err) => { + console.log(err); + }); + }; + + return login ? ( -const MainContainer = () =>{ - return (
+
+ +

+ welcome, {login} +

+
- ); -} + ) : haveAccount ? ( +
+ + +
+ ) : ( +
+ +
+ ); +}; -export default MainContainer; \ No newline at end of file +export default MainContainer; diff --git a/client/src/containers/MainContainer/MainContainer.module.scss b/client/src/containers/MainContainer/MainContainer.module.scss index 38ac063..27f568c 100644 --- a/client/src/containers/MainContainer/MainContainer.module.scss +++ b/client/src/containers/MainContainer/MainContainer.module.scss @@ -1,7 +1,35 @@ .container { - height: 100vh; + height: 100%; width: 100%; display: flex; +} + +.div { + position: absolute; + top: 1rem; + right: 3rem; +} + +.button { + margin-right: 1rem; + border: 1px solid transparent; + border-radius: 5px; +} + +.h2 { + display: inline-block; + background: -webkit-linear-gradient(#ffffff, #333); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.span { + font-family: 'Courier New', Courier, monospace; + font-size: 48px; + text-transform: uppercase; + background: -webkit-linear-gradient(#ffffff, #e2b2b2); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} -} \ No newline at end of file diff --git a/client/src/containers/Sidebar/Sidebar.jsx b/client/src/containers/Sidebar/Sidebar.jsx index 7177f59..97f8046 100644 --- a/client/src/containers/Sidebar/Sidebar.jsx +++ b/client/src/containers/Sidebar/Sidebar.jsx @@ -1,46 +1,58 @@ import React, { useState, useEffect } from 'react'; + +// importing child components import SnippetDisplay from '../../components/SnippetDisplay/SnippetDisplay.jsx'; import AddSnippet from '../../components/AddSnippet/AddSnippet.jsx'; -import styles from './Sidebar.module.scss'; import SnippetsRadioList from './SnippetsRadioList/SnippetsRadioList.jsx'; +import TagsList from './TagsList/TagsList.jsx'; + +// importing utils import { Card, Spinner } from 'react-bootstrap'; + +// importing styles +import styles from './Sidebar.module.scss'; + +// importing assets import arrow from '../../assets/arrow.png'; import img from '../../assets/star nose mole.jpeg'; -const Sidebar = () => { +const Sidebar = ({ handleLogin }) => { + //Snippets and selected snippet const [snippets, setSnippets] = useState([]); const [selectedSnippet, setSelectedSnippet] = useState({}); + + //Tags and selected tags + const [userTags, setUserTags] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); + const [filteredSnippets, setFilteredSnippets] = useState([]); + const [openModal, setOpenModal] = useState(false); const [collapse, setCollapse] = useState(false); const [loading, setLoading] = useState(true); + const [displayType, setDisplayType] = useState('snippets'); - // getSnippet func - const getSnippet = () => { - setLoading(true); - fetch('http://localhost:3000/snippets') - .then((res) => res.json()) - .then((res) => { - console.log('res', res); + useEffect(() => { + getUserData(); + }, []); - // moved setSnippets to outside of for loop so we arent re-rendering each time a snippet is added to state - const newSnippetArray = []; - for (const snippet of res.snippets) newSnippetArray.push(snippet); + useEffect(() => { + filterSnippetsByTags(); + }, [snippets, selectedTags]); + //Get all snippets stored under user's account - setSnippets(newSnippetArray); + const getUserData = () => { + setLoading(true); + fetch('/snippets') + .then((res) => res.json()) + .then((data) => { + //As structured in snippets route, should receive an array of snippet objects + setSnippets(data.snippets); + setUserTags([...data.tagsLangs.tags, ...data.tagsLangs.languages]); setLoading(false); }) - .catch((error) => console.log('Get request failed', error)); - }; - - // renderTags function - const renderTabs = () => { - const tabs = []; - - for (let i = 0; i < snippets.length; i++) { - tabs.push(); - } - - return tabs; + .catch((error) => + console.log('Failed to complete request for snippets: ', error) + ); }; // wrapper to send to our snippets radio list for updating selected snippet. probably not 100% needed, but want to be able to console log from Sidebar @@ -48,57 +60,157 @@ const Sidebar = () => { setSelectedSnippet(e); }; - // get data from backend at first page load - useEffect(() => getSnippet(), []); + const selectDeselectTag = (tagValue) => { + const newTagList = new Set(selectedTags); + if (!newTagList.has(tagValue)) { + newTagList.add(tagValue); + } else { + newTagList.delete(tagValue); + } + + setSelectedTags(Array.from(newTagList)); + }; const toggleSidebar = () => { setCollapse(() => !collapse); }; + const toggleDisplayType = (event) => { + setDisplayType(event.target.value); + }; + + const filterSnippetsByTags = () => { + const snippetSubset = snippets.filter((sn) => { + for (let i = 0; i < [...sn.tags, sn.lanuage].length; i++) { + if (selectedTags.includes([...sn.tags, sn.lanuage][i])) return true; + } + return false; + }); + + setFilteredSnippets(snippetSubset); + }; + + const snippetsDisplay = ( + + {/* Animation while app is fetching data from DB */} +
+ {loading && ( +
+ +
+ )} + +
+
+ ); + + const tagsDisplay = ( + + {/* Animation while app is fetching data from DB */} +
+ {loading && ( +
+ +
+ )} +
+
+ +
+
+
+ +
+
+
+
+ ); + return ( - <> - + + {/*----- SIDE BAR -----*/} + -

Code Snippets

+ {/* Changes the collapse state, which will render/unrender the sidebar*/} +
+ + +
- -
- {/* render our snippet list, pass down snippets and function to update selectedSnippet */} - {loading && ( -
- -
- )} - -
-
- -

Click me to add a new snippet!

+ + {/* Renders the list of snippets fetched from DB */} + + {displayType === 'snippets' ? snippetsDisplay : tagsDisplay} + -
- {openModal && } + + {/*----- ADD SNIPPET MODAL -----*/} + + {openModal && ( + + )} + + {/*----- SNIPPET DISPLAY -----*/} +
{ {snippets && ( )}
- +
); }; diff --git a/client/src/containers/Sidebar/Sidebar.module.scss b/client/src/containers/Sidebar/Sidebar.module.scss index b3215b1..555bb9d 100644 --- a/client/src/containers/Sidebar/Sidebar.module.scss +++ b/client/src/containers/Sidebar/Sidebar.module.scss @@ -9,19 +9,60 @@ // border-color: black; // } +.tagsSnippetsDisplayHolder { + display: flex; + flex-direction: column; + overflow: hidden; + max-height: 75vh; +} + +.tagsSnippetsDisplayBox { + padding: 8px 16px; + overflow-y: auto; + max-height: 50%; +} + +.displayTypeSelector { + display: flex; + justify-content: space-around; + text-align: center; + gap: 10px; + width: 100%; +} + +.displayTypeButtonActive { + background-color: transparent; + box-shadow: none; + width: 100%; + color: whitesmoke !important; + border: none; + border-bottom: 1.5pt solid whitesmoke !important; +} + +.displayTypeButtonInactive { + background-color: transparent; + box-shadow: none; + width: 100%; + color: rgb(124, 124, 124) !important; + border: none !important; +} + +.cardBodyContent { + //height: min-content; + overflow: hidden; +} .sidebar { width: 300px; - height: 100vh !important; + //height: inherit; + //max-height: 75vh; position: absolute; - left: -300px; + left: -250px; top: 0; - + transition: 200ms ease-in; background-color: rgb(10, 25, 41); - padding-top: 28px; - display: flex; flex-direction: column; justify-content: space-evenly; @@ -31,12 +72,12 @@ z-index: 20; right: -50px; top: 35px; - transform: scale(.5); + transform: scale(0.5); background: none; border: none; .arrow { - + color: whitesmoke; } .arrowOpen { @@ -45,7 +86,9 @@ } .cardBody { - + padding: 8px 16px; + overflow-y: auto; + max-height: 75vh; } } @@ -58,11 +101,13 @@ // z-index: -20; left: -250px; transition: 200ms ease-in; + width: 100%; } .snippetDisplayOpen { left: 0; transition: 200ms ease-in; + width: 100%; } .logo { @@ -97,17 +142,16 @@ .addButton { border: transparent; - height: 50px; - width: 270px; - // padding-left: 7px; - // padding-right: 7px; - border-radius: 10px; - margin-left: 10px; - margin-right: 10px; - margin-bottom: 10px; - font-size: 1.5rem; - background-color: transparent; - color: white; + height: 50px; + // padding-left: 7px; + // padding-right: 7px; + border-radius: 10px; + margin-left: 10px; + margin-right: 10px; + margin-bottom: 10px; + font-size: 1.5rem; + background-color: transparent; + color: white; } .img { @@ -122,4 +166,4 @@ margin-bottom: 110px; margin-left: 3px; font-size: 21px; -} \ No newline at end of file +} diff --git a/client/src/containers/Sidebar/SnippetsRadioList/SnippetsRadioList.jsx b/client/src/containers/Sidebar/SnippetsRadioList/SnippetsRadioList.jsx index 5fae115..d5b93f1 100644 --- a/client/src/containers/Sidebar/SnippetsRadioList/SnippetsRadioList.jsx +++ b/client/src/containers/Sidebar/SnippetsRadioList/SnippetsRadioList.jsx @@ -1,80 +1,63 @@ +import React, { Fragment, useEffect } from 'react'; + +// importing utils import PropTypes from 'prop-types'; + +// importing styles import styles from './SnippetsRadioList.module.scss'; -import { Fragment } from 'react'; -function SnippetsRadioList(props) { +function SnippetsRadioList({ snippets, setSelectedSnippet }) { // call passed in function on changed button, should find a way to do this without having to iterate through snippet array. store the snippet on the input itself somehow? const onChangeValue = (e) => { - if (!props.onChange) return; - for (const el of props.snippets) { - if (el.id !== undefined && el.id.toString() === e.target.value) - props.onChange(el); - } + if (!setSelectedSnippet) return; + setSelectedSnippet(buttonMapper[e.target.id]); }; - const toggleButtons = []; + const buttonMapper = {}; + const buttonList = []; - if (props.snippets) { - props.snippets.forEach((el, i) => { - const currButton = - i === 0 ? ( - - - -
-
- ) : ( - - - -
-
- ); + if (snippets) { + snippets.forEach((el, i) => { + const currButton = ( + + + +
+
+ ); - toggleButtons.push(currButton); + buttonMapper[`snippet-input-${i}`] = el; + buttonList.push(currButton); }); } return ( - <> +
onChangeValue(e)} > - {toggleButtons} + {buttonList}
- +
); } SnippetsRadioList.propTypes = { snippets: PropTypes.array, - onChange: PropTypes.func, + setSelectedSnippet: PropTypes.func }; export default SnippetsRadioList; diff --git a/client/src/containers/Sidebar/TagsList/TagsList.jsx b/client/src/containers/Sidebar/TagsList/TagsList.jsx new file mode 100644 index 0000000..5522660 --- /dev/null +++ b/client/src/containers/Sidebar/TagsList/TagsList.jsx @@ -0,0 +1,41 @@ +import React, { useEffect } from 'react'; + +// importing utils +import PropTypes from 'prop-types'; + +// importing styles +import styles from './TagsList.module.scss'; + +function TagsList({ allTags, selectedTags, selectDeselectTag }) { + const buttonList = []; + + const onTagClick = (e) => { + selectDeselectTag(e.target.value); + }; + + if (allTags) { + allTags.forEach((el) => { + const currButton = ( + + ); + buttonList.push(currButton); + }); + } + + return
{buttonList}
; +} +TagsList.propTypes = { + allTags: PropTypes.array, + selectedTags: PropTypes.array, + selectDeselectTag: PropTypes.func +}; +export default TagsList; diff --git a/client/src/containers/Sidebar/TagsList/TagsList.module.scss b/client/src/containers/Sidebar/TagsList/TagsList.module.scss new file mode 100644 index 0000000..17a0433 --- /dev/null +++ b/client/src/containers/Sidebar/TagsList/TagsList.module.scss @@ -0,0 +1,16 @@ +.activeTag { + border: none; + background-color: whitesmoke; + color: black; + border-radius: 10px; +} + +.inactiveTag { + border: 1pt solid whitesmoke; + background-color: black; + color: whitesmoke; + border-radius: 10px; +} + +.tagsListDisplay { +} diff --git a/client/src/scss/_global.scss b/client/src/scss/_global.scss index 0e76d3f..7abf2ad 100644 --- a/client/src/scss/_global.scss +++ b/client/src/scss/_global.scss @@ -1,15 +1,120 @@ +*::-webkit-scrollbar { + color: #232024; +} +*::-webkit-scrollbar-track { + background: transparentize(#232024, 1); +} + +*::-webkit-scrollbar-corner { + background: transparentize(#232024, 1); +} + +*::-webkit-scrollbar-thumb { + background: whitesmoke; +} + body { + background: url(https://wallpaperaccess.com/full/266479.png) no-repeat center + center fixed; + -webkit-background-size: cover; + -moz-background-size: cover; + -o-background-size: cover; + background-size: cover; +} + +#right { + background: radial-gradient(150px 40px at 195px bottom, #666, #222); + + color: rgb(186, 127, 241); +} + +#card { + background: radial-gradient(150px 40px at 195px bottom, #666, #222); + color: white; + margin-left: 20px; +} + +.login { + display: flex; + flex-direction: column; + min-height: 500px; + width: 60%; + margin: auto; + margin-top: 100px; + justify-content: center; + align-items: center; +} + +.errorMessage { + margin-top: 240px; + position: fixed; color: red; + font-size: 17px; } +.form { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-top: 10px; + height: 200px; +} -// this might be a bug. react tag library wasnt going inline -.ReactTags__selected { +#secondDiv { + margin-top: 15px; +} +#go { + margin-top: 20px; + width: 80px; + border: 1px solid; + background-color: rgb(4, 99, 129); + border-radius: 10px; + color: white; +} +#submit { + margin-top: 20px; + width: 100px; + border: 1px solid green; + background-color: green; + border-radius: 10px; + color: white; +} + +#loginBox { + background-color: rgb(18, 64, 84); + opacity: 0.9; + background: -webkit-linear-gradient(#eee, #333); + height: 400px; + color: black; + font-size: 16px; + font-weight: bold; + width: 600px; + border: 2px solid rgb(42, 41, 41); display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + border-radius: 12px; + border-style: double; +} + +#bottomMessage { + font-size: 20px; + background: -webkit-linear-gradient(#eee, #333); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; } -.cm-editor {outline: none !important} +#headerTitle { + font-size: 72px; + background: -webkit-linear-gradient(#eee, #333); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} -.entireSnippetDisplay { - z-index: 50; -} \ No newline at end of file +.signup { + width: 30%; + color: lightgrey; + margin: auto; + margin-top: 200px; +} diff --git a/package.json b/package.json index f13e347..02bfcd2 100644 --- a/package.json +++ b/package.json @@ -14,21 +14,30 @@ "dependencies": { "@uiw/codemirror-extensions-langs": "^4.19.16", "@uiw/react-codemirror": "^4.19.16", + "bcrypt": "^5.1.0", "bootstrap": "^5.2.3", "codemirror": "^6.0.1", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", + "dotenv": "^16.0.3", "express": "^4.18.2", + "express-openid-connect": "^2.16.0", + "jsonwebtoken": "^9.0.0", "mongodb": "^5.5.0", "mongoose": "^7.1.1", + "passport": "^0.6.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "react": "^18.2.0", - "react-copy-to-clipboard": "^5.1.0", "react-bootstrap": "^2.7.4", + "react-copy-to-clipboard": "^5.1.0", "react-dnd": "^14.0.5", "react-dnd-html5-backend": "^14.1.0", "react-dom": "^18.2.0", "react-modal": "^3.16.1", "react-tag-input": "^6.8.1", - "sass": "^1.62.1" + "sass": "^1.62.1", + "validator": "^13.9.0" }, "devDependencies": { "@babel/core": "^7.21.8", @@ -50,6 +59,7 @@ "nodemon": "^2.0.22", "sass-loader": "^13.2.2", "style-loader": "^3.3.2", + "supertest": "^6.3.3", "webpack": "^5.82.1", "webpack-cli": "^5.1.1", "webpack-dev-server": "^4.15.0" diff --git a/server/authConfig/jwt.config.js b/server/authConfig/jwt.config.js new file mode 100644 index 0000000..3cb8157 --- /dev/null +++ b/server/authConfig/jwt.config.js @@ -0,0 +1,43 @@ +const passport = require('passport'); +const User = require('../models/userModel.js'); + +const JwtStrategy = require('passport-jwt').Strategy, + ExtractJwt = require('passport-jwt').ExtractJwt; + +// load env variables +require('dotenv').config(); +const secret = process.env.JWT_SECRET; + +// Passport JWT config +const options = {}; +options.jwtFromRequest = (req) => { + let token = null; + if (req && req.cookies) { + token = req.cookies['token']; + } + return token; +}; +options.secretOrKey = secret; + +// new JWT strategy +const jwtStrategy = new JwtStrategy(options, + async (payload, done) => { + try{ + // find user based on JWT payload + const user = await User.findById(payload.userId); + if (user) { + // if user is found, return user + return done(null, user); + } else { + // if user is not found, return false + return done(null, false); + // or create a new account + } + } catch (err) { + // if error occurs pass to done callback + return done(err); + } + } +); + +module.exports = jwtStrategy; \ No newline at end of file diff --git a/server/authConfig/passport.js b/server/authConfig/passport.js new file mode 100644 index 0000000..4d06a1a --- /dev/null +++ b/server/authConfig/passport.js @@ -0,0 +1,52 @@ +const bcrypt = require('bcrypt'); +const passport = require('passport'); +const User = require('../models/userModel.js'); +const LocalStrategy = require('passport-local').Strategy; +const jwt = require('jsonwebtoken'); +const jwtStrategy = require('./jwt.config'); + + +passport.use(jwtStrategy); +passport.use(new LocalStrategy({ + usernameField: 'username', // field name for username in req body + passwordField: 'password', // field name for password in req body +}, async (username, password, done) => { + try { + const user = await User.findOne({ username }); + + if (!user) { + // User not found + return done(null, false, {message: 'Incorrect username or password'}); + } + + // unhash stored password with bcrypt and compare to input password + const passwordMatch = await bcrypt.compare(password, user.password); + + if (!passwordMatch) { + // incorrect password + return done(null, false, {message: 'Incorrect username or password'}); + } + + // Auth successful, return the authenticated user + return done(null, user); + } catch (err) { + // Error occured during the auth process + return done(`Error occured during the auth process ${err}`); + } +} +)); + +// Serialize the user object into a session +passport.serializeUser((user, done) => { + done(null, user.id); +}); + +// Deserialize the user object from a session +passport.deserializeUser(async (id, done) => { + try { + const user = await User.findById(id); + done(null, user); + } catch (err) { + done(err); + } +}); diff --git a/server/controllers/authenticationController.js b/server/controllers/authenticationController.js new file mode 100644 index 0000000..11a2c43 --- /dev/null +++ b/server/controllers/authenticationController.js @@ -0,0 +1,34 @@ +const passport = require('../authConfig/passport.js'); +const User = require('../models/userModel.js'); +const bcrypt = require('bcrypt'); +const authenticationController = {}; + +authenticationController.signUp = async (req, res, next) => { + const { username, password } = req.body; + try { + const existingUser = await User.findOne({ username }); + + if (existingUser) { + return next({ + log: 'Error occured in authenticationController.signUp', + status: 400, + message: 'Username already exists, please select another', + }); + } + + // password encryption + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + const newUser = await User.create({ + username: req.body.username, + password: hashedPassword, + }); + res.locals.newUser = newUser; + return next(); + } catch (err) { + return next(err); + } +}; + +module.exports = authenticationController; diff --git a/server/controllers/snippetsController.js b/server/controllers/snippetsController.js index 864cf42..5d6ff8b 100644 --- a/server/controllers/snippetsController.js +++ b/server/controllers/snippetsController.js @@ -1,13 +1,30 @@ const User = require('../models/userModel.js'); - +const Snippet = require('../models/snippetModel.js'); const snippetsController = {}; -snippetsController.getSnippets = (req, res, next) => { - const userId = '645fee9104d1f0acef95a002'; +//Error creator method specific to this controller +const createError = (method, log, status, message = log) => { + return { + log: `Error occurred in snippetsController.${method}: ${log}`, + status, + message: { err: message } + }; +}; + +//Retrieves all snippets associated with a user by looking up user (by ID) and referencing all snippets in the associated list +//NOTE: WE SHOULD REALLY SEPARATE OUT STUFF LIKE THIS INTO A SEPARATE USER ROUTE AND USER CONTROLLER +snippetsController.getSnippetsByUser = (req, res, next) => { + console.log('In get snippets...'); + const userId = req.user._id.toString(); + console.log('Logging ID...'); + console.log(userId); - User.findOne({ _id: userId }) + User.findById(userId) + .populate('snippets') + .exec() .then((user) => { - res.locals.allSnippets = user; + res.locals.allSnippets = user.snippets; + res.locals.userTagsLangs = { tags: user.tags, languages: user.languages }; return next(); }) .catch((err) => { @@ -16,121 +33,203 @@ snippetsController.getSnippets = (req, res, next) => { }); }; +//Creates snippet under Snippets collection snippetsController.createSnippet = (req, res, next) => { const { title, comments, storedCode, tags, language } = req.body; const snippet = { title, comments, storedCode, tags, language }; - const userId = '645fee9104d1f0acef95a002'; + Snippet.create(snippet) + .then((doc) => { + res.locals.newSnippet = doc; + return next(); + }) + .catch((err) => { + return next( + createError('createSnippet', `Error creating new snippet: ${err}`, 500) + ); + }); +}; +//Associates snippet with a particular user +snippetsController.saveSnippetToUser = (req, res, next) => { + const userId = req.user._id.toString(); User.findById(userId) .then((user) => { - // Increment the lastId and assign it to the new snippet - const newSnippetId = user.lastId + 1; - user.lastId = newSnippetId; - - // Create the new snippet object with the assigned ID - const newSnippet = { - id: newSnippetId, - ...snippet, - }; - - // Push the new snippet to the snippets array - user.snippets.push(newSnippet); - - const [tags, languages] = recalcTagsAndLang(user); - user.tags = tags; - user.languages = languages; - - // Save the updated user document - return user.save().then((updatedUser) => { - res.locals.createdSnippet = newSnippet; - next(); - }); + user.snippets.push(res.locals.newSnippet._id); + user + .save() + .then((r) => { + res.locals.updatedUserRecord = r; + res.locals.changeFlag = true; + return next(); + }) + .catch((err) => { + return next( + createError( + 'saveSnippetToUser', + `Encountered error when saving new snippet to user: ${err}`, + 500 + ) + ); + }); }) - .catch((error) => { - console.error('Creating a snippet has failed:', error); - next(error); + .catch((err) => { + return next( + createError( + 'saveSnippetToUser', + `Encountered error when retrieving user record: ${err}`, + 500 + ) + ); }); }; - + +//Updates snippet with provided properties snippetsController.updateSnippet = (req, res, next) => { - const { id, title, comments, storedCode, tags, language } = req.body; - const updatedSnippet = { id, title, comments, storedCode, tags, language }; - const userId = '645fee9104d1f0acef95a002'; - - User.findOneAndUpdate( - { _id: userId, 'snippets.id': updatedSnippet.id }, - { - $set: { 'snippets.$': updatedSnippet }, - }, - { new: true } + const { snippetId } = req.query; + const { title, comments, storedCode, tags, language } = req.body; + const updatedSnippet = { title, comments, storedCode, tags, language }; + + for (const key in updatedSnippet) { + if (!updatedSnippet[key]) { + delete updatedSnippet[key]; + } + } + + //Need to work out how best to update user tags, languages under this new approach + + Snippet.findByIdAndUpdate( + snippetId, + { ...updatedSnippet }, + { new: false, upsert: true } ) - .then((updatedUser) => { - const [tags, languages] = recalcTagsAndLang(updatedUser); - updatedUser.tags = tags; - updatedUser.languages = languages; - return updatedUser.save(); - }) - .then((savedUser) => { - res.locals.updatedSnippet = updatedSnippet; - next(); + .exec() + .then((result) => { + //Compare tags, languages in original and update to enable user refresh + res.locals.updatedSnippet = result; + let removedTags, addedTags, oldLang, newLang; + + if (result.tags !== tags || result.language !== language) { + res.locals.changeFlag = true; + } + + return next(); }) .catch((err) => { - console.log('Updating the snippet has failed:', err); - next(err); + return next( + createError( + '.updateSnippet', + `Encountered error while updating snippet: ${err}`, + 500 + ) + ); }); }; +//Deletes snippet with provided ID and removes from users with associated ID snippetsController.deleteSnippet = (req, res, next) => { - const { id } = req.query; - const userId = '645fee9104d1f0acef95a002'; + const userId = req.user._id.toString(); + const { snippetId } = req.query; + Snippet.findByIdAndDelete(snippetId) + .exec() + .then((result) => { + res.locals.deletedSnippet = result; + }) + .then(() => { + User.findById(userId) + .exec() + .then((user) => { + user.snippets = user.snippets.filter((el) => el != snippetId); + user + .save() + .then((updatedUser) => { + res.locals.changeFlag = true; + res.locals.updatedUserRecord = updatedUser; + return next(); + }) + .catch((err) => { + return next( + createError( + 'deleteSnippet', + `Encountered error while saving updated user snippets list: ${err}`, + 500 + ) + ); + }); + }) + .catch((err) => { + return next( + createError( + 'deleteSnippet', + `Encountered error while retrieving user with provided ID: ${err}`, + 500 + ) + ); + }); + }) + .catch((err) => { + return next( + createError( + 'deleteSnippet', + `Encountered error while attempting to delete snippet with provided ID: ${err}`, + 500 + ) + ); + }); +}; - User.findOne({ _id: userId }) +snippetsController.recalcTagsAndLang = (req, res, next) => { + if (!res.locals.changeFlag) { + return next(); + } + + const userId = req.user._id.toString(); + const tagList = new Set(); + const languageList = new Set(); + console.log(userId); + User.findById(userId) + .populate('snippets') + .exec() .then((user) => { - const deletedSnippet = user.snippets.find((snippet) => { - return `${snippet.id}` === id; + console.log(user); + user.snippets.forEach((snippet) => { + snippet.tags.forEach((tag) => { + if (!tagList.has(tag)) { + tagList.add(tag); + } + }); + + if (!languageList.has(snippet.language)) { + languageList.add(snippet.language); + } }); - // Remove the snippet from the user's snippets array - user.snippets = user.snippets.filter((snippet) => `${snippet.id}` !== id); - - //recalculate the tags and languages. - const [tags, languages] = recalcTagsAndLang(user); - user.tags = tags; - user.languages = languages; - - // Save the updated user document - return user.save().then(() => { - res.locals.deletedSnippet = deletedSnippet; - next(); - }); + user.tags = Array.from(tagList); + user.languages = Array.from(languageList); + user + .save() + .then((usr) => { + res.locals.updatedUserRecord = usr; + return next(); + }) + .catch((err) => { + return next( + createError( + '.recalcTagsAndLang', + `Error saving new tags, languages to user: ${err}`, + 500 + ) + ); + }); }) - .catch((error) => { - console.error('Error deleting snippet:', error); - next(error); + .catch((err) => { + return next( + createError( + '.recalcTagsAndLanguages', + `Error locating user for update: ${err}`, + 500 + ) + ); }); }; -// helper function to re-calculate taglist/language counts? -const recalcTagsAndLang = function (user) { - const tagList = {}; - const languageList = {}; - - for (const snippet of user.snippets) { - if (Array.isArray(snippet.tags)) { - for (const tag of snippet.tags) { - if (!tagList[tag]) { - tagList[tag] = []; - } - tagList[tag].push(snippet); - } - - if (!languageList[snippet.language]) { - languageList[snippet.language] = []; - } - languageList[snippet.language].push(snippet); - } - } - //return something here. - return [tagList, languageList]; -}; module.exports = snippetsController; diff --git a/server/models/snippetModel.js b/server/models/snippetModel.js new file mode 100644 index 0000000..a3a1a39 --- /dev/null +++ b/server/models/snippetModel.js @@ -0,0 +1,12 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const snippetSchema = new Schema({ + title: { type: String, required: true }, + comments: { type: String }, + storedCode: { type: String }, + tags: [String], + language: { type: String } +}); + +module.exports = mongoose.model('Snippet', snippetSchema); diff --git a/client/src/components/ui/TagInput/TagInput.module.scss b/server/models/tagModel.js similarity index 100% rename from client/src/components/ui/TagInput/TagInput.module.scss rename to server/models/tagModel.js diff --git a/server/models/userModel.js b/server/models/userModel.js index a29070c..912910d 100644 --- a/server/models/userModel.js +++ b/server/models/userModel.js @@ -2,34 +2,19 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; const userSchema = new Schema({ - username: { type: String, required: true }, + username: { type: String, required: true, unique: true }, password: { type: String, required: true }, - tags: { - type: Object, - default: {}, - }, - languages: { - type: Object, - default: {}, - }, - - lastId: { type: Number, default: 0 }, - + tags: { type: [String], default: [] }, + languages: { type: [String], default: [] }, snippets: { type: [ { - id: { type: Number, required: true }, - type: Object, - title: { type: String, required: true }, - comments: { type: String }, - storedCode: { type: String }, - - tags: [String], - language: { type: String }, - }, + type: Schema.Types.ObjectId, + ref: 'Snippet' + } ], - default: [], - }, + default: [] + } }); module.exports = mongoose.model('User', userSchema); diff --git a/server/routes/authenticationRouter.js b/server/routes/authenticationRouter.js new file mode 100644 index 0000000..f78606f --- /dev/null +++ b/server/routes/authenticationRouter.js @@ -0,0 +1,43 @@ +const express = require('express'); +const router = express.Router(); +const passport = require('passport'); +const jwt = require('jsonwebtoken'); + +const authenticationController = require('../controllers/authenticationController'); + +require('dotenv').config(); +const secret = process.env.JWT_SECRET; + +router.post('/signup', authenticationController.signUp, (req, res) => { + return res.status(201).json({ username: res.locals.newUser.username }); +}); + +router.post('/login', + passport.authenticate('local', { session: false }), + (req, res) => { + console.log(req.user); + const token = jwt.sign({ userId: req.user.id }, secret, { + expiresIn: '1d', + }); + res.cookie('token', token, { + expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // Expires in 30 days + httpOnly: true, + }); + res.cookie('userId', req.user.id, { + expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // Expires in 30 days + httpOnly: true, + }); + return res.status(202).json({ username: req.user.username }); + } +); + +router.get( + '/protected', + passport.authenticate('jwt', { session: false }), + (req, res) => { + console.log('at protected router, SUCCESS!'); + res.send('Protected route accessed!'); + } +); + +module.exports = router; diff --git a/server/routes/snippets.js b/server/routes/snippets.js deleted file mode 100644 index 4e1b5c2..0000000 --- a/server/routes/snippets.js +++ /dev/null @@ -1,25 +0,0 @@ -const express = require('express'); - -const snippetsController = require('../controllers/snippetsController'); - -const router = express.Router(); - -router.get('/', snippetsController.getSnippets, (req, res) => - res.status(200).json(res.locals.allSnippets) -); - -router.post('/', snippetsController.createSnippet, (req, res) => - res.status(200).json(res.locals.createdSnippet) -); - -router.put('/', snippetsController.updateSnippet, (req, res) => - res.status(200).json(res.locals.updatedSnippet) -); - -router.delete('/', snippetsController.deleteSnippet, (req, res) => - res.status(200).json(res.locals.deletedSnippet) -); - -router.use((req, res) => res.status(404).send('Invalid endpoint')); - -module.exports = router; diff --git a/server/routes/snippetsRouter.js b/server/routes/snippetsRouter.js new file mode 100644 index 0000000..c23a306 --- /dev/null +++ b/server/routes/snippetsRouter.js @@ -0,0 +1,57 @@ +const express = require('express'); +const passport = require('passport'); + +const snippetsController = require('../controllers/snippetsController'); + +const router = express.Router(); + +router.get( + '/', + passport.authenticate('jwt', { session: false }), + snippetsController.getSnippetsByUser, + (req, res) => { + return res + .status(200) + .json({ + snippets: res.locals.allSnippets, + tagsLangs: res.locals.userTagsLangs + }); + } +); + +router.post( + '/', + passport.authenticate('jwt', { session: false }), + snippetsController.createSnippet, + snippetsController.saveSnippetToUser, + snippetsController.recalcTagsAndLang, + (req, res) => res.status(200).json(res.locals.newSnippet) +); + +router.put( + '/', + passport.authenticate('jwt', { session: false }), + snippetsController.updateSnippet, + snippetsController.recalcTagsAndLang, + (req, res) => + res.status(200).json({ + snippet: res.locals.updatedSnippet, + userData: res.locals.updatedUserRecord + }) +); + +router.delete( + '/', + passport.authenticate('jwt', { session: false }), + snippetsController.deleteSnippet, + snippetsController.recalcTagsAndLang, + (req, res) => + res.status(200).json({ + snippet: res.locals.deletedSnippet, + userData: res.locals.updatedUserRecord + }) +); + +router.use((req, res) => res.status(404).send('Invalid endpoint')); + +module.exports = router; diff --git a/server/server.js b/server/server.js index 855cf32..c72030b 100644 --- a/server/server.js +++ b/server/server.js @@ -1,37 +1,55 @@ -const path = require('path'); const express = require('express'); -const app = express(); const mongoose = require('mongoose'); const cors = require('cors'); +const passport = require('passport'); +const cookieParser = require('cookie-parser'); -const port = process.env.PORT || 3000; +const snippetsRouter = require('./routes/snippetsRouter'); +const authenticationRouter = require('./routes/authenticationRouter'); -const mongoURI = - 'mongodb+srv://paaoul:Melikeit1@scratchcluster.igf2bag.mongodb.net/'; -mongoose.connect(mongoURI); +require('dotenv').config(); + +//Create express app and set constants +const app = express(); +const port = process.env.PORT || 3000; -const snippetsRouter = require('./routes/snippets'); +//Get mongoURI and connect to DB +const mongoURI = process.env.MONGO_URI; +mongoose.connect(mongoURI); -app.use(cors()); +// initialize passport +app.use(passport.initialize()); +// parse incoming cookies to authentication endpoints and store them on req.cookies object +app.use(cookieParser()); +// allow cookies to be included in CORS request +app.use(cors({ + origin: 'http://localhost:8080', + credentials: true +})); app.use(express.json()); app.use(express.urlencoded({ extended: true })); + +//Point relevant requests to snippet and authentication routers + app.use('/snippets', snippetsRouter); +app.use('/authentication', authenticationRouter); +//Handle requests to invalid endpoints and middleware errors app.use((req, res) => res.status(404).send('Invalid endpoint')); - app.use((err, req, res, next) => { const defaultErr = { log: 'Express error handler caught unknown middleware error', status: 500, - message: { err: 'An error occurred' }, + message: { err: 'An error occurred' } }; const errorObj = Object.assign({}, defaultErr, err); console.log(errorObj.log); return res.status(errorObj.status).json(errorObj.message); }); +//Get 'er goin' app.listen(port, () => { console.log(`Server listening on port ${port}...`); }); diff --git a/webpack.config.js b/webpack.config.js index 57743ec..aa87539 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -14,7 +14,7 @@ module.exports = { rules: [ { test: /\.(png|jp(e*)g|svg|gif)$/, - type: 'asset/resource', + type: 'asset/resource' }, { test: /\.jsx?/, @@ -24,27 +24,25 @@ module.exports = { options: { presets: [ '@babel/preset-env', - ['@babel/preset-react', { runtime: 'automatic' }], - ], - }, - }, + ['@babel/preset-react', { runtime: 'automatic' }] + ] + } + } }, { test: /\.s?css/, - use: [ - 'style-loader', 'css-loader', 'sass-loader' - ] + use: ['style-loader', 'css-loader', 'sass-loader'] } ] }, plugins: [ new HtmlWebpackPlugin({ title: 'Development', - template:'./public/index.html' + template: './public/index.html' }) ], resolve: { - extensions: ['.js', '.jsx', '.scss'], + extensions: ['.js', '.jsx', '.scss'] }, devServer: { static: { @@ -52,8 +50,8 @@ module.exports = { directory: path.resolve(__dirname, 'dist') }, proxy: { - '/snippets': 'http://localhost:3000' + '/': 'http://localhost:3000' } }, devtool: 'eval-source-map' -}; \ No newline at end of file +};