diff --git a/package-lock.json b/package-lock.json index 8a6fd5e..eefe19c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,19 @@ "name": "panel", "version": "0.1.0", "dependencies": { + "bcryptjs": "^3.0.2", "better-sqlite3": "^11.10.0", "date-fns": "^4.1.0", "leaflet": "^1.9.4", "next": "15.1.8", + "next-auth": "^4.24.11", "proj4": "^2.19.3", "proj4leaflet": "^1.0.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0", - "recharts": "^2.15.3" + "recharts": "^2.15.3", + "zod": "^3.25.67" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1912,6 +1915,14 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@petamoriken/float16": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", @@ -3396,6 +3407,14 @@ } ] }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", @@ -3911,6 +3930,14 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -7176,6 +7203,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7720,6 +7755,37 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.11", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", + "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -7801,6 +7867,11 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -7924,6 +7995,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", + "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7948,6 +8027,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8404,6 +8521,31 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/preact": { + "version": "10.26.9", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz", + "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/preact-render-to-string/node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -10323,6 +10465,14 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -10826,6 +10976,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.67", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zstddec": { "version": "0.2.0-alpha.3", "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz", @@ -12031,6 +12189,11 @@ "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", "dev": true }, + "@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==" + }, "@petamoriken/float16": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", @@ -13077,6 +13240,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==" + }, "better-sqlite3": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", @@ -13428,6 +13596,11 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, "create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -15738,6 +15911,11 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true }, + "jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -16128,6 +16306,22 @@ } } }, + "next-auth": { + "version": "4.24.11", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", + "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "requires": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + } + }, "node-abi": { "version": "3.75.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", @@ -16169,6 +16363,11 @@ "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", "dev": true }, + "oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -16253,6 +16452,11 @@ "es-object-atoms": "^1.0.0" } }, + "oidc-token-hash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", + "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -16270,6 +16474,37 @@ "mimic-fn": "^2.1.0" } }, + "openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "requires": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, "optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -16559,6 +16794,26 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "preact": { + "version": "10.26.9", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz", + "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==" + }, + "preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "requires": { + "pretty-format": "^3.8.0" + }, + "dependencies": { + "pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + } + } + }, "prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -17881,6 +18136,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, "v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -18240,6 +18500,11 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true }, + "zod": { + "version": "3.25.67", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", + "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==" + }, "zstddec": { "version": "0.2.0-alpha.3", "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz", diff --git a/package.json b/package.json index 0051bae..c998b7e 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "panel", "version": "0.1.0", "private": true, + "type": "module", "scripts": { "dev": "next dev", "build": "next build", @@ -14,16 +15,19 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { + "bcryptjs": "^3.0.2", "better-sqlite3": "^11.10.0", "date-fns": "^4.1.0", "leaflet": "^1.9.4", "next": "15.1.8", + "next-auth": "^4.24.11", "proj4": "^2.19.3", "proj4leaflet": "^1.0.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0", - "recharts": "^2.15.3" + "recharts": "^2.15.3", + "zod": "^3.25.67" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/scripts/create-admin.js b/scripts/create-admin.js new file mode 100644 index 0000000..3f487f4 --- /dev/null +++ b/scripts/create-admin.js @@ -0,0 +1,34 @@ +import { createUser } from "../src/lib/userManagement.js" +import initializeDatabase from "../src/lib/init-db.js" + +async function createInitialAdmin() { + try { + // Initialize database first + initializeDatabase() + + console.log("Creating initial admin user...") + + const adminUser = await createUser({ + name: "Administrator", + email: "admin@localhost", + password: "admin123456", // Change this in production! + role: "admin" + }) + + console.log("✅ Initial admin user created successfully!") + console.log("📧 Email: admin@localhost") + console.log("🔑 Password: admin123456") + console.log("âš ī¸ Please change the password after first login!") + console.log("👤 User ID:", adminUser.id) + + } catch (error) { + if (error.message.includes("already exists")) { + console.log("â„šī¸ Admin user already exists. Skipping creation.") + } else { + console.error("❌ Error creating admin user:", error.message) + process.exit(1) + } + } +} + +createInitialAdmin() diff --git a/src/app/api/auth/[...nextauth]/route.js b/src/app/api/auth/[...nextauth]/route.js new file mode 100644 index 0000000..59a8046 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.js @@ -0,0 +1,4 @@ +import NextAuth from "@/lib/auth" + +export const GET = NextAuth +export const POST = NextAuth diff --git a/src/app/api/projects/route.js b/src/app/api/projects/route.js index 10ebd54..857c391 100644 --- a/src/app/api/projects/route.js +++ b/src/app/api/projects/route.js @@ -1,11 +1,12 @@ import { getAllProjects, createProject } from "@/lib/queries/projects"; import initializeDatabase from "@/lib/init-db"; import { NextResponse } from "next/server"; +import { withReadAuth, withUserAuth } from "@/lib/middleware/auth"; // Make sure the DB is initialized before queries run initializeDatabase(); -export async function GET(req) { +async function getProjectsHandler(req) { const { searchParams } = new URL(req.url); const contractId = searchParams.get("contract_id"); @@ -13,8 +14,12 @@ export async function GET(req) { return NextResponse.json(projects); } -export async function POST(req) { +async function createProjectHandler(req) { const data = await req.json(); createProject(data); return NextResponse.json({ success: true }); } + +// Protected routes - require authentication +export const GET = withReadAuth(getProjectsHandler); +export const POST = withUserAuth(createProjectHandler); diff --git a/src/app/auth/error/page.js b/src/app/auth/error/page.js new file mode 100644 index 0000000..db8b24b --- /dev/null +++ b/src/app/auth/error/page.js @@ -0,0 +1,24 @@ +export default function AuthError() { + return ( +
+
+
+

+ Authentication Error +

+

+ There was a problem signing you in. Please try again. +

+
+ + Back to Sign In + +
+
+
+
+ ) +} diff --git a/src/app/auth/signin/page.js b/src/app/auth/signin/page.js new file mode 100644 index 0000000..c6e9745 --- /dev/null +++ b/src/app/auth/signin/page.js @@ -0,0 +1,127 @@ +"use client" + +import { useState } from "react" +import { signIn, getSession } from "next-auth/react" +import { useRouter } from "next/navigation" +import { useSearchParams } from "next/navigation" + +export default function SignIn() { + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + const router = useRouter() + const searchParams = useSearchParams() + const callbackUrl = searchParams.get("callbackUrl") || "/" + + const handleSubmit = async (e) => { + e.preventDefault() + setIsLoading(true) + setError("") + + try { + const result = await signIn("credentials", { + email, + password, + redirect: false, + }) + + if (result?.error) { + setError("Invalid email or password") + } else { + // Successful login + router.push(callbackUrl) + router.refresh() + } + } catch (error) { + setError("An error occurred. Please try again.") + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+

+ Sign in to your account +

+

+ Access the Project Management Panel +

+
+
+ {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ +
+ +
+ +
+
+

Default Admin Account:

+

Email: admin@localhost

+

Password: admin123456

+
+
+
+
+
+ ) +} diff --git a/src/app/layout.js b/src/app/layout.js index f90d3dd..e2fb948 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -1,6 +1,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import Navigation from "@/components/ui/Navigation"; +import { AuthProvider } from "@/components/auth/AuthProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -23,8 +24,10 @@ export default function RootLayout({ children }) { - -
{children}
+ + +
{children}
+
); diff --git a/src/app/page.js b/src/app/page.js index 52ae231..22372d2 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; import Link from "next/link"; import { Card, CardHeader, CardContent } from "@/components/ui/Card"; import Button from "@/components/ui/Button"; @@ -24,6 +25,7 @@ import { formatDate } from "@/lib/utils"; import TaskStatusChart from "@/components/ui/TaskStatusChart"; export default function Home() { + const { data: session, status } = useSession(); const [stats, setStats] = useState({ totalProjects: 0, activeProjects: 0, @@ -47,6 +49,12 @@ export default function Home() { const [loading, setLoading] = useState(true); useEffect(() => { + // Only fetch data if user is authenticated + if (!session) { + setLoading(false); + return; + } + const fetchDashboardData = async () => { try { // Fetch all data concurrently @@ -210,7 +218,7 @@ export default function Home() { }; fetchDashboardData(); - }, []); + }, [session]); const getProjectStatusColor = (status) => { switch (status) { @@ -257,10 +265,38 @@ export default function Home() { ); } + + // Show loading state while session is being fetched + if (status === "loading") { + return ; + } + + // Show sign-in prompt if not authenticated + if (!session) { + return ( + +
+

+ Welcome to Project Management Panel +

+

+ Please sign in to access the project management system. +

+ + Sign In + +
+
+ ); + } + return (
diff --git a/src/components/auth/AuthProvider.js b/src/components/auth/AuthProvider.js new file mode 100644 index 0000000..7581256 --- /dev/null +++ b/src/components/auth/AuthProvider.js @@ -0,0 +1,11 @@ +"use client" + +import { SessionProvider } from "next-auth/react" + +export function AuthProvider({ children }) { + return ( + + {children} + + ) +} diff --git a/src/components/ui/Navigation.js b/src/components/ui/Navigation.js index 0020eea..25dfdea 100644 --- a/src/components/ui/Navigation.js +++ b/src/components/ui/Navigation.js @@ -2,9 +2,12 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useSession, signOut } from "next-auth/react"; const Navigation = () => { const pathname = usePathname(); + const { data: session, status } = useSession(); + const isActive = (path) => { if (path === "/") return pathname === "/"; // Exact match for paths @@ -13,6 +16,7 @@ const Navigation = () => { if (pathname.startsWith(path + "/")) return true; return false; }; + const navItems = [ { href: "/", label: "Dashboard" }, { href: "/projects", label: "Projects" }, @@ -21,6 +25,20 @@ const Navigation = () => { { href: "/contracts", label: "Contracts" }, ]; + // Add admin-only items + if (session?.user?.role === 'admin') { + navItems.push({ href: "/admin/users", label: "User Management" }); + } + + const handleSignOut = async () => { + await signOut({ callbackUrl: "/auth/signin" }); + }; + + // Don't show navigation on auth pages + if (pathname.startsWith('/auth/')) { + return null; + } + return (
diff --git a/src/lib/auth.js b/src/lib/auth.js new file mode 100644 index 0000000..4fc44cd --- /dev/null +++ b/src/lib/auth.js @@ -0,0 +1,173 @@ +import NextAuth from "next-auth" +import CredentialsProvider from "next-auth/providers/credentials" +import db from "./db.js" +import bcrypt from "bcryptjs" +import { z } from "zod" +import { randomBytes } from "crypto" + +const loginSchema = z.object({ + email: z.string().email("Invalid email format"), + password: z.string().min(6, "Password must be at least 6 characters") +}) + +export const authOptions = { + providers: [ + CredentialsProvider({ + name: "credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" } + }, + async authorize(credentials, req) { + try { + // Validate input + const validatedFields = loginSchema.parse(credentials) + + // Check if user exists and is active + const user = db.prepare(` + SELECT id, email, name, password_hash, role, is_active, + failed_login_attempts, locked_until + FROM users + WHERE email = ? AND is_active = 1 + `).get(validatedFields.email) + + if (!user) { + throw new Error("Invalid credentials") + } + + // Check if account is locked + if (user.locked_until && new Date(user.locked_until) > new Date()) { + throw new Error("Account temporarily locked") + } + + // Verify password + const isValidPassword = await bcrypt.compare(validatedFields.password, user.password_hash) + + if (!isValidPassword) { + // Increment failed attempts + db.prepare(` + UPDATE users + SET failed_login_attempts = failed_login_attempts + 1, + locked_until = CASE + WHEN failed_login_attempts >= 4 + THEN datetime('now', '+15 minutes') + ELSE locked_until + END + WHERE id = ? + `).run(user.id) + + throw new Error("Invalid credentials") + } + + // Reset failed attempts and update last login + db.prepare(` + UPDATE users + SET failed_login_attempts = 0, + locked_until = NULL, + last_login = CURRENT_TIMESTAMP + WHERE id = ? + `).run(user.id) + + // Log successful login + logAuditEvent(user.id, 'LOGIN_SUCCESS', 'user', user.id, req) + + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role + } + } catch (error) { + console.error("Login error:", error) + return null + } + } + }) + ], + session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 days + updateAge: 24 * 60 * 60, // 24 hours + }, + callbacks: { + async jwt({ token, user, account }) { + if (user) { + token.role = user.role + token.userId = user.id + + // Create session in database + const sessionToken = randomBytes(32).toString('hex') + const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days + + db.prepare(` + INSERT INTO sessions (session_token, user_id, expires) + VALUES (?, ?, ?) + `).run(sessionToken, user.id, expires.toISOString()) + + token.sessionToken = sessionToken + } + return token + }, + async session({ session, token }) { + if (token) { + session.user.id = token.userId + session.user.role = token.role + + // Verify session is still valid in database + const dbSession = db.prepare(` + SELECT user_id FROM sessions + WHERE session_token = ? AND expires > datetime('now') + `).get(token.sessionToken) + + if (!dbSession) { + // Session expired or invalid + return null + } + } + return session + }, + async signIn({ user, account, profile, email, credentials }) { + return true + } + }, + pages: { + signIn: '/auth/signin', + signOut: '/auth/signout', + error: '/auth/error' + }, + events: { + async signOut({ token }) { + // Remove session from database + if (token?.sessionToken) { + db.prepare(` + DELETE FROM sessions WHERE session_token = ? + `).run(token.sessionToken) + + if (token.userId) { + logAuditEvent(token.userId, 'LOGOUT', 'user', token.userId) + } + } + } + } +} + +// Audit logging helper +function logAuditEvent(userId, action, resourceType, resourceId, req = null) { + try { + db.prepare(` + INSERT INTO audit_logs (user_id, action, resource_type, resource_id, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?) + `).run( + userId, + action, + resourceType, + resourceId, + req?.ip || req?.socket?.remoteAddress || 'unknown', + req?.headers?.['user-agent'] || 'unknown' + ) + } catch (error) { + console.error("Audit log error:", error) + } +} + +export default NextAuth(authOptions) diff --git a/src/lib/init-db.js b/src/lib/init-db.js index a72788b..10b7340 100644 --- a/src/lib/init-db.js +++ b/src/lib/init-db.js @@ -162,4 +162,52 @@ export default function initializeDatabase() { } catch (e) { // Column already exists, ignore error } + + // Authorization tables + db.exec(` + -- Users table + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT CHECK(role IN ('admin', 'project_manager', 'user', 'read_only')) DEFAULT 'user', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + is_active INTEGER DEFAULT 1, + last_login TEXT, + failed_login_attempts INTEGER DEFAULT 0, + locked_until TEXT + ); + + -- NextAuth.js sessions table (simplified for custom implementation) + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + session_token TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL, + expires TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + -- Audit log table for security tracking + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT, + action TEXT NOT NULL, + resource_type TEXT, + resource_id TEXT, + ip_address TEXT, + user_agent TEXT, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + details TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + -- Create indexes for performance + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(session_token); + CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp ON audit_logs(user_id, timestamp); + `); } diff --git a/src/lib/middleware/auth.js b/src/lib/middleware/auth.js new file mode 100644 index 0000000..fce229a --- /dev/null +++ b/src/lib/middleware/auth.js @@ -0,0 +1,116 @@ +import { getToken } from "next-auth/jwt" +import { NextResponse } from "next/server" +import db from "../db.js" + +// Role hierarchy for permission checking +const ROLE_HIERARCHY = { + 'admin': 4, + 'project_manager': 3, + 'user': 2, + 'read_only': 1 +} + +export function withAuth(handler, options = {}) { + return async (req, context) => { + try { + const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }) + + // Check if user is authenticated + if (!token?.userId) { + return NextResponse.json( + { error: "Authentication required" }, + { status: 401 } + ) + } + + // Check if user account is active + const user = db.prepare("SELECT is_active FROM users WHERE id = ?").get(token.userId) + if (!user?.is_active) { + return NextResponse.json( + { error: "Account deactivated" }, + { status: 403 } + ) + } + + // Check role-based permissions + if (options.requiredRole && !hasPermission(token.role, options.requiredRole)) { + logAuditEvent(token.userId, 'ACCESS_DENIED', options.resource || 'api', req.url) + return NextResponse.json( + { error: "Insufficient permissions" }, + { status: 403 } + ) + } + + // Check resource-specific permissions + if (options.checkResourceAccess) { + const hasAccess = await options.checkResourceAccess(token, context.params) + if (!hasAccess) { + return NextResponse.json( + { error: "Access denied to this resource" }, + { status: 403 } + ) + } + } + + // Add user info to request + req.user = { + id: token.userId, + email: token.email, + name: token.name, + role: token.role + } + + // Call the original handler + return await handler(req, context) + } catch (error) { + console.error("Auth middleware error:", error) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ) + } + } +} + +export function hasPermission(userRole, requiredRole) { + return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole] +} + +// Helper for read-only operations +export function withReadAuth(handler) { + return withAuth(handler, { requiredRole: 'read_only' }) +} + +// Helper for user-level operations +export function withUserAuth(handler) { + return withAuth(handler, { requiredRole: 'user' }) +} + +// Helper for project manager operations +export function withManagerAuth(handler) { + return withAuth(handler, { requiredRole: 'project_manager' }) +} + +// Helper for admin operations +export function withAdminAuth(handler) { + return withAuth(handler, { requiredRole: 'admin' }) +} + +// Audit logging helper +function logAuditEvent(userId, action, resourceType, resourceId, req = null) { + try { + db.prepare(` + INSERT INTO audit_logs (user_id, action, resource_type, resource_id, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?) + `).run( + userId, + action, + resourceType, + resourceId, + req?.ip || req?.socket?.remoteAddress || 'unknown', + req?.headers?.['user-agent'] || 'unknown' + ) + } catch (error) { + console.error("Audit log error:", error) + } +} diff --git a/src/lib/userManagement.js b/src/lib/userManagement.js new file mode 100644 index 0000000..d95bad3 --- /dev/null +++ b/src/lib/userManagement.js @@ -0,0 +1,125 @@ +import db from "./db.js" +import bcrypt from "bcryptjs" +import { randomBytes } from "crypto" + +// Create a new user +export async function createUser({ name, email, password, role = 'user' }) { + const existingUser = db.prepare("SELECT id FROM users WHERE email = ?").get(email) + if (existingUser) { + throw new Error("User with this email already exists") + } + + const passwordHash = await bcrypt.hash(password, 12) + const userId = randomBytes(16).toString('hex') + + const result = db.prepare(` + INSERT INTO users (id, name, email, password_hash, role) + VALUES (?, ?, ?, ?, ?) + `).run(userId, name, email, passwordHash, role) + + return { id: userId, name, email, role } +} + +// Get user by ID +export function getUserById(id) { + return db.prepare(` + SELECT id, name, email, role, created_at, last_login, is_active + FROM users WHERE id = ? + `).get(id) +} + +// Get user by email +export function getUserByEmail(email) { + return db.prepare(` + SELECT id, name, email, role, created_at, last_login, is_active + FROM users WHERE email = ? + `).get(email) +} + +// Get all users (for admin) +export function getAllUsers() { + return db.prepare(` + SELECT id, name, email, role, created_at, last_login, is_active, + failed_login_attempts, locked_until + FROM users + ORDER BY created_at DESC + `).all() +} + +// Update user role +export function updateUserRole(userId, role) { + const validRoles = ['admin', 'project_manager', 'user', 'read_only'] + if (!validRoles.includes(role)) { + throw new Error("Invalid role") + } + + const result = db.prepare(` + UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(role, userId) + + return result.changes > 0 +} + +// Activate/deactivate user +export function setUserActive(userId, isActive) { + const result = db.prepare(` + UPDATE users SET is_active = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(isActive ? 1 : 0, userId) + + return result.changes > 0 +} + +// Change user password +export async function changeUserPassword(userId, newPassword) { + const passwordHash = await bcrypt.hash(newPassword, 12) + + const result = db.prepare(` + UPDATE users + SET password_hash = ?, updated_at = CURRENT_TIMESTAMP, + failed_login_attempts = 0, locked_until = NULL + WHERE id = ? + `).run(passwordHash, userId) + + return result.changes > 0 +} + +// Clean up expired sessions +export function cleanupExpiredSessions() { + const result = db.prepare(` + DELETE FROM sessions WHERE expires < datetime('now') + `).run() + + return result.changes +} + +// Get user sessions +export function getUserSessions(userId) { + return db.prepare(` + SELECT id, session_token, expires, created_at + FROM sessions + WHERE user_id = ? AND expires > datetime('now') + ORDER BY created_at DESC + `).all(userId) +} + +// Revoke user session +export function revokeSession(sessionToken) { + const result = db.prepare(` + DELETE FROM sessions WHERE session_token = ? + `).run(sessionToken) + + return result.changes > 0 +} + +// Get audit logs for user +export function getUserAuditLogs(userId, limit = 50) { + return db.prepare(` + SELECT action, resource_type, resource_id, ip_address, timestamp, details + FROM audit_logs + WHERE user_id = ? + ORDER BY timestamp DESC + LIMIT ? + `).all(userId, limit) +} diff --git a/src/middleware.js b/src/middleware.js new file mode 100644 index 0000000..3245c42 --- /dev/null +++ b/src/middleware.js @@ -0,0 +1,48 @@ +import { withAuth } from "next-auth/middleware" + +export default withAuth( + function middleware(req) { + // Additional middleware logic can go here + }, + { + callbacks: { + authorized: ({ token, req }) => { + const { pathname } = req.nextUrl + + // Allow access to auth pages + if (pathname.startsWith('/auth/')) { + return true + } + + // Require authentication for all other pages + if (!token) { + return false + } + + // Check admin routes + if (pathname.startsWith('/admin/')) { + return token.role === 'admin' + } + + // Allow authenticated users to access other pages + return true + }, + }, + pages: { + signIn: '/auth/signin', + }, + } +) + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api/auth (NextAuth.js API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + '/((?!api/auth|_next/static|_next/image|favicon.ico).*)', + ], +}