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}
}
+
+
+
+
+
+
+
+
+ );
+}
+
+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}
}
+
+
+
+
+ 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}`);