Initial commit: ETF Trade Tracker with multi-user authentication

Features:
- Multi-user authentication system with admin panel
- SQLite database with user isolation
- Trade entry, history, and portfolio tracking
- Gains/losses calculation with price updates
- Responsive design with sidebar navigation
- Session-based authentication with bcrypt password hashing
- Admin user management capabilities

Default admin credentials: admin/admin123

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
kris 2025-08-28 15:36:40 +00:00
commit 8a68a8bf86
7 changed files with 6119 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
etf_trades.db
*.log
.env
.DS_Store
cookies.txt

308
index.html Normal file
View File

@ -0,0 +1,308 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ETF Trade Tracker</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app-container">
<nav class="sidebar">
<div class="sidebar-header">
<h2>ETF Tracker</h2>
</div>
<ul class="sidebar-menu">
<li class="menu-item active" data-page="dashboard">
<span class="menu-icon">📊</span>
<span class="menu-text">Dashboard</span>
</li>
<li class="menu-item" data-page="portfolio">
<span class="menu-icon">💼</span>
<span class="menu-text">Portfolio</span>
</li>
<li class="menu-item" data-page="trade-history">
<span class="menu-icon">📋</span>
<span class="menu-text">Trade History</span>
</li>
<li class="menu-item" data-page="gains-losses">
<span class="menu-icon">📈</span>
<span class="menu-text">Gains/Losses</span>
</li>
<li class="menu-item menu-separator" data-page="add-trade">
<span class="menu-icon"></span>
<span class="menu-text">Add Trade</span>
</li>
<li class="menu-item admin-only" data-page="admin" style="display: none;">
<span class="menu-icon">👥</span>
<span class="menu-text">Admin Panel</span>
</li>
</ul>
<div class="sidebar-footer">
<div class="user-info" id="user-info" style="display: none;">
<span id="current-user">Not logged in</span>
<button id="logout-btn" class="logout-btn">Logout</button>
</div>
</div>
</nav>
<div class="main-content">
<header class="top-header">
<button class="sidebar-toggle"></button>
<h1 id="page-title">Dashboard</h1>
</header>
<main class="content-area">
<!-- Login Page -->
<div id="login-page" class="page active">
<div class="login-container">
<h2>Login to ETF Tracker</h2>
<form id="login-form" class="login-form">
<div class="form-group">
<label for="login-username">Username</label>
<input type="text" id="login-username" required placeholder="Enter your username">
</div>
<div class="form-group">
<label for="login-password">Password</label>
<input type="password" id="login-password" required placeholder="Enter your password">
</div>
<button type="submit" class="login-btn">Login</button>
</form>
<div class="login-info">
<p>Default admin credentials:</p>
<p><strong>Username:</strong> admin</p>
<p><strong>Password:</strong> admin123</p>
</div>
</div>
</div>
<!-- Dashboard Page -->
<div id="dashboard-page" class="page">
<div class="dashboard-grid">
<div class="dashboard-card total-value">
<h3>Current Portfolio Value</h3>
<div class="metric-value" id="dashboard-current-value">€0.00</div>
<div class="metric-detail" id="dashboard-last-updated">Using cost basis</div>
</div>
<div class="dashboard-card total-gains">
<h3>Total Gains/Losses</h3>
<div class="metric-value" id="dashboard-total-gains">€0.00</div>
<div class="metric-change" id="dashboard-gains-percentage">0.0%</div>
</div>
<div class="dashboard-card monthly-investment">
<h3>Monthly Investment</h3>
<div class="metric-value" id="monthly-investment">€0.00</div>
<div class="metric-detail" id="monthly-trades">0 trades</div>
</div>
<div class="dashboard-card yearly-investment">
<h3>Yearly Investment</h3>
<div class="metric-value" id="yearly-investment">€0.00</div>
<div class="metric-detail" id="yearly-trades">0 trades</div>
</div>
<div class="dashboard-card total-shares">
<h3>Total Shares</h3>
<div class="metric-value" id="total-shares">0</div>
<div class="metric-detail" id="unique-etfs">0 ETFs</div>
</div>
<div class="dashboard-card cost-basis">
<h3>Total Investment</h3>
<div class="metric-value" id="dashboard-cost-basis">€0.00</div>
<div class="metric-detail" id="dashboard-avg-return">Avg return: 0.0%</div>
</div>
</div>
<div class="etf-breakdown">
<h3>ETF Breakdown</h3>
<div id="etf-breakdown-list" class="breakdown-list">
<p class="no-data">No ETF positions yet</p>
</div>
</div>
</div>
<!-- Add Trade Page -->
<div id="add-trade-page" class="page">
<div class="trade-form-container">
<h2>Add New Trade</h2>
<form id="trade-form" class="trade-form">
<div class="form-group">
<label for="etf-symbol">ETF Symbol</label>
<input type="text" id="etf-symbol" required placeholder="e.g., VWCE, SPY, QQQ">
</div>
<div class="form-group">
<label for="trade-type">Trade Type</label>
<select id="trade-type" required>
<option value="">Select trade type</option>
<option value="buy">Buy</option>
<option value="sell">Sell</option>
</select>
</div>
<div class="form-group">
<label for="shares">Number of Shares</label>
<input type="number" id="shares" step="0.001" min="0" required placeholder="0.000">
</div>
<div class="form-group">
<label for="price">Price per Share</label>
<input type="number" id="price" step="0.01" min="0" required placeholder="0.00">
</div>
<div class="form-group">
<label for="currency">Currency</label>
<select id="currency" required>
<option value="">Select currency</option>
<option value="EUR">Euro (€)</option>
<option value="USD">US Dollar ($)</option>
</select>
</div>
<div class="form-group">
<label for="trade-date">Trade Date</label>
<input type="date" id="trade-date" required>
</div>
<div class="form-group">
<label for="trade-time">Trade Time (24h format)</label>
<input type="time" id="trade-time" required step="1">
</div>
<div class="form-group">
<label for="fees">Fees (optional)</label>
<input type="number" id="fees" step="0.01" min="0" placeholder="0.00">
</div>
<div class="form-group">
<label for="notes">Notes (optional)</label>
<textarea id="notes" rows="3" placeholder="Additional notes about this trade..."></textarea>
</div>
<button type="submit" class="submit-btn">Add Trade</button>
</form>
</div>
</div>
<!-- Trade History Page -->
<div id="trade-history-page" class="page">
<div class="trade-history-container">
<h2>Trade History</h2>
<div class="trades-controls">
<button id="clear-trades" class="clear-btn">Clear All Trades</button>
<button id="export-trades" class="export-btn">Export to JSON</button>
</div>
<div id="trades-list" class="trades-list">
<p class="no-trades">No trades recorded yet. Add your first trade above!</p>
</div>
</div>
</div>
<!-- Portfolio Page -->
<div id="portfolio-page" class="page">
<div class="portfolio-container">
<h2>Portfolio Summary</h2>
<div id="portfolio-summary" class="portfolio-summary">
<p class="no-data">No portfolio data yet</p>
</div>
</div>
</div>
<!-- Admin Panel Page -->
<div id="admin-page" class="page">
<div class="admin-container">
<h2>Admin Panel - User Management</h2>
<div class="admin-actions">
<h3>Create New User</h3>
<form id="create-user-form" class="create-user-form">
<div class="form-row">
<div class="form-group">
<label for="new-username">Username</label>
<input type="text" id="new-username" required placeholder="Enter username">
</div>
<div class="form-group">
<label for="new-password">Password</label>
<input type="password" id="new-password" required placeholder="Enter password">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="new-email">Email (optional)</label>
<input type="email" id="new-email" placeholder="Enter email">
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="new-is-admin"> Admin User
</label>
</div>
</div>
<button type="submit" class="create-user-btn">Create User</button>
</form>
</div>
<div class="users-list-section">
<h3>Existing Users</h3>
<div id="users-list" class="users-list">
<p class="no-data">Loading users...</p>
</div>
</div>
</div>
</div>
<!-- Gains/Losses Page -->
<div id="gains-losses-page" class="page">
<div class="gains-losses-container">
<h2>Gains & Losses</h2>
<div class="performance-summary">
<div class="performance-grid">
<div class="performance-card total-gains">
<h3>Total Gains/Losses</h3>
<div class="performance-value" id="total-performance">€0.00</div>
<div class="performance-percentage" id="total-percentage">0.0%</div>
</div>
<div class="performance-card unrealized-gains">
<h3>Unrealized P&L</h3>
<div class="performance-value" id="unrealized-performance">€0.00</div>
<div class="performance-percentage" id="unrealized-percentage">0.0%</div>
</div>
<div class="performance-card current-value">
<h3>Current Portfolio Value</h3>
<div class="performance-value" id="current-portfolio-value">€0.00</div>
<div class="performance-detail" id="last-updated">Not updated</div>
</div>
</div>
</div>
<div class="price-updates-section">
<h3>Update Current Prices</h3>
<div class="price-update-controls">
<button id="update-all-prices" class="update-all-btn">Update All Prices</button>
<span class="update-info">Enter current market prices to calculate gains/losses</span>
</div>
<div id="price-update-list" class="price-update-list">
<p class="no-data">No ETF positions to update</p>
</div>
</div>
<div class="gains-breakdown-section">
<h3>Individual Performance</h3>
<div id="gains-breakdown-list" class="gains-breakdown-list">
<p class="no-data">Update prices to see performance breakdown</p>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

