implement authentication and authorization
This commit is contained in:
Generated
+234
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -3063,3 +3063,72 @@ kbd {
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
<button class="tab active" data-tab="sources">Sources</button>
|
||||
<button class="tab" data-tab="player">Player</button>
|
||||
<button class="tab" data-tab="content">Manage Content</button>
|
||||
<button class="tab" data-tab="users" id="users-tab" style="display:none;">Users</button>
|
||||
</div>
|
||||
|
||||
<!-- Sources Tab -->
|
||||
@@ -475,6 +476,54 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab (Admin Only) -->
|
||||
<div id="tab-users" class="tab-content">
|
||||
<div class="settings-section">
|
||||
<h3>User Management</h3>
|
||||
<p class="hint">Manage user accounts and permissions</p>
|
||||
|
||||
<div class="user-list-container">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-list">
|
||||
<tr>
|
||||
<td colspan="4" class="hint">Loading users...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="add-user-section">
|
||||
<h4>Add New User</h4>
|
||||
<form id="add-user-form" class="user-form">
|
||||
<div class="form-group">
|
||||
<label for="new-username">Username</label>
|
||||
<input type="text" id="new-username" name="username" class="form-input" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-password">Password</label>
|
||||
<input type="password" id="new-password" name="password" class="form-input" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new-role">Role</label>
|
||||
<select id="new-role" name="role" class="form-input" required>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -14,6 +14,12 @@ const API = {
|
||||
}
|
||||
};
|
||||
|
||||
// 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}`)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<span class="nav-icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
|
||||
<path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/>
|
||||
</svg></span>
|
||||
<span>Logout</span>
|
||||
`;
|
||||
|
||||
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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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 = '<tr><td colspan="4" class="hint">No users found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
userList.innerHTML = users.map(user => `
|
||||
<tr>
|
||||
<td>${user.username}</td>
|
||||
<td><span class="badge badge-${user.role === 'admin' ? 'primary' : 'secondary'}">${user.role}</span></td>
|
||||
<td>${user.createdAt ? new Date(user.createdAt).toLocaleDateString() : 'N/A'}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-secondary" onclick="window.app.pages.settings.editUser(${user.id})">Edit</button>
|
||||
<button class="btn btn-sm btn-error" onclick="window.app.pages.settings.deleteUser(${user.id}, '${user.username}')">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (err) {
|
||||
console.error('Error loading users:', err);
|
||||
userList.innerHTML = '<tr><td colspan="4" class="hint">Error loading users</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - NodeCast TV</title>
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--color-bg-primary);
|
||||
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: var(--color-bg-secondary);
|
||||
padding: var(--space-2xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.login-logo .logo-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto var(--space-md);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.login-logo h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-logo .brand-accent {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.login-logo p {
|
||||
color: var(--color-text-secondary);
|
||||
margin: var(--space-sm) 0 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 15px;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
background: var(--color-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.btn-login:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--color-error);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.setup-message {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: var(--color-accent);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
border: 1px solid var(--color-accent-dim);
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.setup-message.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<svg class="logo-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8a2 2 0 0 0-2-2zm0 14H3V8h18v12zM9 10v8l7-4z" />
|
||||
</svg>
|
||||
<h1>NodeCast <span class="brand-accent">TV</span></h1>
|
||||
<p id="login-subtitle">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="error-message"></div>
|
||||
<div class="setup-message" id="setup-message">
|
||||
Welcome! Please create your admin account to get started.
|
||||
</div>
|
||||
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username" placeholder="Enter your username">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" minlength="6" placeholder="Enter your password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-login" id="submit-btn">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Check if setup is required
|
||||
let isSetupMode = false;
|
||||
|
||||
async function checkSetupRequired() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/setup-required');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.setupRequired) {
|
||||
isSetupMode = true;
|
||||
document.getElementById('login-subtitle').textContent = 'Create Admin Account';
|
||||
document.getElementById('submit-btn').textContent = 'Create Account';
|
||||
document.getElementById('setup-message').classList.add('show');
|
||||
document.getElementById('password').placeholder = 'Minimum 6 characters';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking setup status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
|
||||
// Clear previous errors
|
||||
errorMessage.classList.remove('show');
|
||||
errorMessage.textContent = '';
|
||||
|
||||
// Disable button
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Please wait...';
|
||||
|
||||
try {
|
||||
const endpoint = isSetupMode ? '/api/auth/setup' : '/api/auth/login';
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
// Store token
|
||||
localStorage.setItem('authToken', data.token);
|
||||
|
||||
// Redirect to main app
|
||||
window.location.href = '/';
|
||||
|
||||
} catch (err) {
|
||||
errorMessage.textContent = err.message;
|
||||
errorMessage.classList.add('show');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = isSetupMode ? 'Create Account' : 'Sign In';
|
||||
}
|
||||
});
|
||||
|
||||
// Check setup status on load
|
||||
checkSetupRequired();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+151
@@ -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
|
||||
};
|
||||
+104
-1
@@ -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 };
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user