Added api endpoints for user creation, created login and register components, and starrted modifying app.jsx to handle auth workflow

This commit is contained in:
MattLeo 2025-12-01 17:06:34 -06:00
parent e603780812
commit 3daac43d76
8 changed files with 711 additions and 15 deletions

View File

@ -5,18 +5,41 @@ import SearchBar from './components/SearchBar';
import ArticleList from './components/ArticleList'; import ArticleList from './components/ArticleList';
import ArticleDetail from './components/ArticleDetail'; import ArticleDetail from './components/ArticleDetail';
import ArticleEditor from './components/ArticleEdit'; import ArticleEditor from './components/ArticleEdit';
import Login from './components/Login';
import Registration from './components/Registration'
function App() { 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 [currentView, setCurrentView] = useState('list');
const [selectedArticle, setSelectedArticle] = useState(null); const [selectedArticle, setSelectedArticle] = useState(null);
const [articles, setArticles] = useState([]); const [articles, setArticles] = useState([]);
useEffect(() => { 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') fetch('http://localhost:9000/api/articles')
.then(response => response.json()) .then(response => response.json())
.then(data => setArticles(data)) .then(data => setArticles(data))
.catch(error => console.error('Error fetching articles:', error)) .catch(error => console.error('Error fetching articles:', error))
}, []); };
const handleSearch = (query) => { const handleSearch = (query) => {
if (!query) { if (!query) {

View File

@ -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;
}

View File

@ -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 (
<div className='login-container'>
<div className='login-box'>
<h2>Login to Knowledge Base</h2>
{error && <div className='error-message'>{error}</div>}
<form onSubmit={handleSubmit}>
<div className='form-group'>
<label htmlFor='username'>Username:</label>
<input
type='text'
id='username'
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={loading}
/>
</div>
<div className='form-group'>
<label htmlFor='password'>Password:</label>
<input
type='password'
id='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<button type='submit' disabled={loading}>
{loading ? 'Logging in...': 'Login'}
</button>
</form>
<div className='switch-auth'>
<button
type='button'
className='link-button'
onClick={onSwitchToRegister}
disabled={loading}
>Register here</button>
</div>
</div>
</div>
);
}
export default Login;

View File

@ -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;
}

View File

@ -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 (
<div className='registration-container'>
<div className='registration-box'>
<h2>Create Account</h2>
{error && <div className='error-message'>{error}</div>}
<form onSubmit={{handleSubmit}}>
<div className='form-group'>
<label htmlFor="username">Username:</label>
<input
type='text'
id='username'
value={username}
onChange={(e) => setUsername(e.target.value)}
required
disabled={loading}
/>
</div>
<div className='form-group'>
<label htmlFor='email'>Email:</label>
<input
type='text'
id='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
/>
</div>
<div className='form-group'>
<label htmlFor='display_name'>Display Name:</label>
<input
type='text'
id='display_name'
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
disabled={loading}
/>
</div>
<div className='form-group'>
<label htmlFor='password'>Password:</label>
<input
type='password'
id='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<div className='form-group'>
<label htmlFor='confirmPassword'>Confirm Password:</label>
<input
type='password'
id='confirmPassword'
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<button type='submit' disabled={loading}>
{loading ? 'Creating Account...' : 'Register'}
</button>
</form>
<div className='switch-auth'>
Already have an account?
<button
type='button'
className='link-button'
onClick={onSwitchToLogin}
disabled={loading}
>Login Here</button>
</div>
</div>
</div>
);
}
export default Reguistration

80
server/auth.js Normal file
View File

@ -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
};

View File