2842
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "etf-trade-tracker",
"version": "1.0.0",
"description": "ETF Trade Tracker with SQLite storage",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"sqlite3": "^5.1.6",
"cors": "^2.8.5",
"express-session": "^1.17.3",
"bcrypt": "^5.1.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
},
"keywords": ["etf", "trading", "portfolio", "sqlite"],
"author": "",
"license": "MIT"
}

1159
script.js Normal file

File diff suppressed because it is too large Load Diff

469
server.js Normal file
View File

@ -0,0 +1,469 @@
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const cors = require('cors');
const path = require('path');
const session = require('express-session');
const bcrypt = require('bcrypt');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors({
origin: true,
credentials: true
}));
app.use(express.json());
app.use(session({
secret: 'etf-tracker-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // Set to true if using HTTPS
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
app.use(express.static('.'));
const db = new sqlite3.Database('./etf_trades.db', (err) => {
if (err) {
console.error('Error opening database:', err.message);
} else {
console.log('Connected to SQLite database');
initializeDatabase();
}
});
function initializeDatabase() {
// Create users table
const createUsersTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT UNIQUE,
is_admin BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
)
`;
// Create trades table with user_id
const createTradesTableSQL = `
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
etf_symbol TEXT NOT NULL,
trade_type TEXT NOT NULL CHECK (trade_type IN ('buy', 'sell')),
shares REAL NOT NULL CHECK (shares > 0),
price REAL NOT NULL CHECK (price > 0),
currency TEXT NOT NULL CHECK (currency IN ('EUR', 'USD')),
trade_datetime TEXT NOT NULL,
fees REAL DEFAULT 0 CHECK (fees >= 0),
notes TEXT,
total_value REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)
`;
db.run(createUsersTableSQL, (err) => {
if (err) {
console.error('Error creating users table:', err.message);
} else {
console.log('Users table ready');
createDefaultAdmin();
}
});
db.run(createTradesTableSQL, (err) => {
if (err) {
console.error('Error creating trades table:', err.message);
} else {
console.log('Trades table ready');
}
});
}
async function createDefaultAdmin() {
const adminUsername = 'admin';
const adminPassword = 'admin123';
db.get('SELECT id FROM users WHERE username = ?', [adminUsername], async (err, row) => {
if (err) {
console.error('Error checking for admin user:', err.message);
return;
}
if (!row) {
try {
const passwordHash = await bcrypt.hash(adminPassword, 10);
db.run(
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)',
[adminUsername, passwordHash, 1],
function(err) {
if (err) {
console.error('Error creating admin user:', err.message);
} else {
console.log('Default admin user created (username: admin, password: admin123)');
}
}
);
} catch (error) {
console.error('Error hashing admin password:', error);
}
}
});
}
// Middleware to check if user is authenticated
function requireAuth(req, res, next) {
if (req.session && req.session.userId) {
next();
} else {
res.status(401).json({ error: 'Authentication required' });
}
}
// Middleware to check if user is admin
function requireAdmin(req, res, next) {
if (req.session && req.session.userId && req.session.isAdmin) {
next();
} else {
res.status(403).json({ error: 'Admin privileges required' });
}
}
// Authentication endpoints
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => {
if (err) {
console.error('Login error:', err.message);
return res.status(500).json({ error: 'Server error' });
}
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
try {
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Update last login
db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]);
// Set session
req.session.userId = user.id;
req.session.username = user.username;
req.session.isAdmin = user.is_admin;
res.json({
id: user.id,
username: user.username,
isAdmin: user.is_admin
});
} catch (error) {
console.error('Password comparison error:', error);
res.status(500).json({ error: 'Server error' });
}
});
});
app.post('/api/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Could not log out' });
}
res.json({ message: 'Logged out successfully' });
});
});
app.get('/api/me', requireAuth, (req, res) => {
res.json({
id: req.session.userId,
username: req.session.username,
isAdmin: req.session.isAdmin
});
});
// User management endpoints (admin only)
app.get('/api/admin/users', requireAdmin, (req, res) => {
const sql = 'SELECT id, username, email, is_admin, created_at, last_login FROM users ORDER BY created_at DESC';
db.all(sql, [], (err, rows) => {
if (err) {
console.error('Error fetching users:', err.message);
res.status(500).json({ error: 'Failed to fetch users' });
return;
}
res.json(rows);
});
});
app.post('/api/admin/users', requireAdmin, async (req, res) => {
const { username, password, email, isAdmin } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
try {
const passwordHash = await bcrypt.hash(password, 10);
const sql = 'INSERT INTO users (username, password_hash, email, is_admin) VALUES (?, ?, ?, ?)';
db.run(sql, [username, passwordHash, email || null, isAdmin ? 1 : 0], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
res.status(400).json({ error: 'Username or email already exists' });
} else {
console.error('Error creating user:', err.message);
res.status(500).json({ error: 'Failed to create user' });
}
return;
}
res.status(201).json({
id: this.lastID,
username,
email,
isAdmin: isAdmin || false
});
});
} catch (error) {
console.error('Password hashing error:', error);
res.status(500).json({ error: 'Server error' });
}
});
app.delete('/api/admin/users/:id', requireAdmin, (req, res) => {
const userId = req.params.id;
if (!userId || isNaN(userId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
// Prevent deleting yourself
if (parseInt(userId) === req.session.userId) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
db.run('DELETE FROM users WHERE id = ?', [userId], function(err) {
if (err) {
console.error('Error deleting user:', err.message);
res.status(500).json({ error: 'Failed to delete user' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json({ message: 'User deleted successfully' });
});
});
app.get('/api/trades', requireAuth, (req, res) => {
const sql = 'SELECT * FROM trades WHERE user_id = ? ORDER BY trade_datetime DESC, created_at DESC';
db.all(sql, [req.session.userId], (err, rows) => {
if (err) {
console.error('Error fetching trades:', err.message);
res.status(500).json({ error: 'Failed to fetch trades' });
return;
}
const trades = rows.map(row => ({
id: row.id.toString(),
etfSymbol: row.etf_symbol,
tradeType: row.trade_type,
shares: row.shares,
price: row.price,
currency: row.currency,
dateTime: row.trade_datetime,
fees: row.fees,
notes: row.notes,
totalValue: row.total_value,
timestamp: new Date(row.created_at).getTime()
}));
res.json(trades);
});
});
app.post('/api/trades', requireAuth, (req, res) => {
const {
etfSymbol,
tradeType,
shares,
price,
currency,
dateTime,
fees = 0,
notes = ''
} = req.body;
if (!etfSymbol || !tradeType || !shares || !price || !currency || !dateTime) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (!['buy', 'sell'].includes(tradeType)) {
return res.status(400).json({ error: 'Invalid trade type' });
}
if (!['EUR', 'USD'].includes(currency)) {
return res.status(400).json({ error: 'Invalid currency' });
}
if (shares <= 0 || price <= 0 || fees < 0) {
return res.status(400).json({ error: 'Invalid numeric values' });
}
const totalValue = (shares * price) + fees;
const sql = `
INSERT INTO trades (user_id, etf_symbol, trade_type, shares, price, currency, trade_datetime, fees, notes, total_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const params = [
req.session.userId,
etfSymbol.toUpperCase(),
tradeType,
shares,
price,
currency,
dateTime,
fees,
notes,
totalValue
];
db.run(sql, params, function(err) {
if (err) {
console.error('Error inserting trade:', err.message);
res.status(500).json({ error: 'Failed to add trade' });
return;
}
const newTrade = {
id: this.lastID.toString(),
etfSymbol: etfSymbol.toUpperCase(),
tradeType,
shares,
price,
currency,
dateTime,
fees,
notes,
totalValue,
timestamp: Date.now()
};
res.status(201).json(newTrade);
});
});
app.delete('/api/trades/:id', requireAuth, (req, res) => {
const tradeId = req.params.id;
if (!tradeId || isNaN(tradeId)) {
return res.status(400).json({ error: 'Invalid trade ID' });
}
const sql = 'DELETE FROM trades WHERE id = ? AND user_id = ?';
db.run(sql, [tradeId, req.session.userId], function(err) {
if (err) {
console.error('Error deleting trade:', err.message);
res.status(500).json({ error: 'Failed to delete trade' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Trade not found' });
return;
}
res.json({ message: 'Trade deleted successfully' });
});
});
app.delete('/api/trades', requireAuth, (req, res) => {
const sql = 'DELETE FROM trades WHERE user_id = ?';
db.run(sql, [req.session.userId], function(err) {
if (err) {
console.error('Error clearing trades:', err.message);
res.status(500).json({ error: 'Failed to clear trades' });
return;
}
res.json({
message: 'All trades cleared successfully',
deletedCount: this.changes
});
});
});
app.get('/api/portfolio-summary', requireAuth, (req, res) => {
const sql = `
SELECT
etf_symbol,
SUM(CASE WHEN trade_type = 'buy' THEN shares ELSE -shares END) as total_shares,
SUM(CASE WHEN trade_type = 'buy' THEN total_value ELSE -total_value END) as total_invested,
COUNT(*) as trade_count
FROM trades
WHERE user_id = ?
GROUP BY etf_symbol
HAVING total_shares > 0
ORDER BY total_invested DESC
`;
db.all(sql, [req.session.userId], (err, rows) => {
if (err) {
console.error('Error fetching portfolio summary:', err.message);
res.status(500).json({ error: 'Failed to fetch portfolio summary' });
return;
}
const summary = rows.map(row => ({
symbol: row.etf_symbol,
totalShares: row.total_shares,
totalInvested: row.total_invested,
avgPrice: row.total_shares > 0 ? row.total_invested / row.total_shares : 0,
trades: row.trade_count
}));
res.json(summary);
});
});
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
process.on('SIGINT', () => {
console.log('\nShutting down server...');
db.close((err) => {
if (err) {
console.error('Error closing database:', err.message);
} else {
console.log('Database connection closed.');
}
process.exit(0);
});
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on http://0.0.0.0:${PORT}`);
});

1312
styles.css Normal file

File diff suppressed because it is too large Load Diff