Your commit message here
This commit is contained in:
267
package-lock.json
generated
267
package-lock.json
generated
@@ -8,16 +8,19 @@
|
|||||||
"name": "panel",
|
"name": "panel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "15.1.8",
|
"next": "15.1.8",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
"proj4": "^2.19.3",
|
"proj4": "^2.19.3",
|
||||||
"proj4leaflet": "^1.0.2",
|
"proj4leaflet": "^1.0.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "^2.15.3"
|
"recharts": "^2.15.3",
|
||||||
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -1912,6 +1915,14 @@
|
|||||||
"node": ">=12.4.0"
|
"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": {
|
"node_modules/@petamoriken/float16": {
|
||||||
"version": "3.9.2",
|
"version": "3.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
"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": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "11.10.0",
|
"version": "11.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||||
@@ -3911,6 +3930,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/create-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||||
@@ -7176,6 +7203,14 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"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": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -7801,6 +7867,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -7924,6 +7995,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -7948,6 +8027,44 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -8404,6 +8521,31 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"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": {
|
"node_modules/v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
"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"
|
"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": {
|
"node_modules/zstddec": {
|
||||||
"version": "0.2.0-alpha.3",
|
"version": "0.2.0-alpha.3",
|
||||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",
|
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",
|
||||||
@@ -12031,6 +12189,11 @@
|
|||||||
"integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
|
"integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
|
||||||
"dev": true
|
"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": {
|
"@petamoriken/float16": {
|
||||||
"version": "3.9.2",
|
"version": "3.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
"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": {
|
"better-sqlite3": {
|
||||||
"version": "11.10.0",
|
"version": "11.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||||
@@ -13428,6 +13596,11 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="
|
||||||
|
},
|
||||||
"create-jest": {
|
"create-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
"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==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true
|
"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": {
|
"js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"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": {
|
"node-abi": {
|
||||||
"version": "3.75.0",
|
"version": "3.75.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
|
||||||
@@ -16169,6 +16363,11 @@
|
|||||||
"integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
|
"integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
|
||||||
"dev": true
|
"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": {
|
"object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -16253,6 +16452,11 @@
|
|||||||
"es-object-atoms": "^1.0.0"
|
"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": {
|
"once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -16270,6 +16474,37 @@
|
|||||||
"mimic-fn": "^2.1.0"
|
"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": {
|
"optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -16559,6 +16794,26 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"dev": true
|
"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": {
|
"prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"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": {
|
"v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
"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==",
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"zod": {
|
||||||
|
"version": "3.25.67",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
|
||||||
|
"integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="
|
||||||
|
},
|
||||||
"zstddec": {
|
"zstddec": {
|
||||||
"version": "0.2.0-alpha.3",
|
"version": "0.2.0-alpha.3",
|
||||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",
|
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "panel",
|
"name": "panel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -14,16 +15,19 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "15.1.8",
|
"next": "15.1.8",
|
||||||
|
"next-auth": "^4.24.11",
|
||||||
"proj4": "^2.19.3",
|
"proj4": "^2.19.3",
|
||||||
"proj4leaflet": "^1.0.2",
|
"proj4leaflet": "^1.0.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "^2.15.3"
|
"recharts": "^2.15.3",
|
||||||
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
|||||||
34
scripts/create-admin.js
Normal file
34
scripts/create-admin.js
Normal file
@@ -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()
|
||||||
4
src/app/api/auth/[...nextauth]/route.js
Normal file
4
src/app/api/auth/[...nextauth]/route.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import NextAuth from "@/lib/auth"
|
||||||
|
|
||||||
|
export const GET = NextAuth
|
||||||
|
export const POST = NextAuth
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { getAllProjects, createProject } from "@/lib/queries/projects";
|
import { getAllProjects, createProject } from "@/lib/queries/projects";
|
||||||
import initializeDatabase from "@/lib/init-db";
|
import initializeDatabase from "@/lib/init-db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
// Make sure the DB is initialized before queries run
|
// Make sure the DB is initialized before queries run
|
||||||
initializeDatabase();
|
initializeDatabase();
|
||||||
|
|
||||||
export async function GET(req) {
|
async function getProjectsHandler(req) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const contractId = searchParams.get("contract_id");
|
const contractId = searchParams.get("contract_id");
|
||||||
|
|
||||||
@@ -13,8 +14,12 @@ export async function GET(req) {
|
|||||||
return NextResponse.json(projects);
|
return NextResponse.json(projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req) {
|
async function createProjectHandler(req) {
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
createProject(data);
|
createProject(data);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getProjectsHandler);
|
||||||
|
export const POST = withUserAuth(createProjectHandler);
|
||||||
|
|||||||
24
src/app/auth/error/page.js
Normal file
24
src/app/auth/error/page.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export default function AuthError() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
|
||||||
|
Authentication Error
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
There was a problem signing you in. Please try again.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<a
|
||||||
|
href="/auth/signin"
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Back to Sign In
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
src/app/auth/signin/page.js
Normal file
127
src/app/auth/signin/page.js
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Sign in to your account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Access the Project Management Panel
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="sr-only">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Email address"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Signing in...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Sign in"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
|
||||||
|
<p className="font-medium">Default Admin Account:</p>
|
||||||
|
<p>Email: admin@localhost</p>
|
||||||
|
<p>Password: admin123456</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Navigation from "@/components/ui/Navigation";
|
import Navigation from "@/components/ui/Navigation";
|
||||||
|
import { AuthProvider } from "@/components/auth/AuthProvider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -23,8 +24,10 @@ export default function RootLayout({ children }) {
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<AuthProvider>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
@@ -24,6 +25,7 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import TaskStatusChart from "@/components/ui/TaskStatusChart";
|
import TaskStatusChart from "@/components/ui/TaskStatusChart";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalProjects: 0,
|
totalProjects: 0,
|
||||||
activeProjects: 0,
|
activeProjects: 0,
|
||||||
@@ -47,6 +49,12 @@ export default function Home() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only fetch data if user is authenticated
|
||||||
|
if (!session) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchDashboardData = async () => {
|
const fetchDashboardData = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch all data concurrently
|
// Fetch all data concurrently
|
||||||
@@ -210,7 +218,7 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
}, []);
|
}, [session]);
|
||||||
|
|
||||||
const getProjectStatusColor = (status) => {
|
const getProjectStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -257,10 +265,38 @@ export default function Home() {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show loading state while session is being fetched
|
||||||
|
if (status === "loading") {
|
||||||
|
return <LoadingState message="Loading authentication..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show sign-in prompt if not authenticated
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-6">
|
||||||
|
Welcome to Project Management Panel
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 mb-8">
|
||||||
|
Please sign in to access the project management system.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/auth/signin"
|
||||||
|
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title={`Welcome back, ${session.user.name}!`}
|
||||||
description="Overview of your projects, contracts, and tasks"
|
description="Overview of your projects, contracts, and tasks"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
11
src/components/auth/AuthProvider.js
Normal file
11
src/components/auth/AuthProvider.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react"
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
|
||||||
const Navigation = () => {
|
const Navigation = () => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
const isActive = (path) => {
|
const isActive = (path) => {
|
||||||
if (path === "/") return pathname === "/";
|
if (path === "/") return pathname === "/";
|
||||||
// Exact match for paths
|
// Exact match for paths
|
||||||
@@ -13,6 +16,7 @@ const Navigation = () => {
|
|||||||
if (pathname.startsWith(path + "/")) return true;
|
if (pathname.startsWith(path + "/")) return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "Dashboard" },
|
{ href: "/", label: "Dashboard" },
|
||||||
{ href: "/projects", label: "Projects" },
|
{ href: "/projects", label: "Projects" },
|
||||||
@@ -21,6 +25,20 @@ const Navigation = () => {
|
|||||||
{ href: "/contracts", label: "Contracts" },
|
{ 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 (
|
return (
|
||||||
<nav className="bg-white border-b border-gray-200">
|
<nav className="bg-white border-b border-gray-200">
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
@@ -31,6 +49,11 @@ const Navigation = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{status === "loading" ? (
|
||||||
|
<div className="text-gray-500">Loading...</div>
|
||||||
|
) : session ? (
|
||||||
|
<>
|
||||||
<div className="flex space-x-8">
|
<div className="flex space-x-8">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -46,6 +69,32 @@ const Navigation = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4 ml-8 pl-8 border-l border-gray-200">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-gray-900">{session.user.name}</div>
|
||||||
|
<div className="text-gray-500 capitalize">{session.user.role?.replace('_', ' ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/auth/signin"
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
173
src/lib/auth.js
Normal file
173
src/lib/auth.js
Normal file
@@ -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)
|
||||||
@@ -162,4 +162,52 @@ export default function initializeDatabase() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Column already exists, ignore error
|
// 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);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
116
src/lib/middleware/auth.js
Normal file
116
src/lib/middleware/auth.js
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/lib/userManagement.js
Normal file
125
src/lib/userManagement.js
Normal file
@@ -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)
|
||||||
|
}
|
||||||
48
src/middleware.js
Normal file
48
src/middleware.js
Normal file
@@ -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).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user