@ -78,7 +78,10 @@ async function initDb() {
return db; 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() { function getAllArticles() {
const result = db.exec("SELECT * FROM articles ORDER BY created_at DESC"); const result = db.exec("SELECT * FROM articles ORDER BY created_at DESC");
@ -102,7 +105,11 @@ function getAllArticles() {
return articles; 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) { function getArticle(ka_num) {
const stmt = db.prepare('SELECT * FROM articles where ka_number = ?'); const stmt = db.prepare('SELECT * FROM articles where ka_number = ?');
stmt.bind([ka_num]); stmt.bind([ka_num]);
@ -117,7 +124,13 @@ function getArticle(ka_num) {
return null; 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) { function createArticle(title, content, author) {
const ka_num = getNextKANumber(); const ka_num = getNextKANumber();
@ -133,6 +146,14 @@ function createArticle(title, content, author) {
return getArticle(ka_num); 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) { function updateArticle(ka_num, title, content, author) {
db.run( db.run(
"UPDATE articles SET title = ?, content = ?, updated_at = CURRENT_TIMESTAMP, updated_by = ? WHERE ka_number = ?", "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); 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) { function deleteArticle(ka_num) {
db.run( db.run(
"DELETE FROM articles WHERE ka_number = ?", "DELETE FROM articles WHERE ka_number = ?",
@ -158,7 +182,11 @@ function deleteArticle(ka_num) {
fs.writeFileSync(DB_PATH, Buffer.from(data)); 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) { function searchArticles(query) {
const searchTerm = `%${query}%`; const searchTerm = `%${query}%`;
const stmt = db.prepare( const stmt = db.prepare(
@ -175,6 +203,16 @@ function searchArticles(query) {
return results; 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) { 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 (?, ?, ?, ?, ?, ?)", db.run("INSERT INTO users (username, email, pass_hash, display_name, auth_provider, entra_id) VALUES (?, ?, ?, ?, ?, ?)",
[username, email, passHash, displayName, authProvider, entraId] [username, email, passHash, displayName, authProvider, entraId]
@ -188,6 +226,11 @@ function createUser(username, email, passHash, displayName, authProvider = 'loca
return user; 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) { function getUserByEmail(email) {
const stmt = db.prepare("SELECT * FROM users WHERE email = ? LIMIT 1"); const stmt = db.prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
stmt.bind([email]); stmt.bind([email]);
@ -202,6 +245,11 @@ function getUserByEmail(email) {
return null; 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) { function getUserByUsername(username) {
const stmt = db.prepare("SELECT * FROM users WHERE username = ?"); const stmt = db.prepare("SELECT * FROM users WHERE username = ?");
stmt.bind([username]); stmt.bind([username]);
@ -216,6 +264,11 @@ function getUserByUsername(username) {
return null; 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) { function getUserById(id) {
const stmt = db.prepare("SELECT * FROM users WHERE id = ?"); const stmt = db.prepare("SELECT * FROM users WHERE id = ?");
stmt.bind([id]); stmt.bind([id]);

View File

@ -1,5 +1,18 @@
const express = require('express'); 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 app = express();
const cors = require('cors'); const cors = require('cors');
const PORT = 9000; const PORT = 9000;
@ -13,7 +26,7 @@ initDb().then(() => {
res.json({message : 'Server is running'}); res.json({message : 'Server is running'});
}); });
app.get('/api/articles', (req, res) => { app.get('/api/articles', authenticateToken, (req, res) => {
try { try {
const articles = getAllArticles(); const articles = getAllArticles();
res.json(articles); 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 { try {
const article = getArticle(req.params.ka_number); 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 { try {
const { title, content } = req.body; const { title, content } = req.body;
@ -46,7 +59,7 @@ initDb().then(() => {
return res.status(400).json({error: 'Title is required' }); 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); res.status(201).json(article);
} catch (error) { } 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 { try {
const { title, content } = req.body; const { title, content } = req.body;
@ -63,7 +76,7 @@ initDb().then(() => {
return res.status(400).json({error: 'Title is required' }); 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) { if (!article) {
return res.status(404).json({error: 'Article not found'}); 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 { try {
deleteArticle(req.params.ka_number); deleteArticle(req.params.ka_number);
return res.status(200).json({'message': 'Successfully deleted article'}); 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 { try {
const query = req.query.q; const query = req.query.q;
@ -102,6 +115,84 @@ initDb().then(() => {
return res.status(500).json({error: 'Failed to search articles', 'details': String(error)}); 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, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);