diff --git a/package-lock.json b/package-lock.json
index c62c745..1cb44e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,13 @@
"version": "1.0.0",
"license": "GPL-3.0-only",
"dependencies": {
+ "bcryptjs": "^3.0.3",
"express": "^4.18.2",
+ "express-session": "^1.18.2",
+ "jsonwebtoken": "^9.0.3",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "passport-local": "^1.0.0",
"sax": "^1.4.3",
"xml2js": "^0.6.2"
},
@@ -100,6 +106,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -124,6 +139,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -270,6 +291,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -386,6 +416,25 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-session": {
+ "version": "1.18.2",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
+ "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.7",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
"node_modules/ffmpeg-static": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
@@ -617,6 +666,97 @@
"node": ">= 0.10"
}
},
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -725,6 +865,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
@@ -740,12 +889,64 @@
"node": ">= 0.8"
}
},
+ "node_modules/passport": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
+ "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "passport-strategy": "1.x.x",
+ "pause": "0.0.1",
+ "utils-merge": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jaredhanson"
+ }
+ },
+ "node_modules/passport-jwt": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
+ "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0",
+ "passport-strategy": "^1.0.0"
+ }
+ },
+ "node_modules/passport-local": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
+ "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==",
+ "dependencies": {
+ "passport-strategy": "1.x.x"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/passport-strategy": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
+ "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
+ "node_modules/pause": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
+ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
+ },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -784,6 +985,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -855,6 +1065,18 @@
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -1026,6 +1248,18 @@
"license": "MIT",
"optional": true
},
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "license": "MIT",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
diff --git a/package.json b/package.json
index 799d410..0614e56 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,13 @@
"dev": "node --watch server/index.js"
},
"dependencies": {
+ "bcryptjs": "^3.0.3",
"express": "^4.18.2",
+ "express-session": "^1.18.2",
+ "jsonwebtoken": "^9.0.3",
+ "passport": "^0.7.0",
+ "passport-jwt": "^4.0.1",
+ "passport-local": "^1.0.0",
"sax": "^1.4.3",
"xml2js": "^0.6.2"
},
diff --git a/public/css/main.css b/public/css/main.css
index f51b7bf..a1dff48 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -3062,4 +3062,73 @@ kbd {
.spin {
animation: spin 1s linear infinite;
-}
\ No newline at end of file
+}
+/* User Management Styles */
+.user-list-container {
+ margin: var(--space-lg) 0;
+ overflow-x: auto;
+}
+
+.user-table {
+ width: 100%;
+ border-collapse: collapse;
+ background: var(--color-bg-secondary);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+.user-table thead {
+ background: var(--color-bg-tertiary);
+}
+
+.user-table th {
+ padding: var(--space-md);
+ text-align: left;
+ font-weight: 600;
+ color: var(--color-text-primary);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.user-table td {
+ padding: var(--space-md);
+ color: var(--color-text-secondary);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.user-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.user-table tbody tr:hover {
+ background: var(--color-bg-hover);
+}
+
+.user-table .btn {
+ padding: var(--space-xs) var(--space-sm);
+ margin: 0 var(--space-xs);
+ font-size: 14px;
+}
+
+.add-user-section {
+ margin-top: var(--space-2xl);
+ padding: var(--space-lg);
+ background: var(--color-bg-secondary);
+ border-radius: var(--radius-md);
+}
+
+.user-form {
+ display: grid;
+ gap: var(--space-md);
+ max-width: 500px;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+}
+
+.form-group label {
+ margin-bottom: var(--space-xs);
+ color: var(--color-text-primary);
+ font-weight: 500;
+}
diff --git a/public/index.html b/public/index.html
index 1f59960..3558bd2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -280,6 +280,7 @@
+
@@ -475,6 +476,54 @@
+
+
+
+
+
User Management
+
Manage user accounts and permissions
+
+
+
+
+
+ | Username |
+ Role |
+ Created |
+ Actions |
+
+
+
+
+ | Loading users... |
+
+
+
+
+
+
+
+
diff --git a/public/js/api.js b/public/js/api.js
index dfaae9c..b99fb45 100644
--- a/public/js/api.js
+++ b/public/js/api.js
@@ -13,6 +13,12 @@ const API = {
'Content-Type': 'application/json'
}
};
+
+ // Add authentication token if available
+ const token = localStorage.getItem('authToken');
+ if (token) {
+ options.headers['Authorization'] = `Bearer ${token}`;
+ }
if (data) {
options.body = JSON.stringify(data);
@@ -30,6 +36,12 @@ const API = {
}
if (!response.ok) {
+ // If unauthorized, redirect to login
+ if (response.status === 401) {
+ localStorage.removeItem('authToken');
+ window.location.href = '/login.html';
+ return;
+ }
throw new Error(result.error || `Server responded with ${response.status}`);
}
@@ -120,6 +132,14 @@ const API = {
update: (data) => API.request('PUT', '/settings', data),
reset: () => API.request('DELETE', '/settings'),
getDefaults: () => API.request('GET', '/settings/defaults')
+ },
+
+ // Users (admin only)
+ users: {
+ getAll: () => API.request('GET', '/auth/users'),
+ create: (data) => API.request('POST', '/auth/users', data),
+ update: (id, data) => API.request('PUT', `/auth/users/${id}`, data),
+ delete: (id) => API.request('DELETE', `/auth/users/${id}`)
}
};
diff --git a/public/js/app.js b/public/js/app.js
index 4e3b0db..b4edda5 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -6,6 +6,7 @@ class App {
constructor() {
this.currentPage = 'home';
this.pages = {};
+ this.currentUser = null;
// Initialize components
this.player = new VideoPlayer();
@@ -24,6 +25,9 @@ class App {
}
async init() {
+ // Check authentication first
+ await this.checkAuth();
+
// Mobile menu toggle
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const navbarMenu = document.getElementById('navbar-menu');
@@ -110,6 +114,82 @@ class App {
console.log('NodeCast TV initialized');
}
+ async checkAuth() {
+ const token = localStorage.getItem('authToken');
+
+ if (!token) {
+ // No token, redirect to login
+ window.location.href = '/login.html';
+ return;
+ }
+
+ try {
+ // Verify token with server
+ const response = await fetch('/api/auth/me', {
+ headers: {
+ 'Authorization': `Bearer ${token}`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error('Invalid token');
+ }
+
+ this.currentUser = await response.json();
+
+ // Hide settings for viewers
+ if (this.currentUser.role === 'viewer') {
+ const settingsLink = document.querySelector('.nav-link[data-page="settings"]');
+ if (settingsLink) {
+ settingsLink.style.display = 'none';
+ }
+ }
+
+ // Add logout button to navbar
+ this.addLogoutButton();
+
+ } catch (err) {
+ console.error('Authentication error:', err);
+ localStorage.removeItem('authToken');
+ window.location.href = '/login.html';
+ }
+ }
+
+ addLogoutButton() {
+ const navbar = document.querySelector('.navbar-menu');
+ if (!navbar || document.getElementById('logout-btn')) return;
+
+ const logoutLink = document.createElement('a');
+ logoutLink.href = '#';
+ logoutLink.className = 'nav-link';
+ logoutLink.id = 'logout-btn';
+ logoutLink.innerHTML = `
+
+ Logout
+ `;
+
+ logoutLink.addEventListener('click', async (e) => {
+ e.preventDefault();
+
+ const token = localStorage.getItem('authToken');
+ if (token) {
+ await fetch('/api/auth/logout', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`
+ }
+ });
+ }
+
+ localStorage.removeItem('authToken');
+ window.location.href = '/login.html';
+ });
+
+ navbar.appendChild(logoutLink);
+ }
+
navigateTo(pageName) {
// Update nav
document.querySelectorAll('.nav-link').forEach(link => {
diff --git a/public/js/auth.js b/public/js/auth.js
new file mode 100644
index 0000000..fe25b7b
--- /dev/null
+++ b/public/js/auth.js
@@ -0,0 +1,144 @@
+/**
+ * Auth Manager - Frontend authentication state management
+ */
+
+const Auth = {
+ currentUser: null,
+
+ /**
+ * Initialize auth - check setup status and current user
+ */
+ async init() {
+ try {
+ // Check if setup is required
+ const setupStatus = await API.auth.checkSetup();
+ if (setupStatus.setupRequired) {
+ this.showSetup();
+ return false;
+ }
+
+ // Check if user is logged in
+ if (API.getToken()) {
+ try {
+ this.currentUser = await API.auth.me();
+ return true;
+ } catch (error) {
+ // Token invalid, clear it
+ console.log('Token invalid, showing login');
+ API.setToken(null);
+ this.showLogin();
+ return false;
+ }
+ } else {
+ this.showLogin();
+ return false;
+ }
+ } catch (error) {
+ console.error('Auth initialization failed:', error);
+ // On any error, show login (don't loop)
+ this.showLogin();
+ return false;
+ }
+ },
+
+ /**
+ * Show setup screen
+ */
+ showSetup() {
+ document.getElementById('setup-screen').classList.add('active');
+ document.getElementById('login-screen').classList.remove('active');
+ document.getElementById('app').classList.remove('active');
+ },
+
+ /**
+ * Show login screen
+ */
+ showLogin() {
+ document.getElementById('setup-screen').classList.remove('active');
+ document.getElementById('login-screen').classList.add('active');
+ document.getElementById('app').classList.remove('active');
+ },
+
+ /**
+ * Show main app
+ */
+ showApp() {
+ document.getElementById('setup-screen').classList.remove('active');
+ document.getElementById('login-screen').classList.remove('active');
+ document.getElementById('app').classList.add('active');
+
+ // Hide settings tab if viewer
+ if (!this.isAdmin()) {
+ const settingsLink = document.querySelector('[data-page="settings"]');
+ if (settingsLink) {
+ settingsLink.parentElement.style.display = 'none';
+ }
+ }
+ },
+
+ /**
+ * Setup initial admin user
+ */
+ async setup(username, password) {
+ try {
+ const result = await API.auth.setup(username, password);
+ API.setToken(result.token);
+ this.currentUser = result.user;
+ this.showApp();
+ return true;
+ } catch (error) {
+ throw error;
+ }
+ },
+
+ /**
+ * Login user
+ */
+ async login(username, password) {
+ try {
+ const result = await API.auth.login(username, password);
+ API.setToken(result.token);
+ this.currentUser = result.user;
+ this.showApp();
+ return true;
+ } catch (error) {
+ throw error;
+ }
+ },
+
+ /**
+ * Logout user
+ */
+ async logout() {
+ try {
+ await API.auth.logout();
+ } catch (error) {
+ console.error('Logout error:', error);
+ } finally {
+ API.setToken(null);
+ this.currentUser = null;
+ this.showLogin();
+ }
+ },
+
+ /**
+ * Check if current user is admin
+ */
+ isAdmin() {
+ return this.currentUser && this.currentUser.role === 'admin';
+ },
+
+ /**
+ * Check if current user is viewer
+ */
+ isViewer() {
+ return this.currentUser && this.currentUser.role === 'viewer';
+ },
+
+ /**
+ * Get current user
+ */
+ getCurrentUser() {
+ return this.currentUser;
+ }
+};
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js
index 1fafab2..d642a32 100644
--- a/public/js/components/VideoPlayer.js
+++ b/public/js/components/VideoPlayer.js
@@ -3,6 +3,11 @@
* Handles HLS video playback with custom controls
*/
+// Check if device is mobile
+function isMobile() {
+ return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+}
+
class VideoPlayer {
constructor() {
this.video = document.getElementById('video-player');
diff --git a/public/js/login.js b/public/js/login.js
new file mode 100644
index 0000000..a246d10
--- /dev/null
+++ b/public/js/login.js
@@ -0,0 +1,14 @@
+document.getElementById('login-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const username = document.getElementById('username').value;
+ const password = document.getElementById('password').value;
+
+ try {
+ const response = await API.request('POST', '/auth/login', { username, password });
+ localStorage.setItem('sessionToken', response.token);
+ window.location.href = '/';
+ } catch (err) {
+ document.getElementById('login-error').textContent = 'Login failed: ' + err.message;
+ }
+});
\ No newline at end of file
diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js
index b61e238..bb2f21c 100644
--- a/public/js/pages/Settings.js
+++ b/public/js/pages/Settings.js
@@ -19,6 +19,9 @@ class SettingsPage {
// Player settings
this.initPlayerSettings();
+
+ // User management (admin only)
+ this.initUserManagement();
}
initPlayerSettings() {
@@ -130,6 +133,99 @@ class SettingsPage {
}
}
+ initUserManagement() {
+ // User tab visibility is handled in show() method
+ // when currentUser is available
+
+ // Handle add user form
+ const addUserForm = document.getElementById('add-user-form');
+ if (addUserForm) {
+ addUserForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const username = document.getElementById('new-username').value;
+ const password = document.getElementById('new-password').value;
+ const role = document.getElementById('new-role').value;
+
+ try {
+ await API.users.create({ username, password, role });
+ alert('User created successfully!');
+ addUserForm.reset();
+ this.loadUsers();
+ } catch (err) {
+ alert('Error creating user: ' + err.message);
+ }
+ });
+ }
+ }
+
+ async loadUsers() {
+ const userList = document.getElementById('user-list');
+ if (!userList) return;
+
+ try {
+ const users = await API.users.getAll();
+
+ if (users.length === 0) {
+ userList.innerHTML = '| No users found |
';
+ return;
+ }
+
+ userList.innerHTML = users.map(user => `
+
+ | ${user.username} |
+ ${user.role} |
+ ${user.createdAt ? new Date(user.createdAt).toLocaleDateString() : 'N/A'} |
+
+
+
+ |
+
+ `).join('');
+ } catch (err) {
+ console.error('Error loading users:', err);
+ userList.innerHTML = '| Error loading users |
';
+ }
+ }
+
+ async editUser(userId) {
+ const username = prompt('Enter new username (leave blank to keep current):');
+ const password = prompt('Enter new password (leave blank to keep current):');
+ const role = prompt('Enter role (admin or viewer, leave blank to keep current):');
+
+ const updates = {};
+ if (username) updates.username = username;
+ if (password) updates.password = password;
+ if (role) updates.role = role;
+
+ if (Object.keys(updates).length === 0) {
+ alert('No changes made');
+ return;
+ }
+
+ try {
+ await API.users.update(userId, updates);
+ alert('User updated successfully!');
+ this.loadUsers();
+ } catch (err) {
+ alert('Error updating user: ' + err.message);
+ }
+ }
+
+ async deleteUser(userId, username) {
+ if (!confirm(`Are you sure you want to delete user "${username}"?`)) {
+ return;
+ }
+
+ try {
+ await API.users.delete(userId);
+ alert('User deleted successfully!');
+ this.loadUsers();
+ } catch (err) {
+ alert('Error deleting user: ' + err.message);
+ }
+ }
+
switchTab(tabName) {
this.tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
this.tabContents.forEach(c => c.classList.toggle('active', c.id === `tab-${tabName}`));
@@ -138,9 +234,22 @@ class SettingsPage {
if (tabName === 'content') {
this.app.sourceManager.loadContentSources();
}
+
+ // Load users when switching to users tab
+ if (tabName === 'users') {
+ this.loadUsers();
+ }
}
async show() {
+ // Show users tab for admin
+ if (this.app.currentUser && this.app.currentUser.role === 'admin') {
+ const usersTab = document.getElementById('users-tab');
+ if (usersTab) {
+ usersTab.style.display = 'block';
+ }
+ }
+
// Load sources when page is shown
await this.app.sourceManager.loadSources();
diff --git a/public/login.html b/public/login.html
new file mode 100644
index 0000000..58ed322
--- /dev/null
+++ b/public/login.html
@@ -0,0 +1,265 @@
+
+
+
+
+
+ Login - NodeCast TV
+
+
+
+
+
+
+
+
+
+
NodeCast TV
+
Sign in to continue
+
+
+
+
+ Welcome! Please create your admin account to get started.
+
+
+
+
+
+
+
+
+
diff --git a/server/auth.js b/server/auth.js
new file mode 100644
index 0000000..e87dc7a
--- /dev/null
+++ b/server/auth.js
@@ -0,0 +1,151 @@
+const bcrypt = require('bcryptjs');
+const jwt = require('jsonwebtoken');
+const passport = require('passport');
+const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
+const { Strategy: LocalStrategy } = require('passport-local');
+
+/**
+ * Authentication and Authorization Module
+ * Handles user authentication, session management, and role-based access control
+ * Using Passport.js with JWT tokens
+ */
+
+// JWT Secret - In production, use environment variable
+const JWT_SECRET = process.env.JWT_SECRET || 'nodecast-tv-secret-key-change-in-production';
+const JWT_EXPIRY = '24h';
+
+/**
+ * Hash password using bcrypt
+ */
+async function hashPassword(password) {
+ const salt = await bcrypt.genSalt(10);
+ return bcrypt.hash(password, salt);
+}
+
+/**
+ * Verify password against hash
+ */
+async function verifyPassword(password, hash) {
+ return bcrypt.compare(password, hash);
+}
+
+/**
+ * Generate JWT token
+ */
+function generateToken(user) {
+ return jwt.sign(
+ {
+ id: user.id,
+ username: user.username,
+ role: user.role
+ },
+ JWT_SECRET,
+ { expiresIn: JWT_EXPIRY }
+ );
+}
+
+/**
+ * Verify JWT token
+ */
+function verifyToken(token) {
+ try {
+ return jwt.verify(token, JWT_SECRET);
+ } catch (err) {
+ return null;
+ }
+}
+
+/**
+ * Configure Passport Local Strategy for username/password authentication
+ */
+function configureLocalStrategy(getUserByUsername, verifyUserPassword) {
+ passport.use(new LocalStrategy(
+ async (username, password, done) => {
+ try {
+ const user = await getUserByUsername(username);
+
+ if (!user) {
+ return done(null, false, { message: 'Invalid credentials' });
+ }
+
+ const isValid = await verifyUserPassword(password, user.passwordHash);
+
+ if (!isValid) {
+ return done(null, false, { message: 'Invalid credentials' });
+ }
+
+ return done(null, user);
+ } catch (err) {
+ return done(err);
+ }
+ }
+ ));
+}
+
+/**
+ * Configure Passport JWT Strategy for token-based authentication
+ */
+function configureJwtStrategy(getUserById) {
+ const options = {
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ secretOrKey: JWT_SECRET
+ };
+
+ passport.use(new JwtStrategy(options, async (payload, done) => {
+ try {
+ const user = await getUserById(payload.id);
+
+ if (!user) {
+ return done(null, false);
+ }
+
+ return done(null, {
+ id: user.id,
+ username: user.username,
+ role: user.role
+ });
+ } catch (err) {
+ return done(err, false);
+ }
+ }));
+}
+
+/**
+ * Middleware: Require authentication using Passport JWT
+ */
+const requireAuth = passport.authenticate('jwt', { session: false });
+
+/**
+ * Middleware: Require admin role
+ */
+function requireAdmin(req, res, next) {
+ if (!req.user || req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'Forbidden - Admin access required' });
+ }
+ next();
+}
+
+/**
+ * Middleware: Check for specific role
+ */
+function requireRole(role) {
+ return (req, res, next) => {
+ if (!req.user || req.user.role !== role) {
+ return res.status(403).json({ error: `Forbidden - ${role} access required` });
+ }
+ next();
+ };
+}
+
+module.exports = {
+ passport,
+ hashPassword,
+ verifyPassword,
+ generateToken,
+ verifyToken,
+ configureLocalStrategy,
+ configureJwtStrategy,
+ requireAuth,
+ requireAdmin,
+ requireRole
+};
diff --git a/server/db.js b/server/db.js
index 999e599..cb5765f 100644
--- a/server/db.js
+++ b/server/db.js
@@ -22,6 +22,7 @@ async function loadDb() {
hiddenItems: data.hiddenItems || [],
favorites: data.favorites || [],
settings: data.settings || getDefaultSettings(),
+ users: data.users || [],
nextId: data.nextId || 1
};
} catch (error) {
@@ -32,6 +33,7 @@ async function loadDb() {
hiddenItems: [],
favorites: [],
settings: getDefaultSettings(),
+ users: [],
nextId: 1
};
}
@@ -45,6 +47,7 @@ async function loadDb() {
hiddenItems: [],
favorites: [],
settings: getDefaultSettings(),
+ users: [],
nextId: 1
};
}
@@ -311,4 +314,104 @@ const settings = {
}
};
-module.exports = { sources, hiddenItems, favorites, settings, getDefaultSettings };
+// User operations
+const users = {
+ async getAll() {
+ const db = await loadDb();
+ return db.users || [];
+ },
+
+ async getById(id) {
+ const db = await loadDb();
+ return db.users?.find(u => u.id === parseInt(id));
+ },
+
+ async getByUsername(username) {
+ const db = await loadDb();
+ return db.users?.find(u => u.username === username);
+ },
+
+ async create(userData) {
+ const db = await loadDb();
+ if (!db.users) {
+ db.users = [];
+ }
+
+ // Check if username already exists
+ if (db.users.some(u => u.username === userData.username)) {
+ throw new Error('Username already exists');
+ }
+
+ const newUser = {
+ id: db.nextId++,
+ username: userData.username,
+ passwordHash: userData.passwordHash,
+ role: userData.role || 'viewer',
+ createdAt: new Date().toISOString()
+ };
+
+ db.users.push(newUser);
+ await saveDb(db);
+
+ // Return user without password hash
+ const { passwordHash, ...userWithoutPassword } = newUser;
+ return userWithoutPassword;
+ },
+
+ async update(id, updates) {
+ const db = await loadDb();
+ const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
+
+ if (userIndex === -1 || userIndex === undefined) {
+ throw new Error('User not found');
+ }
+
+ // Check if username is being changed and if it already exists
+ if (updates.username && updates.username !== db.users[userIndex].username) {
+ if (db.users.some(u => u.username === updates.username)) {
+ throw new Error('Username already exists');
+ }
+ }
+
+ db.users[userIndex] = {
+ ...db.users[userIndex],
+ ...updates,
+ updatedAt: new Date().toISOString()
+ };
+
+ await saveDb(db);
+
+ // Return user without password hash
+ const { passwordHash, ...userWithoutPassword } = db.users[userIndex];
+ return userWithoutPassword;
+ },
+
+ async delete(id) {
+ const db = await loadDb();
+ const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
+
+ if (userIndex === -1 || userIndex === undefined) {
+ throw new Error('User not found');
+ }
+
+ // Prevent deleting the last admin
+ const user = db.users[userIndex];
+ if (user.role === 'admin') {
+ const adminCount = db.users.filter(u => u.role === 'admin').length;
+ if (adminCount <= 1) {
+ throw new Error('Cannot delete the last admin user');
+ }
+ }
+
+ db.users.splice(userIndex, 1);
+ await saveDb(db);
+ return true;
+ },
+
+ async count() {
+ const db = await loadDb();
+ return db.users?.length || 0;
+ }
+};
+
+module.exports = { loadDb, saveDb, sources, hiddenItems, favorites, settings, users, getDefaultSettings };
diff --git a/server/index.js b/server/index.js
index c542ed8..e72218a 100644
--- a/server/index.js
+++ b/server/index.js
@@ -1,5 +1,6 @@
const express = require('express');
const path = require('path');
+const passport = require('passport');
// Initialize database
require('./db');
@@ -13,6 +14,10 @@ app.set('trust proxy', true);
// Middleware
app.use(express.json({ limit: '50mb' }));
+
+// Initialize Passport
+app.use(passport.initialize());
+
app.use(express.static(path.join(__dirname, '..', 'public')));
// FFMPEG Configuration (optional - for transcoding support)
@@ -49,6 +54,7 @@ function findFFmpeg() {
app.locals.ffmpegPath = findFFmpeg();
// API Routes
+app.use('/api/auth', require('./routes/auth'));
app.use('/api/sources', require('./routes/sources'));
app.use('/api/proxy', require('./routes/proxy'));
app.use('/api/channels', require('./routes/channels'));
diff --git a/server/routes/auth.js b/server/routes/auth.js
new file mode 100644
index 0000000..9d3ba96
--- /dev/null
+++ b/server/routes/auth.js
@@ -0,0 +1,261 @@
+const express = require('express');
+const router = express.Router();
+const db = require('../db');
+const auth = require('../auth');
+
+// Configure Passport strategies
+auth.configureLocalStrategy(
+ async (username) => await db.users.getByUsername(username),
+ async (password, hash) => await auth.verifyPassword(password, hash)
+);
+
+auth.configureJwtStrategy(
+ async (id) => await db.users.getById(id)
+);
+
+/**
+ * Check if initial setup is required
+ * GET /api/auth/setup-required
+ */
+router.get('/setup-required', async (req, res) => {
+ try {
+ const userCount = await db.users.count();
+ res.json({ setupRequired: userCount === 0 });
+ } catch (err) {
+ console.error('Error in /setup-required:', err);
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+/**
+ * Initial setup - Create admin user
+ * POST /api/auth/setup
+ */
+router.post('/setup', async (req, res) => {
+ try {
+ const userCount = await db.users.count();
+
+ // Check if setup already done
+ if (userCount > 0) {
+ return res.status(400).json({ error: 'Setup already completed' });
+ }
+
+ const { username, password } = req.body;
+
+ if (!username || !password) {
+ return res.status(400).json({ error: 'Username and password required' });
+ }
+
+ if (password.length < 6) {
+ return res.status(400).json({ error: 'Password must be at least 6 characters' });
+ }
+
+ // Create admin user
+ const passwordHash = await auth.hashPassword(password);
+ const adminUser = await db.users.create({
+ username,
+ passwordHash,
+ role: 'admin'
+ });
+
+ // Generate token for immediate login
+ const token = auth.generateToken(adminUser);
+
+ res.status(201).json({
+ message: 'Admin user created successfully',
+ token,
+ user: adminUser
+ });
+ } catch (err) {
+ console.error('Error in /setup:', err);
+ res.status(500).json({ error: err.message || 'Server error' });
+ }
+});
+
+/**
+ * Login with Passport Local Strategy
+ * POST /api/auth/login
+ */
+router.post('/login', (req, res, next) => {
+ auth.passport.authenticate('local', { session: false }, (err, user, info) => {
+ if (err) {
+ console.error('Login error:', err);
+ return res.status(500).json({ error: 'Server error' });
+ }
+
+ if (!user) {
+ return res.status(401).json({ error: info?.message || 'Invalid credentials' });
+ }
+
+ // Generate JWT token
+ const token = auth.generateToken(user);
+
+ res.json({
+ token,
+ user: {
+ id: user.id,
+ username: user.username,
+ role: user.role
+ }
+ });
+ })(req, res, next);
+});
+
+/**
+ * Logout (client-side handles token removal)
+ * POST /api/auth/logout
+ */
+router.post('/logout', (req, res) => {
+ // With JWT, logout is handled client-side by removing the token
+ // This endpoint exists for consistency and future server-side token blacklisting
+ res.json({ success: true, message: 'Logged out successfully' });
+});
+
+/**
+ * Get current user
+ * GET /api/auth/me
+ */
+router.get('/me', auth.requireAuth, async (req, res) => {
+ try {
+ const user = await db.users.getById(req.user.id);
+
+ if (!user) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ res.json({
+ id: user.id,
+ username: user.username,
+ role: user.role
+ });
+ } catch (err) {
+ console.error('Error in /me:', err);
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+/**
+ * Get all users (admin only)
+ * GET /api/auth/users
+ */
+router.get('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
+ try {
+ const allUsers = await db.users.getAll();
+
+ // Remove password hashes
+ const users = allUsers.map(u => {
+ const { passwordHash, ...userWithoutPassword } = u;
+ return userWithoutPassword;
+ });
+
+ res.json(users);
+ } catch (err) {
+ console.error('Error fetching users:', err);
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+/**
+ * Create a new user (admin only)
+ * POST /api/auth/users
+ */
+router.post('/users', auth.requireAuth, auth.requireAdmin, async (req, res) => {
+ try {
+ const { username, password, role } = req.body;
+
+ if (!username || !password || !role) {
+ return res.status(400).json({ error: 'Username, password, and role are required' });
+ }
+
+ if (password.length < 6) {
+ return res.status(400).json({ error: 'Password must be at least 6 characters' });
+ }
+
+ if (!['admin', 'viewer'].includes(role)) {
+ return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
+ }
+
+ const passwordHash = await auth.hashPassword(password);
+ const newUser = await db.users.create({
+ username,
+ passwordHash,
+ role
+ });
+
+ res.status(201).json(newUser);
+ } catch (err) {
+ console.error('Error creating user:', err);
+ res.status(500).json({ error: err.message || 'Server error' });
+ }
+});
+
+/**
+ * Update a user (admin only)
+ * PUT /api/auth/users/:id
+ */
+router.put('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { username, password, role } = req.body;
+
+ const updates = {};
+
+ if (username) {
+ updates.username = username;
+ }
+
+ if (password) {
+ if (password.length < 6) {
+ return res.status(400).json({ error: 'Password must be at least 6 characters' });
+ }
+ updates.passwordHash = await auth.hashPassword(password);
+ }
+
+ if (role) {
+ if (!['admin', 'viewer'].includes(role)) {
+ return res.status(400).json({ error: 'Role must be either "admin" or "viewer"' });
+ }
+
+ // Prevent removing admin role from the last admin
+ const user = await db.users.getById(id);
+ if (user && user.role === 'admin' && role !== 'admin') {
+ const allUsers = await db.users.getAll();
+ const adminCount = allUsers.filter(u => u.role === 'admin').length;
+ if (adminCount <= 1) {
+ return res.status(400).json({ error: 'Cannot remove admin role from the last admin user' });
+ }
+ }
+
+ updates.role = role;
+ }
+
+ const updatedUser = await db.users.update(id, updates);
+ res.json(updatedUser);
+ } catch (err) {
+ console.error('Error updating user:', err);
+ res.status(500).json({ error: err.message || 'Server error' });
+ }
+});
+
+/**
+ * Delete a user (admin only)
+ * DELETE /api/auth/users/:id
+ */
+router.delete('/users/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ // Prevent deleting yourself
+ if (parseInt(id) === req.user.id) {
+ return res.status(400).json({ error: 'Cannot delete your own account' });
+ }
+
+ await db.users.delete(id);
+ res.json({ success: true, message: 'User deleted successfully' });
+ } catch (err) {
+ console.error('Error deleting user:', err);
+ res.status(500).json({ error: err.message || 'Server error' });
+ }
+});
+
+module.exports = router;
diff --git a/server/routes/users.js b/server/routes/users.js
new file mode 100644
index 0000000..3393afb
--- /dev/null
+++ b/server/routes/users.js
@@ -0,0 +1,186 @@
+const express = require('express');
+const router = express.Router();
+const db = require('../db');
+const auth = require('../auth');
+
+/**
+ * Get all users (admin only)
+ * GET /api/users
+ */
+router.get('/', auth.requireAuth, auth.requireAdmin, async (req, res) => {
+ try {
+ const data = await db.loadDb();
+ const users = (data.users || []).map(u => ({
+ id: u.id,
+ username: u.username,
+ role: u.role,
+ createdAt: u.createdAt
+ }));
+ res.json(users);
+ } catch (err) {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+/**
+ * Create user (admin only)
+ * POST /api/users
+ */
+router.post('/', auth.requireAuth, auth.requireAdmin, async (req, res) => {
+ try {
+ const { username, password, role } = req.body;
+
+ if (!username || !password || !role) {
+ return res.status(400).json({ error: 'Username, password, and role required' });
+ }
+
+ if (password.length < 6) {
+ return res.status(400).json({ error: 'Password must be at least 6 characters' });
+ }
+
+ if (!['admin', 'viewer'].includes(role)) {
+ return res.status(400).json({ error: 'Role must be admin or viewer' });
+ }
+
+ const data = await db.loadDb();
+
+ // Check if username exists
+ if (data.users?.some(u => u.username === username)) {
+ return res.status(400).json({ error: 'Username already exists' });
+ }
+
+ // Create user
+ const passwordHash = await auth.hashPassword(password);
+ const newUser = {
+ id: data.nextUserId || (data.users?.length || 0) + 1,
+ username,
+ passwordHash,
+ role,
+ createdAt: new Date().toISOString()
+ };
+
+ data.users = data.users || [];
+ data.users.push(newUser);
+ data.nextUserId = newUser.id + 1;
+
+ await db.saveDb(data);
+
+ res.json({
+ id: newUser.id,
+ username: newUser.username,
+ role: newUser.role,
+ createdAt: newUser.createdAt
+ });
+ } catch (err) {
+ console.error('Create user error:', err);
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+/**
+ * Update user (admin only)
+ * PUT /api/users/:id
+ */
+router.put('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
+ try {
+ const userId = parseInt(req.params.id);
+ const { username, password, role } = req.body;
+
+ const data = await db.loadDb();
+ const userIndex = data.users?.findIndex(u => u.id === userId);
+
+ if (userIndex === -1 || userIndex === undefined) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ const user = data.users[userIndex];
+
+ // Update username if provided
+ if (username && username !== user.username) {
+ // Check if new username exists
+ if (data.users.some(u => u.username === username && u.id !== userId)) {
+ return res.status(400).json({ error: 'Username already exists' });
+ }
+ user.username = username;
+ }
+
+ // Update password if provided
+ if (password) {
+ if (password.length < 6) {
+ return res.status(400).json({ error: 'Password must be at least 6 characters' });
+ }
+ user.passwordHash = await auth.hashPassword(password);
+ }
+
+ // Update role if provided
+ if (role) {
+ if (!['admin', 'viewer'].includes(role)) {
+ return res.status(400).json({ error: 'Role must be admin or viewer' });
+ }
+
+ // Prevent removing last admin
+ if (user.role === 'admin' && role !== 'admin') {
+ const adminCount = data.users.filter(u => u.role === 'admin').length;
+ if (adminCount <= 1) {
+ return res.status(400).json({ error: 'Cannot remove last admin user' });
+ }
+ }
+
+ user.role = role;
+ }
+
+ await db.saveDb(data);
+
+ res.json({
+ id: user.id,
+ username: user.username,
+ role: user.role,
+ createdAt: user.createdAt
+ });
+ } catch (err) {
+ console.error('Update user error:', err);
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+/**
+ * Delete user (admin only)
+ * DELETE /api/users/:id
+ */
+router.delete('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
+ try {
+ const userId = parseInt(req.params.id);
+
+ const data = await db.loadDb();
+ const userIndex = data.users?.findIndex(u => u.id === userId);
+
+ if (userIndex === -1 || userIndex === undefined) {
+ return res.status(404).json({ error: 'User not found' });
+ }
+
+ const user = data.users[userIndex];
+
+ // Prevent deleting yourself
+ if (user.id === req.session.userId) {
+ return res.status(400).json({ error: 'Cannot delete your own account' });
+ }
+
+ // Prevent deleting last admin
+ if (user.role === 'admin') {
+ const adminCount = data.users.filter(u => u.role === 'admin').length;
+ if (adminCount <= 1) {
+ return res.status(400).json({ error: 'Cannot delete last admin user' });
+ }
+ }
+
+ data.users.splice(userIndex, 1);
+ await db.saveDb(data);
+
+ res.json({ success: true });
+ } catch (err) {
+ console.error('Delete user error:', err);
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+module.exports = router;