commit cfd83aedc4155b5d76f201fdfcf907a4fa11d170 Author: jpmvaz Date: Sun Sep 13 20:17:19 2026 +0100 v_1.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..615380d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.DS_Store +backup_*.json \ No newline at end of file diff --git a/OLD_VERSIONS/v1.2/Martinhal_Contact_Site.zip b/OLD_VERSIONS/v1.2/Martinhal_Contact_Site.zip new file mode 100644 index 0000000..63fc2e3 Binary files /dev/null and b/OLD_VERSIONS/v1.2/Martinhal_Contact_Site.zip differ diff --git a/OLD_VERSIONS/v1.3/Martinhal_Contact_Site_v1.3_update.zip b/OLD_VERSIONS/v1.3/Martinhal_Contact_Site_v1.3_update.zip new file mode 100644 index 0000000..f2278ae Binary files /dev/null and b/OLD_VERSIONS/v1.3/Martinhal_Contact_Site_v1.3_update.zip differ diff --git a/OLD_VERSIONS/v1.4/Martinhal_Contact_Site_v1.4_update.zip b/OLD_VERSIONS/v1.4/Martinhal_Contact_Site_v1.4_update.zip new file mode 100644 index 0000000..f3411b9 Binary files /dev/null and b/OLD_VERSIONS/v1.4/Martinhal_Contact_Site_v1.4_update.zip differ diff --git a/OLD_VERSIONS/v1.5/Martinhal_Contact_Site_v1.5_update.zip b/OLD_VERSIONS/v1.5/Martinhal_Contact_Site_v1.5_update.zip new file mode 100644 index 0000000..2ba16dd Binary files /dev/null and b/OLD_VERSIONS/v1.5/Martinhal_Contact_Site_v1.5_update.zip differ diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..3919cad --- /dev/null +++ b/Readme.md @@ -0,0 +1,227 @@ +# Martinhal Contact Management System - Docker Setup + +## 🐳 Docker Installation (Recommended) + +### Prerequisites +- Docker installed: https://docs.docker.com/get-docker/ +- Docker Compose installed (usually comes with Docker Desktop) + +### Quick Start with Docker + +1. **Make sure you have all files in the same directory:** +``` +martinhal-contacts/ +β”œβ”€β”€ Dockerfile +β”œβ”€β”€ docker-compose.yml +β”œβ”€β”€ .dockerignore +β”œβ”€β”€ index.html +β”œβ”€β”€ server.js +β”œβ”€β”€ package.json +β”œβ”€β”€ contacts_data.json (created automatically) +└── users_data.json (created automatically on first run) +``` + +2. **Build and start the container:** +```bash +docker-compose up -d +``` + +3. **Access the application:** +Open your browser and go to: `http://localhost:8000` + +4. **Stop the application:** +```bash +docker-compose down +``` + +5. **View logs:** +```bash +docker-compose logs -f +``` + +### Docker Commands Reference + +**Start the application:** +```bash +docker-compose up -d +``` + +**Stop the application:** +```bash +docker-compose down +``` + +**Restart the application:** +```bash +docker-compose restart +``` + +**View logs:** +```bash +docker-compose logs -f martinhal-contacts +``` + +**Rebuild after code changes:** +```bash +docker-compose down +docker-compose build +docker-compose up -d +``` + +**Access container shell:** +```bash +docker exec -it martinhal-contacts sh +``` + +## πŸ“¦ Data Persistence + +Docker volumes ensure your data persists: +- **contacts_data.json** - Your contact database (automatically backed up) +- **backups/** - Directory for backup files + +Even if you delete and recreate containers, your data remains safe! + +## πŸ”„ Updating the Application + +1. Update your files (index.html, server.js, etc.) +2. Rebuild and restart: +```bash +docker-compose down +docker-compose build +docker-compose up -d +``` + +## πŸ” Authentication (v1.5) + +There are **no hardcoded or default passwords** anywhere. Nothing on the site is visible until you sign in, and every account is created by an administrator. + +### First time you open the site +On the very first visit **no account exists yet**, so the site shows a **"Create Admin Account"** screen. You choose the first administrator's username and password and you're signed in straight away. This one-time setup only appears when the site has no users β€” it is **not** shown when navigating to the admin section later. + +### Signing in +After setup, opening the site shows a **login page**. By default sign-in is just **username + password** β€” there is no separate "view password" and no separate "admin password", the whole site is behind this single login. + +### Two-factor authentication (optional) +**MFA is never mandatory.** Every account signs in with just a username and password unless that user chooses to turn on 2FA. Any user can enable it from **Profile β†’ Two-factor authentication**: scan the QR code with an authenticator app (Google Authenticator, Authy, 1Password, Microsoft Authenticator…) and confirm a 6-digit code. Once enabled, that account's login also asks for the current code. The same panel lets the user **disable** it again at any time, returning to password-only sign-in. + +### Users & roles +Signed-in administrators can open **Users** to add more accounts: +- **Administrator** accounts can view and edit the directory and manage users. +- **View-only** accounts can browse the directory but cannot edit it or manage users (the Admin button is hidden for them, and the server rejects any write attempts). + +New users sign in with just their username and password; enabling 2FA is each user's own choice. Every user has a **profile with an uploadable avatar** and display name (the **Profile** button, available on every page after login). + +### Changing your password +Any signed-in user (administrators included) can change their **own** password from **Profile β†’ Change password**: enter the current password, then the new one twice. The new password must be at least 8 characters. After a successful change the account is signed out of any *other* active sessions, while the session you changed it from stays logged in. + +### Footer +Every page shown **after login** displays the footer **"Β© 2026 Martinhal IT - Joao Vaz - Version 1.5"**. The login / create-admin screen intentionally has no footer. + +### Resetting all accounts (start setup over) +Delete `users_data.json` and restart β€” the create-admin screen returns: +```bash +docker exec -it martinhal-contacts sh -c "rm -f users_data.json" +docker-compose restart +``` + +> ⚠️ `users_data.json` holds password hashes and MFA secrets. It is **never** served over HTTP and must be treated as sensitive. To keep accounts across container rebuilds, mount it as a volume (e.g. `- ./users_data.json:/app/users_data.json`). + +## ⬆️ Updating from a previous version (no data loss) + +This update ships **only code files** β€” it does **not** contain `contacts_data.json` or `users_data.json`, so unzipping it over your existing installation keeps all your contacts and accounts intact. + +1. Unzip the update **into your existing project folder**, overwriting `index.html`, `server.js`, `package.json`, `Readme.md`, `dockerfile`, `docker-compose.yml` and `.dockerignore`. Your data files are left untouched. +2. Rebuild and restart the container (required because `server.js`/`package.json` changed): +```bash +docker-compose down +docker-compose build +docker-compose up -d +``` +3. Existing accounts keep working. Any account that previously had MFA turned on will still be asked for its code (and can disable it from **Profile**); every other account now signs in with just username and password. If you're upgrading from the very first (pre-login) version and have no accounts yet, the site will show the create-admin screen on first load. + +> `credentials.json` is no longer used and can be deleted; it is ignored by the app and the Docker build. + +## πŸ›  Troubleshooting + +**Port 8000 already in use:** +Edit `docker-compose.yml` and change the port mapping: +```yaml +ports: + - "3000:8000" # Access via http://localhost:3000 +``` + +**Container won't start:** +```bash +docker-compose logs martinhal-contacts +``` + +**Remove everything and start fresh:** +```bash +docker-compose down +docker system prune -a +docker-compose up -d +``` + +## πŸ“‹ Alternative: Manual Setup (No Docker) + +If you don't want to use Docker: + +### Step 1: Install Node.js +Download from: https://nodejs.org/ + +### Step 2: Install Dependencies +```bash +npm install +``` + +### Step 3: Start Server +```bash +npm start +``` + +### Step 4: Open Browser +``` +http://localhost:8000 +``` + +## 🎯 Production Deployment + +For production deployment with Docker: + +1. **Use environment variables for sensitive data** +2. **Set up HTTPS with reverse proxy (nginx)** +3. **Configure automated backups** +4. **Set resource limits in docker-compose.yml** + +Example production docker-compose.yml: +```yaml +version: '3.8' +services: + martinhal-contacts: + build: . + container_name: martinhal-contacts + ports: + - "8000:8000" + volumes: + - ./contacts_data.json:/app/contacts_data.json + - ./backups:/app/backups + restart: always + environment: + - NODE_ENV=production + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M +``` + +## βœ… Benefits of Docker + +- βœ… **Consistent environment** - Works the same everywhere +- βœ… **Easy deployment** - One command to start +- βœ… **Isolated** - Doesn't interfere with other apps +- βœ… **Easy updates** - Rebuild and restart +- βœ… **Data persistence** - Your data is safe +- βœ… **Easy backups** - Just copy the volume + +That's it! Your Martinhal Contact Management System is now running in Docker! πŸŽ‰ \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32c08ae --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + martinhal-contacts: + build: . + container_name: martinhal-contacts + ports: + - "8000:8000" + volumes: + - contacts-data:/app/data + restart: unless-stopped + environment: + - NODE_ENV=production + +volumes: + contacts-data: \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..3c982c3 --- /dev/null +++ b/dockerfile @@ -0,0 +1,23 @@ +FROM node:18-alpine + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy application files +COPY index.html ./ +COPY server.js ./ + +# Create backups directory +RUN mkdir -p /app/backups + +# Expose port +EXPOSE 8000 + +# Start the server +CMD ["npm", "start"] \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..e38a6d9 --- /dev/null +++ b/index.html @@ -0,0 +1,668 @@ + + + + + +Martinhal Contacts + + + + + + + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..de18957 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "martinhal-contacts", + "version": "1.5.0", + "description": "Martinhal Contact Management System", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "qrcode": "^1.5.3" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..01c8de4 --- /dev/null +++ b/server.js @@ -0,0 +1,392 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const cors = require('cors'); +const crypto = require('crypto'); +const QRCode = require('qrcode'); + +const app = express(); +const PORT = 8000; + +// Middleware +app.use(cors()); +app.use(express.json({ limit: '50mb' })); // Increased limit for base64 images/avatars + +// SECURITY: never let the static handler expose the auth store (contains +// password hashes and MFA secrets). Must come BEFORE express.static. +app.use((req, res, next) => { + if (/users_data\.json/i.test(req.path)) return res.status(404).end(); + next(); +}); +app.use(express.static(__dirname)); // Serve static files from current directory + +const DATA_FILE = path.join(__dirname, 'contacts_data.json'); +const USERS_FILE = path.join(__dirname, 'users_data.json'); + +// Initialize contacts data file if it doesn't exist +if (!fs.existsSync(DATA_FILE)) { + const initialData = { + contacts: [ + { id: 1, name: 'John Smith', unit: 'Marketing', phone: '+351 123 456 789', mobile: '+351 987 654 321', email: 'john.smith@company.com', department: 'IT', locations: ['Martinhal Oriente'], photo: 'https://i.pravatar.cc/150?img=12' }, + { id: 2, name: 'Maria Santos', unit: 'Finance', phone: '+351 123 456 790', mobile: '+351 987 654 322', email: 'maria.santos@company.com', department: 'Accounting', locations: ['Martinhal Lisbon', 'Martinhal Quinta'], photo: 'https://i.pravatar.cc/150?img=5' }, + { id: 3, name: 'Pedro Costa', unit: 'Operations', phone: '+351 123 456 791', mobile: '+351 987 654 323', email: 'pedro.costa@company.com', department: 'Maintenance', locations: ['Martinhal Sagres'], photo: 'https://i.pravatar.cc/150?img=33' } + ], + departments: ['IT', 'Accounting', 'Board', 'Housekeeping', 'Maintenance'], + locations: ['Martinhal Oriente', 'Martinhal Lisbon', 'Martinhal Quinta', 'Martinhal Sagres'] + }; + fs.writeFileSync(DATA_FILE, JSON.stringify(initialData, null, 2)); + console.log('Created initial data file:', DATA_FILE); +} + +// Initialize (empty) users store if it doesn't exist -> triggers first-run setup +if (!fs.existsSync(USERS_FILE)) { + fs.writeFileSync(USERS_FILE, JSON.stringify({ users: [] }, null, 2)); + console.log('Created empty users store:', USERS_FILE); +} + +// --------------------------------------------------------------------------- +// Auth helpers +// --------------------------------------------------------------------------- + +function loadUsers() { + try { return JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } + catch (e) { return { users: [] }; } +} +function saveUsers(d) { + fs.writeFileSync(USERS_FILE, JSON.stringify(d, null, 2)); +} + +// Password hashing (scrypt + random salt, constant-time compare) +function hashPassword(pw) { + const salt = crypto.randomBytes(16).toString('hex'); + const hash = crypto.scryptSync(String(pw), salt, 64).toString('hex'); + return { salt, hash }; +} +function verifyPassword(pw, salt, hash) { + try { + const h = crypto.scryptSync(String(pw), salt, 64).toString('hex'); + const a = Buffer.from(h, 'hex'); + const b = Buffer.from(hash, 'hex'); + return a.length === b.length && crypto.timingSafeEqual(a, b); + } catch (e) { return false; } +} + +// --- TOTP (RFC 6238) implemented with built-in crypto, no extra deps --- +const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +function base32Encode(buf) { + let bits = 0, value = 0, out = ''; + for (let i = 0; i < buf.length; i++) { + value = (value << 8) | buf[i]; bits += 8; + while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } + } + if (bits > 0) out += B32[(value << (5 - bits)) & 31]; + return out; +} +function base32Decode(str) { + str = String(str).replace(/=+$/, '').toUpperCase().replace(/\s/g, ''); + let bits = 0, value = 0; const out = []; + for (const ch of str) { + const idx = B32.indexOf(ch); + if (idx < 0) continue; + value = (value << 5) | idx; bits += 5; + if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; } + } + return Buffer.from(out); +} +function genTotpSecret() { return base32Encode(crypto.randomBytes(20)); } +function hotp(secretB32, counter) { + const key = base32Decode(secretB32); + const buf = Buffer.alloc(8); + for (let i = 7; i >= 0; i--) { buf[i] = counter & 0xff; counter = Math.floor(counter / 256); } + const hmac = crypto.createHmac('sha1', key).update(buf).digest(); + const offset = hmac[hmac.length - 1] & 0xf; + const code = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff); + return (code % 1000000).toString().padStart(6, '0'); +} +function verifyTotp(secretB32, token, window = 1) { + if (!token) return false; + token = String(token).replace(/\s/g, ''); + const counter = Math.floor(Date.now() / 1000 / 30); + for (let e = -window; e <= window; e++) { + if (hotp(secretB32, counter + e) === token) return true; + } + return false; +} +function otpauthUrl(username, secret) { + const issuer = 'Martinhal Contacts'; + return 'otpauth://totp/' + encodeURIComponent(issuer) + ':' + encodeURIComponent(username) + + '?secret=' + secret + '&issuer=' + encodeURIComponent(issuer) + '&algorithm=SHA1&digits=6&period=30'; +} + +// Sessions (in-memory; users re-login after a server restart) +const sessions = new Map(); // token -> username +function issueToken(username) { + const t = crypto.randomBytes(24).toString('hex'); + sessions.set(t, username); + return t; +} +function currentUser(req) { + const h = req.headers.authorization || ''; + const t = h.startsWith('Bearer ') ? h.slice(7) : null; + if (!t || !sessions.has(t)) return null; + const uname = sessions.get(t); + const d = loadUsers(); + return d.users.find(u => u.username === uname) || null; +} +function requireAuth(req, res, next) { + const u = currentUser(req); + if (!u) return res.status(401).json({ error: 'Not authenticated' }); + req.user = u; + next(); +} +function requireAdmin(req, res, next) { + const u = currentUser(req); + if (!u) return res.status(401).json({ error: 'Not authenticated' }); + if (!u.isAdmin) return res.status(403).json({ error: 'Administrator privileges required' }); + req.user = u; + next(); +} +function safeProfile(u) { + return { + username: u.username, + displayName: u.displayName || u.username, + avatar: u.avatar || '', + mfaEnabled: !!u.mfaEnabled, + isAdmin: !!u.isAdmin + }; +} + +// --------------------------------------------------------------------------- +// Auth routes +// --------------------------------------------------------------------------- + +// Is initial setup done? (any user exists) +app.get('/api/auth/status', (req, res) => { + const d = loadUsers(); + res.json({ setupComplete: d.users.length > 0 }); +}); + +// First-run setup: create the very first admin (only when no users exist). +// MFA is optional, so we don't force enrollment β€” we just sign them in. +app.post('/api/auth/setup', (req, res) => { + const d = loadUsers(); + if (d.users.length > 0) return res.status(403).json({ error: 'Setup already completed' }); + const { username, password, displayName } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); + if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + const { salt, hash } = hashPassword(password); + const user = { + username: String(username).trim(), + salt, hash, + mfaSecret: '', // generated only if/when the user enables MFA + mfaEnabled: false, + displayName: (displayName || '').trim() || String(username).trim(), + avatar: '', + isAdmin: true, + createdAt: new Date().toISOString() + }; + d.users.push(user); + saveUsers(d); + res.json({ status: 'ok', token: issueToken(user.username), profile: safeProfile(user) }); +}); + +// Login. MFA is OPTIONAL: a code is only required for users who have +// chosen to enable it. Everyone else logs in with username + password. +app.post('/api/auth/login', (req, res) => { + const { username, password, code } = req.body || {}; + const d = loadUsers(); + const u = d.users.find(x => x.username === String(username || '').trim()); + if (!u || !verifyPassword(password, u.salt, u.hash)) { + return res.status(401).json({ error: 'Invalid username or password' }); + } + + // Only enforce a second factor if this account has MFA switched on. + if (u.mfaEnabled) { + if (!code) return res.json({ status: 'mfa' }); + if (!verifyTotp(u.mfaSecret, code)) return res.status(401).json({ status: 'mfa', error: 'Invalid code' }); + } + + return res.json({ status: 'ok', token: issueToken(u.username), profile: safeProfile(u) }); +}); + +app.post('/api/auth/logout', (req, res) => { + const h = req.headers.authorization || ''; + const t = h.startsWith('Bearer ') ? h.slice(7) : null; + if (t) sessions.delete(t); + res.json({ success: true }); +}); + +// Current profile (session restore) +app.get('/api/auth/me', requireAuth, (req, res) => { + res.json({ profile: safeProfile(req.user) }); +}); + +// Update own profile (avatar / display name) +app.post('/api/auth/profile', requireAuth, (req, res) => { + const d = loadUsers(); + const u = d.users.find(x => x.username === req.user.username); + if (!u) return res.status(404).json({ error: 'User not found' }); + if (typeof req.body.displayName === 'string') u.displayName = req.body.displayName.trim() || u.username; + if (typeof req.body.avatar === 'string') u.avatar = req.body.avatar; // '' clears it + saveUsers(d); + res.json({ profile: safeProfile(u) }); +}); + +// --- Optional MFA management (per-user, opt-in) --- + +// Begin enabling MFA: generate a fresh secret + QR. Does NOT enable it yet. +app.post('/api/auth/mfa/setup', requireAuth, (req, res) => { + const d = loadUsers(); + const u = d.users.find(x => x.username === req.user.username); + if (!u) return res.status(404).json({ error: 'User not found' }); + u.mfaSecret = genTotpSecret(); + u.mfaEnabled = false; + saveUsers(d); + const url = otpauthUrl(u.username, u.mfaSecret); + QRCode.toDataURL(url, (err, qr) => { + res.json({ otpauthUrl: url, qrDataUrl: err ? '' : qr, secret: u.mfaSecret }); + }); +}); + +// Confirm a code and switch MFA on for this account. +app.post('/api/auth/mfa/enable', requireAuth, (req, res) => { + const { code } = req.body || {}; + const d = loadUsers(); + const u = d.users.find(x => x.username === req.user.username); + if (!u) return res.status(404).json({ error: 'User not found' }); + if (!u.mfaSecret) return res.status(400).json({ error: 'Start MFA setup first' }); + if (!verifyTotp(u.mfaSecret, code)) return res.status(400).json({ error: 'Invalid code, try again' }); + u.mfaEnabled = true; + saveUsers(d); + res.json({ profile: safeProfile(u) }); +}); + +// Turn MFA back off (user is already authenticated). +app.post('/api/auth/mfa/disable', requireAuth, (req, res) => { + const d = loadUsers(); + const u = d.users.find(x => x.username === req.user.username); + if (!u) return res.status(404).json({ error: 'User not found' }); + u.mfaEnabled = false; + u.mfaSecret = ''; + saveUsers(d); + res.json({ profile: safeProfile(u) }); +}); + +// Change your own password (any authenticated user, admins included). +app.post('/api/auth/password', requireAuth, (req, res) => { + const { currentPassword, newPassword } = req.body || {}; + if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Current and new password required' }); + if (String(newPassword).length < 8) return res.status(400).json({ error: 'New password must be at least 8 characters' }); + const d = loadUsers(); + const u = d.users.find(x => x.username === req.user.username); + if (!u) return res.status(404).json({ error: 'User not found' }); + if (!verifyPassword(currentPassword, u.salt, u.hash)) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + const { salt, hash } = hashPassword(newPassword); + u.salt = salt; + u.hash = hash; + saveUsers(d); + // Keep the current session but sign this account out everywhere else. + const h = req.headers.authorization || ''; + const cur = h.startsWith('Bearer ') ? h.slice(7) : null; + for (const [tok, uname] of sessions) if (uname === u.username && tok !== cur) sessions.delete(tok); + res.json({ success: true }); +}); + +app.get('/api/auth/users', requireAdmin, (req, res) => { + const d = loadUsers(); + res.json({ users: d.users.map(safeProfile) }); +}); + +// Add a new user (they enroll their own MFA on first login). Only admins may +// add users, and they choose whether the new account is an admin or view-only. +app.post('/api/auth/users', requireAdmin, (req, res) => { + const { username, password, displayName, isAdmin } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); + if (String(password).length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + const d = loadUsers(); + if (d.users.some(u => u.username === String(username).trim())) { + return res.status(409).json({ error: 'Username already exists' }); + } + const { salt, hash } = hashPassword(password); + d.users.push({ + username: String(username).trim(), + salt, hash, + mfaSecret: '', + mfaEnabled: false, + displayName: (displayName || '').trim() || String(username).trim(), + avatar: '', + isAdmin: !!isAdmin, + createdAt: new Date().toISOString() + }); + saveUsers(d); + res.json({ success: true }); +}); + +// Delete a user (cannot delete yourself or the last remaining user) +app.delete('/api/auth/users/:username', requireAdmin, (req, res) => { + const target = req.params.username; + if (target === req.user.username) return res.status(400).json({ error: "You can't delete your own account" }); + const d = loadUsers(); + if (d.users.length <= 1) return res.status(400).json({ error: 'Cannot delete the last user' }); + const before = d.users.length; + d.users = d.users.filter(u => u.username !== target); + if (d.users.length === before) return res.status(404).json({ error: 'User not found' }); + saveUsers(d); + // Invalidate any active sessions for that user + for (const [tok, uname] of sessions) if (uname === target) sessions.delete(tok); + res.json({ success: true }); +}); + +// --------------------------------------------------------------------------- +// Contacts data routes (unchanged behaviour) +// --------------------------------------------------------------------------- + +app.get('/api/data', requireAuth, (req, res) => { + try { + const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); + res.json(data); + } catch (error) { + console.error('Error reading data:', error); + res.status(500).json({ error: 'Failed to read data' }); + } +}); + +app.post('/api/data', requireAdmin, (req, res) => { + try { + fs.writeFileSync(DATA_FILE, JSON.stringify(req.body, null, 2)); + res.json({ success: true }); + } catch (error) { + console.error('Error saving data:', error); + res.status(500).json({ error: 'Failed to save data' }); + } +}); + +app.post('/api/backup', requireAdmin, (req, res) => { + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupFile = path.join(__dirname, `backup_${timestamp}.json`); + const data = fs.readFileSync(DATA_FILE, 'utf8'); + fs.writeFileSync(backupFile, data); + res.json({ success: true, file: backupFile }); + } catch (error) { + console.error('Error creating backup:', error); + res.status(500).json({ error: 'Failed to create backup' }); + } +}); + +if (require.main === module) { + app.listen(PORT, () => { + const d = loadUsers(); + console.log('================================='); + console.log(`Server running at http://localhost:${PORT}`); + console.log(`Data file: ${DATA_FILE}`); + console.log(`Users file: ${USERS_FILE}`); + console.log(`Setup complete: ${d.users.length > 0} (${d.users.length} user(s))`); + console.log('================================='); + }); +} + +module.exports = app;