commit 53b9087078559dc9f077476e8b0e5d747d83053a Author: chris Date: Mon Jul 28 12:56:38 2025 -0400 initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..79d8a3e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Use a slim, Debian-based Node.js runtime for better compatibility +FROM node:18-slim + +# Set the working directory in the container +WORKDIR /usr/src/app + +# Copy package.json and package-lock.json to leverage Docker's layer caching +COPY package*.json ./ + +# Install app dependencies +RUN npm install --omit=dev + +# Copy all project files, including the 'public' directory, into the container +COPY . . + +# Make port 3000 available +EXPOSE 3000 + +# Define the command to run your app +CMD [ "node", "server.js" ] diff --git a/data/timetracker.db b/data/timetracker.db new file mode 100644 index 0000000..b1709c7 Binary files /dev/null and b/data/timetracker.db differ diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..2c60f3e --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,27 @@ +# Define the services (containers) for your application +services: + # The name of our service + time-tracker-app: + # Build the Docker image from the Dockerfile in the current directory (.) + build: . + # Name the container for easier management + container_name: time-tracker + # Restart policy: always restart the container if it stops. + # This is useful for production environments to ensure the app is always running. + restart: always + # Keep the container running by allocating a pseudo-TTY + tty: true + # Port mapping: map port 3000 on the host machine to port 3000 in the container. + # This allows you to access the application via http://:3000 + ports: + - "3002:3000" + # Volume mapping: persist the database file. + # This creates a 'data' folder in your project directory on the host machine + # and links it to the /usr/src/app/data directory inside the container. + # The timetracker.db will be stored here, ensuring data is not lost. + volumes: + - ./data:/usr/src/app/data + # Environment file: tells Docker Compose to use the .env file in the + # current directory to set environment variables inside the container. + env_file: + - .env diff --git a/index.html b/index.html new file mode 100644 index 0000000..c161a11 --- /dev/null +++ b/index.html @@ -0,0 +1,243 @@ + + + + + + + TimeTracker + + + + + + + + + +
+ +
+ +
+ +
+ + +
+ + + +
+ + +
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..88b57b0 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "dependencies": { + "bcryptjs": "^3.0.2", + "cors": "^2.8.5", + "dotenv": "^17.2.1", + "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", + "pm2": "^6.0.8", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..0537251 --- /dev/null +++ b/server.js @@ -0,0 +1,264 @@ +// --- Time Tracker Backend Server (with Time Off Requests) --- + +require('dotenv').config(); +const express = require('express'); +const sqlite3 = require('sqlite3').verbose(); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const cors = require('cors'); +const path = require('path'); + +// --- Server Configuration --- +const PORT = process.env.PORT || 3000; +const JWT_SECRET = process.env.JWT_SECRET || 'default_secret_key'; +const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpassword'; + +const app = express(); +app.use(cors()); +app.use(express.json()); + +const dbPath = path.resolve(__dirname, 'data', 'timetracker.db'); +const db = new sqlite3.Database(dbPath, (err) => { + if (err) console.error("Error opening database", err.message); + else { + console.log("Connected to the SQLite database."); + initializeDatabase(); + } +}); + +function initializeDatabase() { + db.serialize(() => { + db.run(`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'employee')`); + db.run(`CREATE TABLE IF NOT EXISTS time_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, username TEXT, punch_in_time DATETIME NOT NULL, punch_out_time DATETIME, status TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE)`); + db.run(`CREATE TABLE IF NOT EXISTS archived_time_entries (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, username TEXT, punch_in_time DATETIME NOT NULL, punch_out_time DATETIME, status TEXT NOT NULL, archived_at DATETIME NOT NULL)`); + // NEW: Table for time off requests + db.run(`CREATE TABLE IF NOT EXISTS time_off_requests (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, username TEXT, start_date TEXT NOT NULL, end_date TEXT NOT NULL, reason TEXT, status TEXT NOT NULL DEFAULT 'pending', FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE)`); + + db.get('SELECT * FROM users WHERE username = ?', [ADMIN_USERNAME], (err, row) => { + if (!row) { + bcrypt.hash(ADMIN_PASSWORD, 10, (err, hashedPassword) => { + db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [ADMIN_USERNAME, hashedPassword, 'admin']); + }); + } + }); + }); +} + +// --- Middleware --- +const requireRole = (role) => (req, res, next) => { + if (req.user && req.user.role === role) next(); + else res.status(403).json({ message: "Access denied." }); +}; + +function authenticateToken(req, res, next) { + const token = req.headers['authorization']?.split(' ')[1]; + if (token == null) return res.sendStatus(401); + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) return res.sendStatus(403); + req.user = user; + next(); + }); +} + +// --- API Routes --- +app.post('/api/login', (req, res) => { + const { username, password } = req.body; + db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => { + if (!user) return res.status(404).json({ message: "User not found." }); + bcrypt.compare(password, user.password, (err, isMatch) => { + if (!isMatch) return res.status(401).json({ message: "Invalid credentials." }); + const tokenPayload = { id: user.id, username: user.username, role: user.role }; + const token = jwt.sign(tokenPayload, JWT_SECRET, { expiresIn: '30d' }); + res.json({ token, user: tokenPayload }); + }); + }); +}); + +app.post('/api/punch', authenticateToken, (req, res) => { + const { id, username } = req.user; + db.get(`SELECT * FROM time_entries WHERE user_id = ? ORDER BY punch_in_time DESC LIMIT 1`, [id], (err, last) => { + if (last && last.status === 'in') { + db.run(`UPDATE time_entries SET punch_out_time = ?, status = 'out' WHERE id = ?`, [new Date().toISOString(), last.id], () => res.json({ message: "Punched out." })); + } else { + db.run(`INSERT INTO time_entries (user_id, username, punch_in_time, status) VALUES (?, ?, ?, 'in')`, [id, username, new Date().toISOString()], () => res.json({ message: "Punched in." })); + } + }); +}); + +app.get('/api/status', authenticateToken, (req, res) => { + db.all(`SELECT * FROM time_entries WHERE user_id = ? ORDER BY punch_in_time DESC`, [req.user.id], (err, rows) => res.json(rows)); +}); + +app.post('/api/user/change-password', authenticateToken, (req, res) => { + const { currentPassword, newPassword } = req.body; + const { id } = req.user; + db.get('SELECT * FROM users WHERE id = ?', [id], (err, user) => { + if (err || !user) return res.status(500).json({ message: "Could not find user." }); + bcrypt.compare(currentPassword, user.password, (err, isMatch) => { + if (!isMatch) return res.status(401).json({ message: "Incorrect current password." }); + bcrypt.hash(newPassword, 10, (err, hashedPassword) => { + db.run('UPDATE users SET password = ? WHERE id = ?', [hashedPassword, id], (err) => { + res.json({ message: "Password updated successfully." }); + }); + }); + }); + }); +}); + +// NEW: Employee requests time off +app.post('/api/user/request-time-off', authenticateToken, (req, res) => { + const { startDate, endDate, reason } = req.body; + const { id, username } = req.user; + if (!startDate || !endDate) return res.status(400).json({ message: "Start and end dates are required." }); + + const sql = `INSERT INTO time_off_requests (user_id, username, start_date, end_date, reason) VALUES (?, ?, ?, ?, ?)`; + db.run(sql, [id, username, startDate, endDate, reason], function(err) { + if (err) return res.status(500).json({ message: "Failed to submit request." }); + res.status(201).json({ message: "Time off request submitted." }); + }); +}); + +// NEW: Employee views their time off requests +app.get('/api/user/time-off-requests', authenticateToken, (req, res) => { + const { id } = req.user; + db.all("SELECT * FROM time_off_requests WHERE user_id = ? ORDER BY start_date DESC", [id], (err, rows) => { + if (err) return res.status(500).json({ message: "Failed to fetch requests." }); + res.json(rows); + }); +}); + +// --- Admin Routes --- +app.post('/api/admin/create-user', authenticateToken, requireRole('admin'), (req, res) => { + const { username, password, role } = req.body; + const userRole = (role === 'admin' || role === 'employee') ? role : 'employee'; + bcrypt.hash(password, 10, (err, hashedPassword) => { + db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [username, hashedPassword, userRole], function(err) { + if (err) return res.status(409).json({ message: "Username already exists." }); + res.status(201).json({ message: `User '${username}' created as ${userRole}.` }); + }); + }); +}); + +app.delete('/api/admin/delete-user/:username', authenticateToken, requireRole('admin'), (req, res) => { + const { username } = req.params; + if (username === req.user.username) return res.status(400).json({ message: "Cannot delete your own account." }); + if (username === ADMIN_USERNAME) return res.status(403).json({ message: "The primary admin account cannot be deleted." }); + + db.get("SELECT id FROM users WHERE username = ?", [username], (err, userToDelete) => { + if (!userToDelete) return res.status(404).json({ message: "User not found." }); + db.serialize(() => { + db.run('BEGIN TRANSACTION'); + db.run('DELETE FROM time_entries WHERE user_id = ?', [userToDelete.id]); + db.run('DELETE FROM archived_time_entries WHERE user_id = ?', [userToDelete.id]); + db.run('DELETE FROM users WHERE id = ?', [userToDelete.id], () => db.run('COMMIT', () => res.json({ message: `User '${username}' deleted.` }))); + }); + }); +}); + +app.post('/api/admin/reset-password', authenticateToken, requireRole('admin'), (req, res) => { + const { username, newPassword } = req.body; + if (username === ADMIN_USERNAME) return res.status(403).json({ message: "The primary admin's password cannot be reset by another user." }); + bcrypt.hash(newPassword, 10, (err, hashedPassword) => { + db.run('UPDATE users SET password = ? WHERE username = ?', [hashedPassword, username], function(err) { + if (this.changes === 0) return res.status(404).json({ message: "User not found." }); + res.json({ message: `Password for '${username}' has been reset.` }); + }); + }); +}); + +app.get('/api/admin/users', authenticateToken, requireRole('admin'), (req, res) => { + db.all("SELECT id, username, role FROM users", [], (err, rows) => { + if(err) return res.status(500).json({message: "Database error fetching users."}); + const usersWithFlags = rows.map(row => ({ ...row, isPrimary: row.username === ADMIN_USERNAME })); + res.json(usersWithFlags); + }); +}); + +app.post('/api/admin/update-role', authenticateToken, requireRole('admin'), (req, res) => { + const { username, newRole } = req.body; + if (username === ADMIN_USERNAME) return res.status(403).json({ message: "The primary admin's role cannot be changed." }); + if (!['admin', 'employee'].includes(newRole)) return res.status(400).json({ message: "Invalid role." }); + db.get("SELECT role FROM users WHERE username = ?", [username], (err, userToUpdate) => { + if (!userToUpdate) return res.status(404).json({ message: "User not found." }); + if (userToUpdate.role === 'admin' && newRole === 'employee') { + db.get("SELECT COUNT(*) as adminCount FROM users WHERE role = 'admin'", (err, row) => { + if (row.adminCount <= 1) return res.status(400).json({ message: "Cannot remove the last administrator." }); + db.run('UPDATE users SET role = ? WHERE username = ?', [newRole, username], () => res.json({ message: `Role for '${username}' updated.` })); + }); + } else { + db.run('UPDATE users SET role = ? WHERE username = ?', [newRole, username], () => res.json({ message: `Role for '${username}' updated.` })); + } + }); +}); + +app.post('/api/admin/add-punch', authenticateToken, requireRole('admin'), (req, res) => { + const { userId, username, punchInTime, punchOutTime } = req.body; + db.run(`INSERT INTO time_entries (user_id, username, punch_in_time, punch_out_time, status) VALUES (?, ?, ?, ?, 'out')`, [userId, username, punchInTime, punchOutTime], () => res.status(201).json({message: "Time punch added."})); +}); + +app.post('/api/admin/force-clock-out', authenticateToken, requireRole('admin'), (req, res) => { + const { userId } = req.body; + db.run(`UPDATE time_entries SET punch_out_time = ?, status = 'out' WHERE user_id = ? AND status = 'in'`, [new Date().toISOString(), userId], function(err) { + if(this.changes === 0) return res.status(404).json({message: "No active punch-in found."}); + res.json({message: "User has been clocked out."}); + }); +}); + +// NEW: Get all time off requests +app.get('/api/admin/time-off-requests', authenticateToken, requireRole('admin'), (req, res) => { + db.all("SELECT * FROM time_off_requests ORDER BY start_date DESC", [], (err, rows) => { + if (err) return res.status(500).json({ message: "Failed to fetch requests." }); + res.json(rows); + }); +}); + +// NEW: Update status of a time off request +app.post('/api/admin/update-time-off-status', authenticateToken, requireRole('admin'), (req, res) => { + const { requestId, status } = req.body; + if (!requestId || !['approved', 'denied'].includes(status)) { + return res.status(400).json({ message: "Request ID and a valid status are required." }); + } + db.run("UPDATE time_off_requests SET status = ? WHERE id = ?", [status, requestId], function(err) { + if (err) return res.status(500).json({ message: "Failed to update status." }); + if (this.changes === 0) return res.status(404).json({ message: "Request not found." }); + res.json({ message: `Request has been ${status}.` }); + }); +}); + + +app.get('/api/admin/logs', authenticateToken, requireRole('admin'), (req, res) => { + db.all(`SELECT * FROM time_entries ORDER BY punch_in_time DESC`, (err, rows) => res.json(rows)); +}); + +app.delete('/api/admin/logs/:id', authenticateToken, requireRole('admin'), (req, res) => { + db.run('DELETE FROM time_entries WHERE id = ?', [req.params.id], () => res.json({ message: 'Entry deleted.' })); +}); + +app.put('/api/admin/logs/:id', authenticateToken, requireRole('admin'), (req, res) => { + const { punch_in_time, punch_out_time } = req.body; + const status = punch_out_time ? 'out' : 'in'; + db.run(`UPDATE time_entries SET punch_in_time = ?, punch_out_time = ?, status = ? WHERE id = ?`, [punch_in_time, punch_out_time, status, req.params.id], () => res.json({ message: 'Entry updated.' })); +}); + +app.post('/api/admin/archive', authenticateToken, requireRole('admin'), (req, res) => { + db.all(`SELECT * FROM time_entries WHERE status = 'out'`, (err, rows) => { + if (rows.length === 0) return res.json({ message: "No entries to archive." }); + const archiveTime = new Date().toISOString(); + db.serialize(() => { + db.run('BEGIN TRANSACTION'); + const insert = db.prepare('INSERT INTO archived_time_entries VALUES (?, ?, ?, ?, ?, ?, ?)'); + rows.forEach(r => insert.run(r.id, r.user_id, r.username, r.punch_in_time, r.punch_out_time, r.status, archiveTime)); + insert.finalize(); + const idsToDelete = rows.map(r => r.id); + db.run(`DELETE FROM time_entries WHERE id IN (${idsToDelete.map(() => '?').join(',')})`, idsToDelete); + db.run('COMMIT', () => res.json({ message: `Archived ${rows.length} entries.` })); + }); + }); +}); + +app.get('/api/admin/archives', authenticateToken, requireRole('admin'), (req, res) => { + db.all(`SELECT * FROM archived_time_entries ORDER BY archived_at DESC, id DESC`, (err, rows) => res.json(rows)); +}); + +app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));