diff --git a/client/kb-frontend/src/App.jsx b/client/kb-frontend/src/App.jsx index d061900..3320ebe 100644 --- a/client/kb-frontend/src/App.jsx +++ b/client/kb-frontend/src/App.jsx @@ -5,18 +5,41 @@ import SearchBar from './components/SearchBar'; import ArticleList from './components/ArticleList'; import ArticleDetail from './components/ArticleDetail'; import ArticleEditor from './components/ArticleEdit'; +import Login from './components/Login'; +import Registration from './components/Registration' function App() { + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + const [token, setToken] = useState(null); + const [authView, setAuthView] = useState('login'); const [currentView, setCurrentView] = useState('list'); const [selectedArticle, setSelectedArticle] = useState(null); const [articles, setArticles] = useState([]); useEffect(() => { + const savedToken = localStorage.getItem('token'); + const savedUser = localStorage.getItem('user'); + + if (savedToken && savedUser) { + setToken(savedToken); + setCurrentUser(JSON.parse(savedUser)); + setIsLoggedIn(true); + } + }, []); + + useEffect(() => { + if (isLoggedIn && token) { + fetchArticles(); + } + }, []); + + const fetchArticles = () => { fetch('http://localhost:9000/api/articles') .then(response => response.json()) .then(data => setArticles(data)) .catch(error => console.error('Error fetching articles:', error)) - }, []); + }; const handleSearch = (query) => { if (!query) { diff --git a/client/kb-frontend/src/components/Login.css b/client/kb-frontend/src/components/Login.css new file mode 100644 index 0000000..b3e02d7 --- /dev/null +++ b/client/kb-frontend/src/components/Login.css @@ -0,0 +1,107 @@ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 80vh; +} + +.login-box { + background-color: #fff; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; +} + +.login-box h2 { + margin-top: 0; + color: #2c3e50; + text-align: center; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: #333; + font-weight: 500; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + box-sizing: border-box; +} + +.form-group input:focus { + outline: none; + border-color: #3498db; +} + +.form-group input:disabled { + background-color: #f5f5f5; + cursor: not-allowed; +} + +.login-box button { + width: 100%; + padding: 0.75rem; + background-color: #3498db; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.3s; +} + +.login-box button:hover:not(:disabled) { + background-color: #2980b9; +} + +.login-box button:disabled { + background-color: #95a5a6; + cursor: not-allowed; +} + +.error-message { + background-color: #fee; + color: #c33; + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 1rem; + border: 1px solid #fcc; +} + +.switch-auth { + margin-top: 1.5rem; + text-align: center; + color: #666; +} + +.link-button { + background: none; + border: none; + color: #3498db; + cursor: pointer; + text-decoration: underline; + padding: 0; + margin-left: 0.5rem; + font-size: 1rem; +} + +.link-button:hover:not(:disabled) { + color: #2980b9; +} + +.link-button:disabled { + color: #95a5a6; + cursor: not-allowed; +} \ No newline at end of file diff --git a/client/kb-frontend/src/components/Login.jsx b/client/kb-frontend/src/components/Login.jsx new file mode 100644 index 0000000..3300bd4 --- /dev/null +++ b/client/kb-frontend/src/components/Login.jsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import './Login.css'; + +function Login({ onLoginSuccess, onSwitchToRegister }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const response = await fetch('http://localhost:9000/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: username, + password: password + }) + }); + + const data = await response.json(); + + if (response.ok) { + onLoginSuccess(data.user, data.token); + } else { + setError(data.error || 'Login failed'); + } + } catch (err) { + console.error('Login error:', err); + setError('Failed to connect to server'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Login to Knowledge Base

+ {error &&
{error}
} + +
+
+ + setUsername(e.target.value)} + required + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + required + disabled={loading} + /> +
+ + +
+ +
+ +
+
+
+ ); +} + +export default Login; \ No newline at end of file diff --git a/client/kb-frontend/src/components/Registration.css b/client/kb-frontend/src/components/Registration.css new file mode 100644 index 0000000..1d66b62 --- /dev/null +++ b/client/kb-frontend/src/components/Registration.css @@ -0,0 +1,107 @@ +.registration-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 80vh; +} + +.registration-box { + background-color: #fff; + padding: 2rem; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 400px; +} + +.registration-box h2 { + margin-top: 0; + color: #2c3e50; + text-align: center; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: #333; + font-weight: 500; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; + box-sizing: border-box; +} + +.form-group input:focus { + outline: none; + border-color: #3498db; +} + +.form-group input:disabled { + background-color: #f5f5f5; + cursor: not-allowed; +} + +.registration-box button[type="submit"] { + width: 100%; + padding: 0.75rem; + background-color: #27ae60; + color: white; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.3s; +} + +.registration-box button[type="submit"]:hover:not(:disabled) { + background-color: #229954; +} + +.registration-box button[type="submit"]:disabled { + background-color: #95a5a6; + cursor: not-allowed; +} + +.error-message { + background-color: #fee; + color: #c33; + padding: 0.75rem; + border-radius: 4px; + margin-bottom: 1rem; + border: 1px solid #fcc; +} + +.switch-auth { + margin-top: 1.5rem; + text-align: center; + color: #666; +} + +.link-button { + background: none; + border: none; + color: #3498db; + cursor: pointer; + text-decoration: underline; + padding: 0; + margin-left: 0.5rem; + font-size: 1rem; +} + +.link-button:hover:not(:disabled) { + color: #2980b9; +} + +.link-button:disabled { + color: #95a5a6; + cursor: not-allowed; +} \ No newline at end of file diff --git a/client/kb-frontend/src/components/Registration.jsx b/client/kb-frontend/src/components/Registration.jsx new file mode 100644 index 0000000..bd3121a --- /dev/null +++ b/client/kb-frontend/src/components/Registration.jsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import './Registration.css'; + +function Registration({ onRegistrationSuccess, onSwitchToLogin }) { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + setLoading(true); + + try { + const response = await fetch('http://localhost:9000/api/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: username, + email: email, + password: password, + display_name: displayName + }) + }); + + const data = await response.json(); + + if (response.ok) { + onRegistrationSuccess(data.user, data.token); + } else { + setError(data.error || 'Registration failed'); + } + } catch (err) { + console.error('Registration failed:', err); + setError('Failed to connect to server'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Create Account

+ {error &&
{error}
} + +
+
+ + setUsername(e.target.value)} + required + disabled={loading} + /> +
+ +
+ + setEmail(e.target.value)} + required + disabled={loading} + /> +
+ +
+ + setDisplayName(e.target.value)} + required + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + required + disabled={loading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + disabled={loading} + /> +
+ + +
+ +
+ Already have an account? + +
+
+
+ ); +} + +export default Reguistration \ No newline at end of file diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..d7e40de --- /dev/null +++ b/server/auth.js @@ -0,0 +1,80 @@ +const jwt = require('jsonwebtoken'); +const {getUserById} = require('./db'); + +const JWT_SECRET = 'THISISTOTALLYSECRETDONTLOOK'; +const JWT_EXPIRATION = '24h'; + + +/** + * Generate a JWT token for a user + * @param {Object}- User object from database + * @returns {string} - signed JWT token + */ +function generateToken(user) { + const payload = { + id: user.id, + email: user.email, + username: user.username, + display_name: user.display_name, + auth_provider: user.auth_provider + }; + + const token = jwt.sign(payload, JWT_SECRET, {expiresIn: JWT_EXPIRATION}); + return token; +} + +/** + * Verify and decode a JWT token + * @param {string} token - JWT token to verify + * @returns {Object} - Decoded token payload + * @throws {Error} - If token is invalid or expired + */ +function verifyToken(token) { + try { + const decoded = jwt.verify(token, JWT_SECRET); + return decoded; + } catch (error) { + throw new Error('Invalid or expired token'); + } +} + +/** + * Express middleware to authenticate requests using JWT + * Extracts token from Authorization header, verfies it, and attaches user to req.user + */ +function authenticateToken(req, res, next) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({error: 'Access token required'}); + } + + try { + const decoded = verifyToken(token); + const user = getUserById(decoded.id); + + if (!user) { + return res.status(403).json({error: 'User not found'}); + } + + req.user = { + id: user.id, + email: user.email, + username: user.username, + display_name: user.display_name, + auth_provider: user.auth_provider + }; + + next(); + } catch (error) { + return res.status(403).json({error: 'Invalid or expired token', details: error.message}); + } +} + +module.exports = { + generateToken, + verifyToken, + authenticateToken, + JWT_SECRET +}; \ No newline at end of file diff --git a/server/db.js b/server/db.js index f7dfb9e..4ee783f 100644 --- a/server/db.js +++ b/server/db.js @@ -78,7 +78,10 @@ async function initDb() { return db; } -// Get all articles from the DB +/** + * Gets all articles from the database + * @returns {[Object]} - returns an array of all stored articles + */ function getAllArticles() { const result = db.exec("SELECT * FROM articles ORDER BY created_at DESC"); @@ -102,7 +105,11 @@ function getAllArticles() { return articles; } -// Get a specific article from the DB +/** + * Gets the article from the database associated with a KA number + * @param {string} ka_num - The KA number associated with an article + * @returns {Object} - the associated article from the database + */ function getArticle(ka_num) { const stmt = db.prepare('SELECT * FROM articles where ka_number = ?'); stmt.bind([ka_num]); @@ -117,7 +124,13 @@ function getArticle(ka_num) { return null; } -// Create a new article +/** + * Creates a new entry in the articles table, atomatically assigns KA number, and returns the newly created article + * @param {string} title - The title of the article + * @param {string} content - The content of the article + * @param {string} author - The username of the article creator + * @returns {Object} - The newly created article from the database + */ function createArticle(title, content, author) { const ka_num = getNextKANumber(); @@ -133,6 +146,14 @@ function createArticle(title, content, author) { return getArticle(ka_num); } +/** + * Updates the stored article that is associated with the KA number, and returns the updated article + * @param {string} ka_num - The KA number of the article beaing updated + * @param {string} title - The title of the article + * @param {string} content - The content of the article + * @param {string} author - The username that updated the article + * @returns {Object} - The newly updatd article from the database + */ function updateArticle(ka_num, title, content, author) { db.run( "UPDATE articles SET title = ?, content = ?, updated_at = CURRENT_TIMESTAMP, updated_by = ? WHERE ka_number = ?", @@ -146,7 +167,10 @@ function updateArticle(ka_num, title, content, author) { return getArticle(ka_num); } -// Delete the article from the DB +/** + * Deletes the associated article from the article table + * @param {*} ka_num - The KA number associated to the article + */ function deleteArticle(ka_num) { db.run( "DELETE FROM articles WHERE ka_number = ?", @@ -158,7 +182,11 @@ function deleteArticle(ka_num) { fs.writeFileSync(DB_PATH, Buffer.from(data)); } -// Simple search to look for matching terms or KA number +/** + * Searches the article table for any words matching in the title, content, or ka number and returns an array of them + * @param {string} query - The search query that will be used for looking up the assocaited articles + * @returns {[Object]} - An arry of articles that match the query content + */ function searchArticles(query) { const searchTerm = `%${query}%`; const stmt = db.prepare( @@ -175,6 +203,16 @@ function searchArticles(query) { return results; } +/** + * Creates a new user and returns the newly created user object from the database + * @param {string} username - The username for the newly created user + * @param {string} email - The email address for the newly created user + * @param {string} passHash - The hashed password for validation purposes + * @param {string} displayName - The name that will be desplayed when an article is created or updated + * @param {string} authProvider - the source of the authentication: 'local' or 'entra' + * @param {string} entraId - The ID number for the associated entra account, can be null if auth provider is local + * @returns {Object} - The user object of the newly created user + */ function createUser(username, email, passHash, displayName, authProvider = 'local', entraId = null) { db.run("INSERT INTO users (username, email, pass_hash, display_name, auth_provider, entra_id) VALUES (?, ?, ?, ?, ?, ?)", [username, email, passHash, displayName, authProvider, entraId] @@ -188,6 +226,11 @@ function createUser(username, email, passHash, displayName, authProvider = 'loca return user; } +/** + * Looks up users table and returns the user object for the user profile that matches the provided email + * @param {string} email - The email address used for the created user + * @returns {Object} - The matching user object from the database + */ function getUserByEmail(email) { const stmt = db.prepare("SELECT * FROM users WHERE email = ? LIMIT 1"); stmt.bind([email]); @@ -202,6 +245,11 @@ function getUserByEmail(email) { return null; } +/** + * Looks up the user account with the specified username from the database and returns the user object + * @param {*} username - The username associated to the profile being looked up + * @returns {Object} - The user object that matches the provided username + */ function getUserByUsername(username) { const stmt = db.prepare("SELECT * FROM users WHERE username = ?"); stmt.bind([username]); @@ -216,6 +264,11 @@ function getUserByUsername(username) { return null; } +/** + * Looks up the user by the primary key and returns the user object + * @param {Int} id - The id number of the associated user account + * @returns {Object} - The user object associated to the primary key provided + */ function getUserById(id) { const stmt = db.prepare("SELECT * FROM users WHERE id = ?"); stmt.bind([id]); diff --git a/server/server.js b/server/server.js index a648a94..1bbbf2f 100644 --- a/server/server.js +++ b/server/server.js @@ -1,5 +1,18 @@ const express = require('express'); -const { initDb, getAllArticles, getArticle, createArticle, updateArticle, deleteArticle, searchArticles } = require('./db'); +const argon2 = require('argon2'); +const { + initDb, + getAllArticles, + getArticle, + createArticle, + updateArticle, + deleteArticle, + searchArticles, + createUser, + getUserByUsername, + getUserByEmail +} = require('./db'); +const { generateToken, authenticateToken } = require('./auth'); const app = express(); const cors = require('cors'); const PORT = 9000; @@ -13,7 +26,7 @@ initDb().then(() => { res.json({message : 'Server is running'}); }); - app.get('/api/articles', (req, res) => { + app.get('/api/articles', authenticateToken, (req, res) => { try { const articles = getAllArticles(); res.json(articles); @@ -23,7 +36,7 @@ initDb().then(() => { } }); - app.get('/api/articles/:ka_number', (req, res) => { + app.get('/api/articles/:ka_number', authenticateToken, (req, res) => { try { const article = getArticle(req.params.ka_number); @@ -38,7 +51,7 @@ initDb().then(() => { } }); - app.post('/api/articles', (req, res) => { + app.post('/api/articles', authenticateToken, (req, res) => { try { const { title, content } = req.body; @@ -46,7 +59,7 @@ initDb().then(() => { return res.status(400).json({error: 'Title is required' }); } - const article = createArticle(title, content || ''); + const article = createArticle(title, content, req.user.display_name); res.status(201).json(article); } catch (error) { @@ -55,7 +68,7 @@ initDb().then(() => { } }); - app.put('/api/articles/:ka_number', (req, res) => { + app.put('/api/articles/:ka_number', authenticateToken, (req, res) => { try { const { title, content } = req.body; @@ -63,7 +76,7 @@ initDb().then(() => { return res.status(400).json({error: 'Title is required' }); } - const article = updateArticle(req.params.ka_number, title, content); + const article = updateArticle(req.params.ka_number, title, content, req.user.display_name); if (!article) { return res.status(404).json({error: 'Article not found'}); @@ -76,7 +89,7 @@ initDb().then(() => { } }); - app.delete('/api/articles/:ka_number', (req, res) => { + app.delete('/api/articles/:ka_number', authenticateToken, (req, res) => { try { deleteArticle(req.params.ka_number); return res.status(200).json({'message': 'Successfully deleted article'}); @@ -87,7 +100,7 @@ initDb().then(() => { } }); - app.get('/api/search', (req, res) => { + app.get('/api/search', authenticateToken, (req, res) => { try { const query = req.query.q; @@ -102,6 +115,84 @@ initDb().then(() => { return res.status(500).json({error: 'Failed to search articles', 'details': String(error)}); } }); + + app.post('/api/auth/register', async (req, res) => { + try { + const {username, email, password, displayName} = req.body; + + if (!username || !email || !password || !displayName) { + return res.status(400).json({error: 'Username, email, password, and display name are required'}); + } + + const cur_user_username = getUserByUsername(username); + if (cur_user_username) { + return res.status(400).json({error: 'Username already exists'}); + } + + const cur_user_email = getUserByEmail(email); + if(cur_user_email) { + return res.status(400).json({error: 'email address already in use'}); + } + + const pass_hash = await argon2.hash(password); + const newUser = createUser(username, email, pass_hash, displayName); + const token = generateToken(newUser); + + return res.status(201).json({ + user: { + id: newUser.id, + username: newUser.username, + email: newUser.email, + display_name: newUser.display_name, + auth_provider: newUser.auth_provider, + created_at: newUser.created_at + }, + token + }); + + } catch (error) { + console.error('Error when registering new user: ', error); + return res.status(500).json({error: 'Failed to create user', 'details': String(error)}); + } + }); + + app.post('/api/auth/login', async (req, res) => { + try { + const {username, email, password} = req.body; + + if (!password || (!username && !email)) { + return res.status(400).json({error: 'Username/email & password are required'}); + } + + let user; + if (!username) { + user = getUserByEmail(email); + } else { + user = getUserByUsername(username); + } + + if (!user || !await argon2.verify(user.pass_hash, password)) { + return res.status(401).json({error: 'Invalid credentials'}); + } + + const token = generateToken(user); + + return res.status(200).json({ + user: { + id: user.id, + username: user.username, + email: user.email, + display_name: user.display_name, + auth_provider: user.auth_provider, + created_at: user.created_at + }, + token + }); + } catch (error) { + console.log('Failed to log user in:', error); + return res.status(500).json({error: 'Failed to login', 'details': String(error)}); + } + }); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`);