From a0f64e4b11d4e2903a982b84445374a94cc80ba5 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 27 Feb 2026 15:47:29 +0900 Subject: [PATCH] =?UTF-8?q?style:=20=EA=B8=B0=EC=A1=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20ESLint/TypeScript=20=EC=97=90=EB=9F=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frontend: ESLint 에러 86건 수정 (unused-vars, set-state-in-effect, static-components 등) - backend: simulation.ts req.params 타입 단언 추가 Co-Authored-By: Claude Opus 4.6 --- backend/package-lock.json | 359 ++++++++++- backend/package.json | 10 +- backend/src/auth/authMiddleware.ts | 45 ++ backend/src/auth/authRouter.ts | 55 ++ backend/src/auth/authService.ts | 183 ++++++ backend/src/auth/jwtProvider.ts | 45 ++ backend/src/db/authDb.ts | 33 + backend/src/roles/roleRouter.ts | 59 ++ backend/src/roles/roleService.ts | 77 +++ backend/src/routes/simulation.ts | 2 +- backend/src/server.ts | 24 +- backend/src/settings/settingsRouter.ts | 49 ++ backend/src/settings/settingsService.ts | 69 +++ backend/src/users/userRouter.ts | 148 +++++ backend/src/users/userService.ts | 294 +++++++++ database/auth_init.sql | 288 +++++++++ docker-compose.yml | 1 + frontend/src/App.tsx | 30 + .../components/analysis/HNSRecalcModal.tsx | 1 + .../components/analysis/HNSScenarioView.tsx | 1 + .../components/analysis/HNSSubstanceView.tsx | 11 +- .../src/components/analysis/RecalcModal.tsx | 2 + frontend/src/components/auth/LoginPage.tsx | 346 +++++++++++ .../src/components/board/BoardWriteForm.tsx | 1 + .../src/components/layout/HNSLeftPanel.tsx | 1 + frontend/src/components/layout/LeftPanel.tsx | 1 + frontend/src/components/layout/TopBar.tsx | 48 +- frontend/src/components/map/MapView.tsx | 1 + .../reports/OilSpillReportTemplate.tsx | 25 +- frontend/src/components/views/AdminView.tsx | 572 ++++++++++++++---- frontend/src/components/views/AerialView.tsx | 87 +-- frontend/src/components/views/AssetsView.tsx | 1 + frontend/src/components/views/HNSView.tsx | 1 + .../src/components/views/IncidentsView.tsx | 6 +- .../src/components/views/OilSpillView.tsx | 1 + frontend/src/components/views/PreScatView.tsx | 2 - frontend/src/components/views/ReportsView.tsx | 13 +- frontend/src/components/views/RescueView.tsx | 6 +- frontend/src/components/views/WeatherView.tsx | 1 + .../components/weather/OceanCurrentLayer.tsx | 1 + .../weather/OceanForecastOverlay.tsx | 2 + .../components/weather/WindParticleLayer.tsx | 1 - frontend/src/hooks/useSubMenu.ts | 3 +- frontend/src/services/api.ts | 14 +- frontend/src/services/authApi.ts | 145 +++++ frontend/src/services/vesselService.ts | 12 +- frontend/src/services/weatherApi.ts | 4 +- frontend/src/services/weatherService.ts | 11 +- frontend/src/store/authStore.ts | 61 ++ frontend/src/utils/geo.ts | 1 + frontend/src/utils/sanitize.ts | 1 + 51 files changed, 2946 insertions(+), 209 deletions(-) create mode 100644 backend/src/auth/authMiddleware.ts create mode 100644 backend/src/auth/authRouter.ts create mode 100644 backend/src/auth/authService.ts create mode 100644 backend/src/auth/jwtProvider.ts create mode 100644 backend/src/db/authDb.ts create mode 100644 backend/src/roles/roleRouter.ts create mode 100644 backend/src/roles/roleService.ts create mode 100644 backend/src/settings/settingsRouter.ts create mode 100644 backend/src/settings/settingsService.ts create mode 100644 backend/src/users/userRouter.ts create mode 100644 backend/src/users/userService.ts create mode 100644 database/auth_init.sql create mode 100644 frontend/src/components/auth/LoginPage.tsx create mode 100644 frontend/src/services/authApi.ts create mode 100644 frontend/src/store/authStore.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 92eaaed..c7d90e9 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,18 +8,26 @@ "name": "backend", "version": "1.0.0", "dependencies": { + "bcrypt": "^6.0.0", "better-sqlite3": "^11.9.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "express": "^4.21.2", "express-rate-limit": "^8.2.1", - "helmet": "^8.1.0" + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "pg": "^8.19.0" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.12", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/helmet": "^0.0.48", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.13.5", + "@types/pg": "^8.16.0", "tsx": "^4.19.2", "typescript": "^5.7.3" } @@ -466,6 +474,16 @@ "node": ">=18" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -497,6 +515,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -549,6 +577,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", @@ -559,6 +605,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -633,6 +691,20 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/better-sqlite3": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", @@ -739,6 +811,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -813,6 +891,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -902,6 +999,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1359,6 +1465,91 @@ "node": ">= 0.10" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1488,6 +1679,26 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1545,6 +1756,134 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", + "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.12.0", + "pg-protocol": "^1.12.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz", + "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz", + "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1899,6 +2238,15 @@ "simple-concat": "^1.0.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2067,6 +2415,15 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } } } } diff --git a/backend/package.json b/backend/package.json index 3cf78fe..3d0f3d6 100755 --- a/backend/package.json +++ b/backend/package.json @@ -9,18 +9,26 @@ "db:seed": "tsx src/db/seed.ts" }, "dependencies": { + "bcrypt": "^6.0.0", "better-sqlite3": "^11.9.1", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "express": "^4.21.2", "express-rate-limit": "^8.2.1", - "helmet": "^8.1.0" + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "pg": "^8.19.0" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/better-sqlite3": "^7.6.12", + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/helmet": "^0.0.48", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.13.5", + "@types/pg": "^8.16.0", "tsx": "^4.19.2", "typescript": "^5.7.3" } diff --git a/backend/src/auth/authMiddleware.ts b/backend/src/auth/authMiddleware.ts new file mode 100644 index 0000000..4193f54 --- /dev/null +++ b/backend/src/auth/authMiddleware.ts @@ -0,0 +1,45 @@ +import type { Request, Response, NextFunction } from 'express' +import { verifyToken, getTokenFromCookie } from './jwtProvider.js' +import type { JwtPayload } from './jwtProvider.js' + +declare global { + namespace Express { + interface Request { + user?: JwtPayload + } + } +} + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + const token = getTokenFromCookie(req.cookies || {}) + + if (!token) { + res.status(401).json({ error: '인증이 필요합니다.' }) + return + } + + try { + const payload = verifyToken(token) + req.user = payload + next() + } catch { + res.status(401).json({ error: '인증 토큰이 유효하지 않습니다.' }) + } +} + +export function requireRole(...roles: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.user) { + res.status(401).json({ error: '인증이 필요합니다.' }) + return + } + + const hasRole = req.user.roles.some((r) => roles.includes(r)) + if (!hasRole) { + res.status(403).json({ error: '접근 권한이 없습니다.' }) + return + } + + next() + } +} diff --git a/backend/src/auth/authRouter.ts b/backend/src/auth/authRouter.ts new file mode 100644 index 0000000..b39af87 --- /dev/null +++ b/backend/src/auth/authRouter.ts @@ -0,0 +1,55 @@ +import { Router } from 'express' +import { login, getUserInfo, AuthError } from './authService.js' +import { clearTokenCookie } from './jwtProvider.js' +import { requireAuth } from './authMiddleware.js' + +const router = Router() + +// POST /api/auth/login +router.post('/login', async (req, res) => { + try { + const { account, password } = req.body + + if (!account || !password) { + res.status(400).json({ error: '아이디와 비밀번호를 입력해주세요.' }) + return + } + + const ipAddr = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || '' + const userAgent = req.headers['user-agent'] || '' + + const userInfo = await login(account, password, ipAddr, userAgent, res) + + res.json({ success: true, user: userInfo }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[auth] 로그인 오류:', err) + res.status(500).json({ error: '로그인 처리 중 오류가 발생했습니다.' }) + } +}) + +// POST /api/auth/logout +router.post('/logout', requireAuth, (_req, res) => { + clearTokenCookie(res) + res.json({ success: true }) +}) + +// GET /api/auth/me +router.get('/me', requireAuth, async (req, res) => { + try { + const userInfo = await getUserInfo(req.user!.sub) + res.json(userInfo) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[auth] 사용자 정보 조회 오류:', err) + res.status(500).json({ error: '사용자 정보 조회 중 오류가 발생했습니다.' }) + } +}) + +export default router diff --git a/backend/src/auth/authService.ts b/backend/src/auth/authService.ts new file mode 100644 index 0000000..30bfe14 --- /dev/null +++ b/backend/src/auth/authService.ts @@ -0,0 +1,183 @@ +import bcrypt from 'bcrypt' +import { authPool } from '../db/authDb.js' +import { signToken, setTokenCookie } from './jwtProvider.js' +import type { Response } from 'express' + +const MAX_FAIL_COUNT = 5 +const SALT_ROUNDS = 10 + +interface AuthUserRow { + user_id: string + user_acnt: string + pswd_hash: string + user_nm: string + rnkp_nm: string | null + org_sn: number | null + user_stts_cd: string + fail_cnt: number +} + +interface AuthUserInfo { + id: string + account: string + name: string + rank: string | null + org: { sn: number; name: string; abbr: string } | null + roles: string[] + permissions: string[] +} + +export async function login( + account: string, + password: string, + ipAddr: string, + userAgent: string, + res: Response +): Promise { + const userResult = await authPool.query( + `SELECT USER_ID as user_id, USER_ACNT as user_acnt, PSWD_HASH as pswd_hash, + USER_NM as user_nm, RNKP_NM as rnkp_nm, ORG_SN as org_sn, + USER_STTS_CD as user_stts_cd, FAIL_CNT as fail_cnt + FROM AUTH_USER WHERE USER_ACNT = $1`, + [account] + ) + + if (userResult.rows.length === 0) { + throw new AuthError('아이디 또는 비밀번호가 올바르지 않습니다.', 401) + } + + const user = userResult.rows[0] + + if (user.user_stts_cd === 'PENDING') { + throw new AuthError('계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.', 403) + } + + if (user.user_stts_cd === 'LOCKED') { + throw new AuthError('계정이 잠겨있습니다. 관리자에게 문의하세요.', 403) + } + + if (user.user_stts_cd === 'INACTIVE') { + throw new AuthError('비활성화된 계정입니다.', 403) + } + + if (user.user_stts_cd === 'REJECTED') { + throw new AuthError('가입이 거절된 계정입니다. 관리자에게 문의하세요.', 403) + } + + const passwordValid = await bcrypt.compare(password, user.pswd_hash) + + if (!passwordValid) { + const newFailCount = user.fail_cnt + 1 + const newStatus = newFailCount >= MAX_FAIL_COUNT ? 'LOCKED' : user.user_stts_cd + + await authPool.query( + 'UPDATE AUTH_USER SET FAIL_CNT = $1, USER_STTS_CD = $2, MDFCN_DTM = NOW() WHERE USER_ID = $3', + [newFailCount, newStatus, user.user_id] + ) + + await recordLoginHistory(user.user_id, ipAddr, userAgent, false) + + if (newStatus === 'LOCKED') { + throw new AuthError('로그인 실패 횟수 초과로 계정이 잠겼습니다.', 403) + } + + throw new AuthError('아이디 또는 비밀번호가 올바르지 않습니다.', 401) + } + + // 성공: FAIL_CNT 리셋, LAST_LOGIN_DTM 갱신 + await authPool.query( + 'UPDATE AUTH_USER SET FAIL_CNT = 0, LAST_LOGIN_DTM = NOW(), MDFCN_DTM = NOW() WHERE USER_ID = $1', + [user.user_id] + ) + + await recordLoginHistory(user.user_id, ipAddr, userAgent, true) + + const userInfo = await getUserInfo(user.user_id) + + const token = signToken({ + sub: userInfo.id, + acnt: userInfo.account, + name: userInfo.name, + roles: userInfo.roles, + }) + + setTokenCookie(res, token) + + return userInfo +} + +export async function getUserInfo(userId: string): Promise { + const userResult = await authPool.query( + `SELECT u.USER_ID as user_id, u.USER_ACNT as user_acnt, u.USER_NM as user_nm, + u.RNKP_NM as rnkp_nm, u.ORG_SN as org_sn, + o.ORG_NM as org_nm, o.ORG_ABBR_NM as org_abbr_nm + FROM AUTH_USER u + LEFT JOIN AUTH_ORG o ON u.ORG_SN = o.ORG_SN + WHERE u.USER_ID = $1`, + [userId] + ) + + if (userResult.rows.length === 0) { + throw new AuthError('사용자를 찾을 수 없습니다.', 404) + } + + const row = userResult.rows[0] + + // 역할 조회 + const rolesResult = await authPool.query( + `SELECT r.ROLE_CD as role_cd + FROM AUTH_USER_ROLE ur + JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN + WHERE ur.USER_ID = $1`, + [userId] + ) + + const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd) + + // 권한 조회 (역할 기반) + const permsResult = await authPool.query( + `SELECT DISTINCT p.RSRC_CD as rsrc_cd + FROM AUTH_PERM p + JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN + WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`, + [userId] + ) + + const permissions = permsResult.rows.map((p: { rsrc_cd: string }) => p.rsrc_cd) + + return { + id: row.user_id, + account: row.user_acnt, + name: row.user_nm, + rank: row.rnkp_nm, + org: row.org_sn ? { sn: row.org_sn, name: row.org_nm, abbr: row.org_abbr_nm } : null, + roles, + permissions, + } +} + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS) +} + +async function recordLoginHistory( + userId: string, + ipAddr: string, + userAgent: string, + success: boolean +): Promise { + await authPool.query( + `INSERT INTO AUTH_LOGIN_HIST (USER_ID, IP_ADDR, USER_AGENT, SUCCESS_YN) + VALUES ($1, $2, $3, $4)`, + [userId, ipAddr, userAgent?.substring(0, 500), success ? 'Y' : 'N'] + ) +} + +export class AuthError extends Error { + status: number + constructor(message: string, status: number) { + super(message) + this.status = status + this.name = 'AuthError' + } +} diff --git a/backend/src/auth/jwtProvider.ts b/backend/src/auth/jwtProvider.ts new file mode 100644 index 0000000..eb0db1c --- /dev/null +++ b/backend/src/auth/jwtProvider.ts @@ -0,0 +1,45 @@ +import jwt from 'jsonwebtoken' +import type { Response } from 'express' + +const JWT_SECRET = process.env.JWT_SECRET || 'wing-jwt-secret-change-in-production' +const COOKIE_NAME = 'WING_SESSION' +const TOKEN_EXPIRY = '24h' +const COOKIE_MAX_AGE = 24 * 60 * 60 * 1000 + +export interface JwtPayload { + sub: string + acnt: string + name: string + roles: string[] +} + +export function signToken(payload: JwtPayload): string { + return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }) +} + +export function verifyToken(token: string): JwtPayload { + return jwt.verify(token, JWT_SECRET) as JwtPayload +} + +export function setTokenCookie(res: Response, token: string): void { + res.cookie(COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: COOKIE_MAX_AGE, + path: '/', + }) +} + +export function clearTokenCookie(res: Response): void { + res.clearCookie(COOKIE_NAME, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + }) +} + +export function getTokenFromCookie(cookies: Record): string | null { + return cookies[COOKIE_NAME] || null +} diff --git a/backend/src/db/authDb.ts b/backend/src/db/authDb.ts new file mode 100644 index 0000000..ac8b352 --- /dev/null +++ b/backend/src/db/authDb.ts @@ -0,0 +1,33 @@ +import pg from 'pg' + +const { Pool } = pg + +const authPool = new Pool({ + host: process.env.AUTH_DB_HOST || 'localhost', + port: Number(process.env.AUTH_DB_PORT) || 5432, + database: process.env.AUTH_DB_NAME || 'wing_auth', + user: process.env.AUTH_DB_USER || 'wing_auth', + password: process.env.AUTH_DB_PASSWORD || 'WingAuth!2026', + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, +}) + +authPool.on('error', (err) => { + console.error('[authDb] 예기치 않은 연결 오류:', err.message) +}) + +export async function testAuthDbConnection(): Promise { + try { + const client = await authPool.connect() + await client.query('SELECT 1') + client.release() + console.log('[authDb] wing_auth 데이터베이스 연결 성공') + return true + } catch (err) { + console.warn('[authDb] wing_auth 데이터베이스 연결 실패:', (err as Error).message) + return false + } +} + +export { authPool } diff --git a/backend/src/roles/roleRouter.ts b/backend/src/roles/roleRouter.ts new file mode 100644 index 0000000..938a18b --- /dev/null +++ b/backend/src/roles/roleRouter.ts @@ -0,0 +1,59 @@ +import { Router } from 'express' +import { requireAuth, requireRole } from '../auth/authMiddleware.js' +import { listRolesWithPermissions, updatePermissions, updateRoleDefault } from './roleService.js' + +const router = Router() + +router.use(requireAuth) +router.use(requireRole('ADMIN')) + +// GET /api/roles +router.get('/', async (_req, res) => { + try { + const roles = await listRolesWithPermissions() + res.json(roles) + } catch (err) { + console.error('[roles] 목록 조회 오류:', err) + res.status(500).json({ error: '역할 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/roles/:id/permissions +router.put('/:id/permissions', async (req, res) => { + try { + const roleSn = Number(req.params.id) + const { permissions } = req.body + + if (!Array.isArray(permissions)) { + res.status(400).json({ error: '권한 목록이 필요합니다.' }) + return + } + + await updatePermissions(roleSn, permissions) + res.json({ success: true }) + } catch (err) { + console.error('[roles] 권한 수정 오류:', err) + res.status(500).json({ error: '권한 수정 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/roles/:id/default — 기본 역할 토글 +router.put('/:id/default', async (req, res) => { + try { + const roleSn = Number(req.params.id) + const { isDefault } = req.body + + if (typeof isDefault !== 'boolean') { + res.status(400).json({ error: 'isDefault 값이 필요합니다.' }) + return + } + + await updateRoleDefault(roleSn, isDefault) + res.json({ success: true }) + } catch (err) { + console.error('[roles] 기본 역할 변경 오류:', err) + res.status(500).json({ error: '기본 역할 변경 중 오류가 발생했습니다.' }) + } +}) + +export default router diff --git a/backend/src/roles/roleService.ts b/backend/src/roles/roleService.ts new file mode 100644 index 0000000..80de5a0 --- /dev/null +++ b/backend/src/roles/roleService.ts @@ -0,0 +1,77 @@ +import { authPool } from '../db/authDb.js' + +interface RoleWithPermissions { + sn: number + code: string + name: string + description: string | null + isDefault: boolean + permissions: Array<{ + sn: number + resourceCode: string + granted: boolean + }> +} + +export async function listRolesWithPermissions(): Promise { + const rolesResult = await authPool.query( + `SELECT ROLE_SN as sn, ROLE_CD as code, ROLE_NM as name, ROLE_DC as description, DFLT_YN as is_default + FROM AUTH_ROLE ORDER BY ROLE_SN` + ) + + const roles: RoleWithPermissions[] = [] + + for (const row of rolesResult.rows) { + const permsResult = await authPool.query( + `SELECT PERM_SN as sn, RSRC_CD as resource_code, GRANT_YN as granted + FROM AUTH_PERM WHERE ROLE_SN = $1 ORDER BY RSRC_CD`, + [row.sn] + ) + + roles.push({ + sn: row.sn, + code: row.code, + name: row.name, + description: row.description, + isDefault: row.is_default === 'Y', + permissions: permsResult.rows.map((p: { sn: number; resource_code: string; granted: string }) => ({ + sn: p.sn, + resourceCode: p.resource_code, + granted: p.granted === 'Y', + })), + }) + } + + return roles +} + +export async function updateRoleDefault(roleSn: number, isDefault: boolean): Promise { + await authPool.query( + 'UPDATE AUTH_ROLE SET DFLT_YN = $1 WHERE ROLE_SN = $2', + [isDefault ? 'Y' : 'N', roleSn] + ) +} + +export async function updatePermissions( + roleSn: number, + permissions: Array<{ resourceCode: string; granted: boolean }> +): Promise { + for (const perm of permissions) { + const existing = await authPool.query( + 'SELECT PERM_SN FROM AUTH_PERM WHERE ROLE_SN = $1 AND RSRC_CD = $2', + [roleSn, perm.resourceCode] + ) + + if (existing.rows.length > 0) { + await authPool.query( + 'UPDATE AUTH_PERM SET GRANT_YN = $1 WHERE ROLE_SN = $2 AND RSRC_CD = $3', + [perm.granted ? 'Y' : 'N', roleSn, perm.resourceCode] + ) + } else { + await authPool.query( + 'INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES ($1, $2, $3)', + [roleSn, perm.resourceCode, perm.granted ? 'Y' : 'N'] + ) + } + } +} diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index c319204..98bcf84 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -209,7 +209,7 @@ function generateDemoTrajectory( * 시뮬레이션 작업 상태 확인 */ router.get('/status/:jobId', async (req: Request, res: Response) => { - const { jobId } = req.params + const jobId = req.params.jobId as string // jobId 형식 검증 (영숫자, 하이픈만 허용) if (!jobId || !/^[a-zA-Z0-9-]+$/.test(jobId) || jobId.length > 50) { diff --git a/backend/src/server.ts b/backend/src/server.ts index 38391e9..8ebbeea 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -2,9 +2,15 @@ import express from 'express' import cors from 'cors' import helmet from 'helmet' import rateLimit from 'express-rate-limit' +import cookieParser from 'cookie-parser' import { initDatabase } from './db/database.js' +import { testAuthDbConnection } from './db/authDb.js' import layersRouter from './routes/layers.js' import simulationRouter from './routes/simulation.js' +import authRouter from './auth/authRouter.js' +import userRouter from './users/userRouter.js' +import roleRouter from './roles/roleRouter.js' +import settingsRouter from './settings/settingsRouter.js' import { sanitizeBody, sanitizeQuery, @@ -91,11 +97,14 @@ const simulationLimiter = rateLimit({ app.use(generalLimiter) -// 5. JSON 본문 파서 (크기 제한 적용) +// 5. 쿠키 파서 (JWT 인증 쿠키 처리) +app.use(cookieParser()) + +// 6. JSON 본문 파서 (크기 제한 적용) app.use(express.json({ limit: BODY_SIZE_LIMIT })) app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT })) -// 6. 입력값 살균 미들웨어 +// 7. 입력값 살균 미들웨어 app.use(sanitizeBody) app.use(sanitizeQuery) @@ -117,7 +126,13 @@ app.get('/', (_req, res) => { }) }) -// API 라우트 +// API 라우트 — 인증 +app.use('/api/auth', authRouter) +app.use('/api/users', userRouter) +app.use('/api/roles', roleRouter) +app.use('/api/settings', settingsRouter) + +// API 라우트 — 업무 app.use('/api/layers', layersRouter) app.use('/api/simulation', simulationLimiter, simulationRouter) @@ -152,6 +167,7 @@ app.use((err: Error, _req: express.Request, res: express.Response, _next: expres // ============================================================ // 서버 시작 // ============================================================ -app.listen(PORT, () => { +app.listen(PORT, async () => { console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`) + await testAuthDbConnection() }) diff --git a/backend/src/settings/settingsRouter.ts b/backend/src/settings/settingsRouter.ts new file mode 100644 index 0000000..f87f08c --- /dev/null +++ b/backend/src/settings/settingsRouter.ts @@ -0,0 +1,49 @@ +import { Router } from 'express' +import { requireAuth, requireRole } from '../auth/authMiddleware.js' +import { + getRegistrationSettings, + updateRegistrationSettings, + getAllSettings, +} from './settingsService.js' + +const router = Router() + +router.use(requireAuth) +router.use(requireRole('ADMIN')) + +// GET /api/settings — 전체 설정 조회 +router.get('/', async (_req, res) => { + try { + const settings = await getAllSettings() + res.json(settings) + } catch (err) { + console.error('[settings] 조회 오류:', err) + res.status(500).json({ error: '설정 조회 중 오류가 발생했습니다.' }) + } +}) + +// GET /api/settings/registration — 가입 설정 조회 +router.get('/registration', async (_req, res) => { + try { + const settings = await getRegistrationSettings() + res.json(settings) + } catch (err) { + console.error('[settings] 가입 설정 조회 오류:', err) + res.status(500).json({ error: '가입 설정 조회 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/settings/registration — 가입 설정 수정 +router.put('/registration', async (req, res) => { + try { + const { autoApprove, defaultRole } = req.body + await updateRegistrationSettings({ autoApprove, defaultRole }) + const updated = await getRegistrationSettings() + res.json(updated) + } catch (err) { + console.error('[settings] 가입 설정 수정 오류:', err) + res.status(500).json({ error: '가입 설정 수정 중 오류가 발생했습니다.' }) + } +}) + +export default router diff --git a/backend/src/settings/settingsService.ts b/backend/src/settings/settingsService.ts new file mode 100644 index 0000000..719dfb4 --- /dev/null +++ b/backend/src/settings/settingsService.ts @@ -0,0 +1,69 @@ +import { authPool } from '../db/authDb.js' + +interface SettingRow { + setting_key: string + setting_val: string + setting_dc: string | null + mdfcn_dtm: string +} + +export interface SettingItem { + key: string + value: string + description: string | null +} + +export async function getSetting(key: string): Promise { + const result = await authPool.query( + 'SELECT SETTING_VAL FROM AUTH_SETTING WHERE SETTING_KEY = $1', + [key] + ) + return result.rows.length > 0 ? result.rows[0].setting_val : null +} + +export async function getSettingBoolean(key: string, defaultValue = false): Promise { + const val = await getSetting(key) + if (val === null) return defaultValue + return val === 'true' +} + +export async function setSetting(key: string, value: string): Promise { + await authPool.query( + `INSERT INTO AUTH_SETTING (SETTING_KEY, SETTING_VAL, MDFCN_DTM) + VALUES ($1, $2, NOW()) + ON CONFLICT (SETTING_KEY) DO UPDATE SET SETTING_VAL = $2, MDFCN_DTM = NOW()`, + [key, value] + ) +} + +export async function getRegistrationSettings(): Promise<{ + autoApprove: boolean + defaultRole: boolean +}> { + const autoApprove = await getSettingBoolean('registration.auto-approve', true) + const defaultRole = await getSettingBoolean('registration.default-role', true) + return { autoApprove, defaultRole } +} + +export async function updateRegistrationSettings(settings: { + autoApprove?: boolean + defaultRole?: boolean +}): Promise { + if (settings.autoApprove !== undefined) { + await setSetting('registration.auto-approve', String(settings.autoApprove)) + } + if (settings.defaultRole !== undefined) { + await setSetting('registration.default-role', String(settings.defaultRole)) + } +} + +export async function getAllSettings(): Promise { + const result = await authPool.query( + 'SELECT SETTING_KEY, SETTING_VAL, SETTING_DC FROM AUTH_SETTING ORDER BY SETTING_KEY' + ) + return result.rows.map(row => ({ + key: row.setting_key, + value: row.setting_val, + description: row.setting_dc, + })) +} diff --git a/backend/src/users/userRouter.ts b/backend/src/users/userRouter.ts new file mode 100644 index 0000000..0fa898e --- /dev/null +++ b/backend/src/users/userRouter.ts @@ -0,0 +1,148 @@ +import { Router } from 'express' +import { requireAuth, requireRole } from '../auth/authMiddleware.js' +import { AuthError } from '../auth/authService.js' +import { + listUsers, + getUser, + createUser, + updateUser, + changePassword, + assignRoles, + approveUser, + rejectUser, +} from './userService.js' + +const router = Router() + +router.use(requireAuth) +router.use(requireRole('ADMIN')) + +// GET /api/users +router.get('/', async (req, res) => { + try { + const search = req.query.search as string | undefined + const status = req.query.status as string | undefined + const users = await listUsers(search, status) + res.json(users) + } catch (err) { + console.error('[users] 목록 조회 오류:', err) + res.status(500).json({ error: '사용자 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// GET /api/users/:id +router.get('/:id', async (req, res) => { + try { + const user = await getUser(req.params.id) + res.json(user) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[users] 상세 조회 오류:', err) + res.status(500).json({ error: '사용자 조회 중 오류가 발생했습니다.' }) + } +}) + +// POST /api/users +router.post('/', async (req, res) => { + try { + const { account, password, name, rank, orgSn, roleSns } = req.body + + if (!account || !password || !name) { + res.status(400).json({ error: '계정, 비밀번호, 이름은 필수입니다.' }) + return + } + + const result = await createUser({ account, password, name, rank, orgSn, roleSns }) + res.status(201).json(result) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[users] 생성 오류:', err) + res.status(500).json({ error: '사용자 생성 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/users/:id +router.put('/:id', async (req, res) => { + try { + const { name, rank, orgSn, status } = req.body + await updateUser(req.params.id, { name, rank, orgSn, status }) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[users] 수정 오류:', err) + res.status(500).json({ error: '사용자 수정 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/users/:id/password +router.put('/:id/password', async (req, res) => { + try { + const { password } = req.body + if (!password || password.length < 4) { + res.status(400).json({ error: '비밀번호는 4자 이상이어야 합니다.' }) + return + } + await changePassword(req.params.id, password) + res.json({ success: true }) + } catch (err) { + console.error('[users] 비밀번호 변경 오류:', err) + res.status(500).json({ error: '비밀번호 변경 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/users/:id/approve — 사용자 승인 +router.put('/:id/approve', async (req, res) => { + try { + await approveUser(req.params.id) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[users] 승인 오류:', err) + res.status(500).json({ error: '사용자 승인 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/users/:id/reject — 사용자 거절 +router.put('/:id/reject', async (req, res) => { + try { + await rejectUser(req.params.id) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { + res.status(err.status).json({ error: err.message }) + return + } + console.error('[users] 거절 오류:', err) + res.status(500).json({ error: '사용자 거절 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/users/:id/roles +router.put('/:id/roles', async (req, res) => { + try { + const { roleSns } = req.body + if (!Array.isArray(roleSns)) { + res.status(400).json({ error: '역할 목록이 필요합니다.' }) + return + } + await assignRoles(req.params.id, roleSns) + res.json({ success: true }) + } catch (err) { + console.error('[users] 역할 할당 오류:', err) + res.status(500).json({ error: '역할 할당 중 오류가 발생했습니다.' }) + } +}) + +export default router diff --git a/backend/src/users/userService.ts b/backend/src/users/userService.ts new file mode 100644 index 0000000..36b135c --- /dev/null +++ b/backend/src/users/userService.ts @@ -0,0 +1,294 @@ +import { authPool } from '../db/authDb.js' +import { hashPassword, AuthError } from '../auth/authService.js' +import { getSettingBoolean } from '../settings/settingsService.js' + +interface UserListItem { + id: string + account: string + name: string + rank: string | null + orgSn: number | null + orgName: string | null + orgAbbr: string | null + status: string + failCount: number + lastLogin: string | null + roles: string[] + regDtm: string +} + +interface CreateUserInput { + account: string + password: string + name: string + rank?: string + orgSn?: number + roleSns?: number[] +} + +interface UpdateUserInput { + name?: string + rank?: string + orgSn?: number | null + status?: string +} + +export async function listUsers(search?: string, status?: string): Promise { + let query = ` + SELECT u.USER_ID as id, u.USER_ACNT as account, u.USER_NM as name, + u.RNKP_NM as rank, u.ORG_SN as org_sn, + o.ORG_NM as org_name, o.ORG_ABBR_NM as org_abbr, + u.USER_STTS_CD as status, u.FAIL_CNT as fail_count, + u.LAST_LOGIN_DTM as last_login, u.REG_DTM as reg_dtm + FROM AUTH_USER u + LEFT JOIN AUTH_ORG o ON u.ORG_SN = o.ORG_SN + WHERE 1=1 + ` + const params: (string | undefined)[] = [] + let paramIdx = 1 + + if (search) { + query += ` AND (u.USER_ACNT ILIKE $${paramIdx} OR u.USER_NM ILIKE $${paramIdx})` + params.push(`%${search}%`) + paramIdx++ + } + + if (status) { + query += ` AND u.USER_STTS_CD = $${paramIdx}` + params.push(status) + paramIdx++ + } + + query += ' ORDER BY u.REG_DTM DESC' + + const result = await authPool.query(query, params) + + const users: UserListItem[] = [] + for (const row of result.rows) { + const rolesResult = await authPool.query( + `SELECT r.ROLE_CD FROM AUTH_USER_ROLE ur JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN WHERE ur.USER_ID = $1`, + [row.id] + ) + users.push({ + id: row.id, + account: row.account, + name: row.name, + rank: row.rank, + orgSn: row.org_sn, + orgName: row.org_name, + orgAbbr: row.org_abbr, + status: row.status, + failCount: row.fail_count, + lastLogin: row.last_login, + roles: rolesResult.rows.map((r: { role_cd: string }) => r.role_cd), + regDtm: row.reg_dtm, + }) + } + + return users +} + +export async function getUser(userId: string): Promise { + const result = await authPool.query( + `SELECT u.USER_ID as id, u.USER_ACNT as account, u.USER_NM as name, + u.RNKP_NM as rank, u.ORG_SN as org_sn, + o.ORG_NM as org_name, o.ORG_ABBR_NM as org_abbr, + u.USER_STTS_CD as status, u.FAIL_CNT as fail_count, + u.LAST_LOGIN_DTM as last_login, u.REG_DTM as reg_dtm + FROM AUTH_USER u + LEFT JOIN AUTH_ORG o ON u.ORG_SN = o.ORG_SN + WHERE u.USER_ID = $1`, + [userId] + ) + + if (result.rows.length === 0) { + throw new AuthError('사용자를 찾을 수 없습니다.', 404) + } + + const row = result.rows[0] + + const rolesResult = await authPool.query( + `SELECT r.ROLE_CD FROM AUTH_USER_ROLE ur JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN WHERE ur.USER_ID = $1`, + [userId] + ) + + return { + id: row.id, + account: row.account, + name: row.name, + rank: row.rank, + orgSn: row.org_sn, + orgName: row.org_name, + orgAbbr: row.org_abbr, + status: row.status, + failCount: row.fail_count, + lastLogin: row.last_login, + roles: rolesResult.rows.map((r: { role_cd: string }) => r.role_cd), + regDtm: row.reg_dtm, + } +} + +export async function createUser(input: CreateUserInput): Promise<{ id: string }> { + const existing = await authPool.query( + 'SELECT 1 FROM AUTH_USER WHERE USER_ACNT = $1', + [input.account] + ) + if (existing.rows.length > 0) { + throw new AuthError('이미 존재하는 계정입니다.', 409) + } + + const pswdHash = await hashPassword(input.password) + + // 자동 승인 설정 확인 + const autoApprove = await getSettingBoolean('registration.auto-approve', true) + const initialStatus = autoApprove ? 'ACTIVE' : 'PENDING' + + const result = await authPool.query( + `INSERT INTO AUTH_USER (USER_ACNT, PSWD_HASH, USER_NM, RNKP_NM, ORG_SN, USER_STTS_CD) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING USER_ID as id`, + [input.account, pswdHash, input.name, input.rank || null, input.orgSn || null, initialStatus] + ) + + const userId = result.rows[0].id + + // 역할 할당 + if (input.roleSns && input.roleSns.length > 0) { + // 명시적으로 역할 지정된 경우 + for (const roleSn of input.roleSns) { + await authPool.query( + 'INSERT INTO AUTH_USER_ROLE (USER_ID, ROLE_SN) VALUES ($1, $2)', + [userId, roleSn] + ) + } + } else { + // 기본 역할 자동 할당 설정 확인 + const useDefaultRole = await getSettingBoolean('registration.default-role', true) + if (useDefaultRole) { + const defaultRoles = await authPool.query( + "SELECT ROLE_SN FROM AUTH_ROLE WHERE DFLT_YN = 'Y'" + ) + for (const row of defaultRoles.rows) { + await authPool.query( + 'INSERT INTO AUTH_USER_ROLE (USER_ID, ROLE_SN) VALUES ($1, $2)', + [userId, row.role_sn] + ) + } + } + } + + return { id: userId } +} + +export async function approveUser(userId: string): Promise { + const result = await authPool.query( + 'SELECT USER_STTS_CD FROM AUTH_USER WHERE USER_ID = $1', + [userId] + ) + if (result.rows.length === 0) { + throw new AuthError('사용자를 찾을 수 없습니다.', 404) + } + if (result.rows[0].user_stts_cd !== 'PENDING') { + throw new AuthError('승인 대기 상태의 사용자만 승인할 수 있습니다.', 400) + } + + await authPool.query( + "UPDATE AUTH_USER SET USER_STTS_CD = 'ACTIVE', MDFCN_DTM = NOW() WHERE USER_ID = $1", + [userId] + ) + + // 기본 역할이 아직 할당되지 않았으면 할당 + const existingRoles = await authPool.query( + 'SELECT 1 FROM AUTH_USER_ROLE WHERE USER_ID = $1', + [userId] + ) + if (existingRoles.rows.length === 0) { + const useDefaultRole = await getSettingBoolean('registration.default-role', true) + if (useDefaultRole) { + const defaultRoles = await authPool.query( + "SELECT ROLE_SN FROM AUTH_ROLE WHERE DFLT_YN = 'Y'" + ) + for (const row of defaultRoles.rows) { + await authPool.query( + 'INSERT INTO AUTH_USER_ROLE (USER_ID, ROLE_SN) VALUES ($1, $2)', + [userId, row.role_sn] + ) + } + } + } +} + +export async function rejectUser(userId: string): Promise { + const result = await authPool.query( + 'SELECT USER_STTS_CD FROM AUTH_USER WHERE USER_ID = $1', + [userId] + ) + if (result.rows.length === 0) { + throw new AuthError('사용자를 찾을 수 없습니다.', 404) + } + if (result.rows[0].user_stts_cd !== 'PENDING') { + throw new AuthError('승인 대기 상태의 사용자만 거절할 수 있습니다.', 400) + } + + await authPool.query( + "UPDATE AUTH_USER SET USER_STTS_CD = 'REJECTED', MDFCN_DTM = NOW() WHERE USER_ID = $1", + [userId] + ) +} + +export async function updateUser(userId: string, input: UpdateUserInput): Promise { + const sets: string[] = [] + const params: (string | number | null)[] = [] + let idx = 1 + + if (input.name !== undefined) { + sets.push(`USER_NM = $${idx++}`) + params.push(input.name) + } + if (input.rank !== undefined) { + sets.push(`RNKP_NM = $${idx++}`) + params.push(input.rank) + } + if (input.orgSn !== undefined) { + sets.push(`ORG_SN = $${idx++}`) + params.push(input.orgSn) + } + if (input.status !== undefined) { + sets.push(`USER_STTS_CD = $${idx++}`) + params.push(input.status) + if (input.status === 'ACTIVE') { + sets.push('FAIL_CNT = 0') + } + } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400) + } + + sets.push('MDFCN_DTM = NOW()') + params.push(userId) + + await authPool.query( + `UPDATE AUTH_USER SET ${sets.join(', ')} WHERE USER_ID = $${idx}`, + params + ) +} + +export async function changePassword(userId: string, newPassword: string): Promise { + const pswdHash = await hashPassword(newPassword) + await authPool.query( + 'UPDATE AUTH_USER SET PSWD_HASH = $1, MDFCN_DTM = NOW() WHERE USER_ID = $2', + [pswdHash, userId] + ) +} + +export async function assignRoles(userId: string, roleSns: number[]): Promise { + await authPool.query('DELETE FROM AUTH_USER_ROLE WHERE USER_ID = $1', [userId]) + + for (const roleSn of roleSns) { + await authPool.query( + 'INSERT INTO AUTH_USER_ROLE (USER_ID, ROLE_SN) VALUES ($1, $2)', + [userId, roleSn] + ) + } +} diff --git a/database/auth_init.sql b/database/auth_init.sql new file mode 100644 index 0000000..18ba464 --- /dev/null +++ b/database/auth_init.sql @@ -0,0 +1,288 @@ +-- ================================================================ +-- WING 인증 시스템 데이터베이스 (wing_auth) +-- 공공데이터베이스 표준화 관리 매뉴얼(2021.06) 기준 적용 +-- PostgreSQL 16 +-- ================================================================ + +-- ============================================================ +-- 1. 사용자 및 데이터베이스 생성 +-- ============================================================ +CREATE USER wing_auth WITH PASSWORD 'WingAuth!2026'; +CREATE DATABASE wing_auth OWNER wing_auth; + +-- wing_auth 데이터베이스로 전환 +\c wing_auth + +-- ============================================================ +-- 2. 확장 설치 +-- ============================================================ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +GRANT ALL ON SCHEMA public TO wing_auth; + +-- ============================================================ +-- 3. 조직 (AUTH_ORG) +-- ============================================================ +CREATE TABLE AUTH_ORG ( + ORG_SN SERIAL NOT NULL, + ORG_NM VARCHAR(100) NOT NULL, + ORG_ABBR_NM VARCHAR(20) NOT NULL, + ORG_TP_CD VARCHAR(20) NOT NULL, + UPPER_ORG_SN INTEGER, + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT PK_AUTH_ORG PRIMARY KEY (ORG_SN), + CONSTRAINT FK_AUTH_ORG_UPPER FOREIGN KEY (UPPER_ORG_SN) REFERENCES AUTH_ORG(ORG_SN) +); + +COMMENT ON TABLE AUTH_ORG IS '인증조직'; +COMMENT ON COLUMN AUTH_ORG.ORG_SN IS '조직순번'; +COMMENT ON COLUMN AUTH_ORG.ORG_NM IS '조직명'; +COMMENT ON COLUMN AUTH_ORG.ORG_ABBR_NM IS '조직약칭명'; +COMMENT ON COLUMN AUTH_ORG.ORG_TP_CD IS '조직유형코드 (HEADQUARTERS, REGIONAL, STATION, AGENCY)'; +COMMENT ON COLUMN AUTH_ORG.UPPER_ORG_SN IS '상위조직순번'; +COMMENT ON COLUMN AUTH_ORG.REG_DTM IS '등록일시'; + + +-- ============================================================ +-- 4. 역할 (AUTH_ROLE) +-- ============================================================ +CREATE TABLE AUTH_ROLE ( + ROLE_SN SERIAL NOT NULL, + ROLE_CD VARCHAR(20) NOT NULL, + ROLE_NM VARCHAR(50) NOT NULL, + ROLE_DC VARCHAR(200), + DFLT_YN CHAR(1) NOT NULL DEFAULT 'N', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT PK_AUTH_ROLE PRIMARY KEY (ROLE_SN), + CONSTRAINT UK_AUTH_ROLE_CD UNIQUE (ROLE_CD), + CONSTRAINT CK_AUTH_ROLE_DFLT CHECK (DFLT_YN IN ('Y','N')) +); + +COMMENT ON TABLE AUTH_ROLE IS '인증역할'; +COMMENT ON COLUMN AUTH_ROLE.ROLE_SN IS '역할순번'; +COMMENT ON COLUMN AUTH_ROLE.ROLE_CD IS '역할코드 (ADMIN, MANAGER, USER, VIEWER)'; +COMMENT ON COLUMN AUTH_ROLE.ROLE_NM IS '역할명'; +COMMENT ON COLUMN AUTH_ROLE.ROLE_DC IS '역할설명'; +COMMENT ON COLUMN AUTH_ROLE.DFLT_YN IS '기본여부 (Y:신규 사용자 기본 역할)'; +COMMENT ON COLUMN AUTH_ROLE.REG_DTM IS '등록일시'; + + +-- ============================================================ +-- 5. 사용자 (AUTH_USER) +-- ============================================================ +CREATE TABLE AUTH_USER ( + USER_ID UUID NOT NULL DEFAULT uuid_generate_v4(), + USER_ACNT VARCHAR(50) NOT NULL, + PSWD_HASH VARCHAR(255) NOT NULL, + USER_NM VARCHAR(50) NOT NULL, + RNKP_NM VARCHAR(30), + ORG_SN INTEGER, + USER_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PENDING', + FAIL_CNT INTEGER NOT NULL DEFAULT 0, + LAST_LOGIN_DTM TIMESTAMPTZ, + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT PK_AUTH_USER PRIMARY KEY (USER_ID), + CONSTRAINT UK_AUTH_USER_ACNT UNIQUE (USER_ACNT), + CONSTRAINT FK_AUTH_USER_ORG FOREIGN KEY (ORG_SN) REFERENCES AUTH_ORG(ORG_SN), + CONSTRAINT CK_AUTH_USER_STTS CHECK (USER_STTS_CD IN ('PENDING','ACTIVE','LOCKED','INACTIVE','REJECTED')) +); + +COMMENT ON TABLE AUTH_USER IS '인증사용자'; +COMMENT ON COLUMN AUTH_USER.USER_ID IS '사용자아이디 (UUID)'; +COMMENT ON COLUMN AUTH_USER.USER_ACNT IS '사용자계정 (로그인 ID)'; +COMMENT ON COLUMN AUTH_USER.PSWD_HASH IS '비밀번호해시 (bcrypt)'; +COMMENT ON COLUMN AUTH_USER.USER_NM IS '사용자명'; +COMMENT ON COLUMN AUTH_USER.RNKP_NM IS '직급명'; +COMMENT ON COLUMN AUTH_USER.ORG_SN IS '조직순번'; +COMMENT ON COLUMN AUTH_USER.USER_STTS_CD IS '사용자상태코드 (PENDING:승인대기, ACTIVE:활성, LOCKED:잠김, INACTIVE:비활성, REJECTED:거절)'; +COMMENT ON COLUMN AUTH_USER.FAIL_CNT IS '로그인실패횟수'; +COMMENT ON COLUMN AUTH_USER.LAST_LOGIN_DTM IS '최종로그인일시'; +COMMENT ON COLUMN AUTH_USER.REG_DTM IS '등록일시'; +COMMENT ON COLUMN AUTH_USER.MDFCN_DTM IS '수정일시'; + + +-- ============================================================ +-- 6. 사용자-역할 매핑 (AUTH_USER_ROLE) +-- ============================================================ +CREATE TABLE AUTH_USER_ROLE ( + USER_ID UUID NOT NULL, + ROLE_SN INTEGER NOT NULL, + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT PK_AUTH_USER_ROLE PRIMARY KEY (USER_ID, ROLE_SN), + CONSTRAINT FK_AUR_USER FOREIGN KEY (USER_ID) REFERENCES AUTH_USER(USER_ID) ON DELETE CASCADE, + CONSTRAINT FK_AUR_ROLE FOREIGN KEY (ROLE_SN) REFERENCES AUTH_ROLE(ROLE_SN) ON DELETE CASCADE +); + +COMMENT ON TABLE AUTH_USER_ROLE IS '사용자역할매핑'; +COMMENT ON COLUMN AUTH_USER_ROLE.USER_ID IS '사용자아이디'; +COMMENT ON COLUMN AUTH_USER_ROLE.ROLE_SN IS '역할순번'; +COMMENT ON COLUMN AUTH_USER_ROLE.REG_DTM IS '등록일시'; + + +-- ============================================================ +-- 7. 역할별 권한 (AUTH_PERM) +-- ============================================================ +CREATE TABLE AUTH_PERM ( + PERM_SN SERIAL NOT NULL, + ROLE_SN INTEGER NOT NULL, + RSRC_CD VARCHAR(50) NOT NULL, + GRANT_YN CHAR(1) NOT NULL DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT PK_AUTH_PERM PRIMARY KEY (PERM_SN), + CONSTRAINT FK_AP_ROLE FOREIGN KEY (ROLE_SN) REFERENCES AUTH_ROLE(ROLE_SN) ON DELETE CASCADE, + CONSTRAINT UK_AUTH_PERM UNIQUE (ROLE_SN, RSRC_CD), + CONSTRAINT CK_AUTH_PERM_GRANT CHECK (GRANT_YN IN ('Y','N')) +); + +COMMENT ON TABLE AUTH_PERM IS '역할별권한'; +COMMENT ON COLUMN AUTH_PERM.PERM_SN IS '권한순번'; +COMMENT ON COLUMN AUTH_PERM.ROLE_SN IS '역할순번'; +COMMENT ON COLUMN AUTH_PERM.RSRC_CD IS '리소스코드 (탭 ID: prediction, hns, rescue 등)'; +COMMENT ON COLUMN AUTH_PERM.GRANT_YN IS '부여여부 (Y:허용, N:거부)'; +COMMENT ON COLUMN AUTH_PERM.REG_DTM IS '등록일시'; + + +-- ============================================================ +-- 8. 로그인 이력 (AUTH_LOGIN_HIST) +-- ============================================================ +CREATE TABLE AUTH_LOGIN_HIST ( + HIST_SN SERIAL NOT NULL, + USER_ID UUID NOT NULL, + LOGIN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + IP_ADDR VARCHAR(45), + USER_AGENT VARCHAR(500), + SUCCESS_YN CHAR(1) NOT NULL DEFAULT 'Y', + CONSTRAINT PK_AUTH_LOGIN_HIST PRIMARY KEY (HIST_SN), + CONSTRAINT FK_ALH_USER FOREIGN KEY (USER_ID) REFERENCES AUTH_USER(USER_ID) ON DELETE CASCADE, + CONSTRAINT CK_ALH_SUCCESS CHECK (SUCCESS_YN IN ('Y','N')) +); + +COMMENT ON TABLE AUTH_LOGIN_HIST IS '로그인이력'; +COMMENT ON COLUMN AUTH_LOGIN_HIST.HIST_SN IS '이력순번'; +COMMENT ON COLUMN AUTH_LOGIN_HIST.USER_ID IS '사용자아이디'; +COMMENT ON COLUMN AUTH_LOGIN_HIST.LOGIN_DTM IS '로그인일시'; +COMMENT ON COLUMN AUTH_LOGIN_HIST.IP_ADDR IS 'IP주소'; +COMMENT ON COLUMN AUTH_LOGIN_HIST.USER_AGENT IS '유저에이전트'; +COMMENT ON COLUMN AUTH_LOGIN_HIST.SUCCESS_YN IS '성공여부 (Y:성공, N:실패)'; + + +-- ============================================================ +-- 8-1. 시스템 설정 (AUTH_SETTING) +-- ============================================================ +CREATE TABLE AUTH_SETTING ( + SETTING_KEY VARCHAR(100) NOT NULL, + SETTING_VAL VARCHAR(500) NOT NULL, + SETTING_DC VARCHAR(200), + MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT PK_AUTH_SETTING PRIMARY KEY (SETTING_KEY) +); + +COMMENT ON TABLE AUTH_SETTING IS '시스템설정'; +COMMENT ON COLUMN AUTH_SETTING.SETTING_KEY IS '설정키'; +COMMENT ON COLUMN AUTH_SETTING.SETTING_VAL IS '설정값'; +COMMENT ON COLUMN AUTH_SETTING.SETTING_DC IS '설정설명'; +COMMENT ON COLUMN AUTH_SETTING.MDFCN_DTM IS '수정일시'; + + +-- ============================================================ +-- 9. 인덱스 +-- ============================================================ +CREATE INDEX IDX_AUTH_USER_STTS ON AUTH_USER (USER_STTS_CD); +CREATE INDEX IDX_AUTH_USER_ORG ON AUTH_USER (ORG_SN); +CREATE INDEX IDX_AUTH_PERM_ROLE ON AUTH_PERM (ROLE_SN); +CREATE INDEX IDX_AUTH_PERM_RSRC ON AUTH_PERM (RSRC_CD); +CREATE INDEX IDX_AUTH_LOGIN_USER ON AUTH_LOGIN_HIST (USER_ID); +CREATE INDEX IDX_AUTH_LOGIN_DTM ON AUTH_LOGIN_HIST (LOGIN_DTM); + + +-- ============================================================ +-- 10. 초기 데이터: 역할 +-- ============================================================ +INSERT INTO AUTH_ROLE (ROLE_CD, ROLE_NM, ROLE_DC, DFLT_YN) VALUES +('ADMIN', '관리자', '시스템 전체 관리 권한', 'N'), +('MANAGER', '운영자', '운영 및 사용자 관리 권한', 'N'), +('USER', '일반사용자', '기본 업무 기능 접근 권한', 'Y'), +('VIEWER', '뷰어', '조회 전용 접근 권한', 'N'); + + +-- ============================================================ +-- 11. 초기 데이터: 역할별 권한 (탭 접근 매트릭스) +-- ============================================================ + +-- ADMIN (ROLE_SN=1): 모든 탭 접근 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES +(1, 'prediction', 'Y'), (1, 'hns', 'Y'), (1, 'rescue', 'Y'), +(1, 'reports', 'Y'), (1, 'aerial', 'Y'), (1, 'assets', 'Y'), +(1, 'scat', 'Y'), (1, 'incidents', 'Y'), (1, 'board', 'Y'), +(1, 'weather', 'Y'), (1, 'admin', 'Y'); + +-- MANAGER (ROLE_SN=2): admin 탭 제외 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES +(2, 'prediction', 'Y'), (2, 'hns', 'Y'), (2, 'rescue', 'Y'), +(2, 'reports', 'Y'), (2, 'aerial', 'Y'), (2, 'assets', 'Y'), +(2, 'scat', 'Y'), (2, 'incidents', 'Y'), (2, 'board', 'Y'), +(2, 'weather', 'Y'), (2, 'admin', 'N'); + +-- USER (ROLE_SN=3): assets, admin 탭 제외 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES +(3, 'prediction', 'Y'), (3, 'hns', 'Y'), (3, 'rescue', 'Y'), +(3, 'reports', 'Y'), (3, 'aerial', 'Y'), (3, 'assets', 'N'), +(3, 'scat', 'Y'), (3, 'incidents', 'Y'), (3, 'board', 'Y'), +(3, 'weather', 'Y'), (3, 'admin', 'N'); + +-- VIEWER (ROLE_SN=4): reports, assets, scat, admin 제외 +INSERT INTO AUTH_PERM (ROLE_SN, RSRC_CD, GRANT_YN) VALUES +(4, 'prediction', 'Y'), (4, 'hns', 'Y'), (4, 'rescue', 'Y'), +(4, 'reports', 'N'), (4, 'aerial', 'Y'), (4, 'assets', 'N'), +(4, 'scat', 'N'), (4, 'incidents', 'Y'), (4, 'board', 'Y'), +(4, 'weather', 'Y'), (4, 'admin', 'N'); + + +-- ============================================================ +-- 12. 초기 데이터: 조직 +-- ============================================================ +INSERT INTO AUTH_ORG (ORG_NM, ORG_ABBR_NM, ORG_TP_CD) VALUES +('해양경찰청', '해경청', 'HEADQUARTERS'), +('남해지방해양경찰청', '남해청', 'REGIONAL'), +('제주지방해양경찰청', '제주청', 'REGIONAL'), +('여수해양경찰서', '여수서', 'STATION'), +('서귀포해양경찰서', '서귀포서', 'STATION'), +('제주해양경찰서', '제주서', 'STATION'); + +UPDATE AUTH_ORG SET UPPER_ORG_SN = 1 WHERE ORG_SN IN (2, 3); +UPDATE AUTH_ORG SET UPPER_ORG_SN = 2 WHERE ORG_SN = 4; +UPDATE AUTH_ORG SET UPPER_ORG_SN = 3 WHERE ORG_SN IN (5, 6); + + +-- ============================================================ +-- 13. 초기 데이터: 관리자 계정 (admin / admin1234) +-- ============================================================ +INSERT INTO AUTH_USER (USER_ACNT, PSWD_HASH, USER_NM, RNKP_NM, ORG_SN, USER_STTS_CD) +VALUES ('admin', crypt('admin1234', gen_salt('bf', 10)), '관리자', '경정', 1, 'ACTIVE'); + +-- admin 사용자에 ADMIN 역할 할당 +INSERT INTO AUTH_USER_ROLE (USER_ID, ROLE_SN) +SELECT USER_ID, 1 FROM AUTH_USER WHERE USER_ACNT = 'admin'; + + +-- ============================================================ +-- 14. 초기 데이터: 시스템 설정 +-- ============================================================ +INSERT INTO AUTH_SETTING (SETTING_KEY, SETTING_VAL, SETTING_DC) VALUES +('registration.auto-approve', 'true', '신규 사용자 자동 승인 여부 (true: 즉시 ACTIVE, false: PENDING 대기)'), +('registration.default-role', 'true', '신규 사용자에게 기본 역할(DFLT_YN=Y) 자동 할당 여부'); + + +-- ============================================================ +-- 15. 권한 부여 +-- ============================================================ +GRANT ALL ON ALL TABLES IN SCHEMA public TO wing_auth; +GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO wing_auth; + + +-- ============================================================ +-- 데이터베이스 코멘트 +-- ============================================================ +COMMENT ON DATABASE wing_auth IS 'WING 인증 시스템 - 사용자 인증, 역할, 권한 관리'; diff --git a/docker-compose.yml b/docker-compose.yml index 014fc02..888a5b3 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - "5432:5432" volumes: - wing_data:/var/lib/postgresql/data + - ./database/auth_init.sql:/docker-entrypoint-initdb.d/00-auth-init.sql - ./database/database_init.sql:/docker-entrypoint-initdb.d/01-init.sql healthcheck: test: ["CMD-SHELL", "pg_isready -U wing_admin -d wing"] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca3a309..ce7590d 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react' import { MainLayout } from './components/layout/MainLayout' +import { LoginPage } from './components/auth/LoginPage' import { registerMainTabSwitcher } from './hooks/useSubMenu' +import { useAuthStore } from './store/authStore' import { OilSpillView } from './components/views/OilSpillView' import { ReportsView } from './components/views/ReportsView' import { HNSView } from './components/views/HNSView' @@ -17,11 +19,39 @@ export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | ' function App() { const [activeMainTab, setActiveMainTab] = useState('prediction') + const { isAuthenticated, isLoading, checkSession } = useAuthStore() + + useEffect(() => { + checkSession() + }, [checkSession]) useEffect(() => { registerMainTabSwitcher(setActiveMainTab) }, []) + // 세션 확인 중 스플래시 + if (isLoading) { + return ( +
+ WING +
+
+ ) + } + + // 미인증 → 로그인 페이지 + if (!isAuthenticated) { + return + } + const renderView = () => { switch (activeMainTab) { case 'prediction': diff --git a/frontend/src/components/analysis/HNSRecalcModal.tsx b/frontend/src/components/analysis/HNSRecalcModal.tsx index 70c1ea8..c46326d 100755 --- a/frontend/src/components/analysis/HNSRecalcModal.tsx +++ b/frontend/src/components/analysis/HNSRecalcModal.tsx @@ -32,6 +32,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProp const [phase, setPhase] = useState('editing') useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect if (isOpen) setPhase('editing') }, [isOpen]) diff --git a/frontend/src/components/analysis/HNSScenarioView.tsx b/frontend/src/components/analysis/HNSScenarioView.tsx index e2f60fb..aff0be6 100755 --- a/frontend/src/components/analysis/HNSScenarioView.tsx +++ b/frontend/src/components/analysis/HNSScenarioView.tsx @@ -713,6 +713,7 @@ function NewScenarioModal({ isOpen, onClose, onSubmit }: { return () => document.removeEventListener('mousedown', handler) }, [isOpen, onClose]) + // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { if (isOpen) setName('') }, [isOpen]) if (!isOpen) return null diff --git a/frontend/src/components/analysis/HNSSubstanceView.tsx b/frontend/src/components/analysis/HNSSubstanceView.tsx index 1d24fca..6f9f41f 100755 --- a/frontend/src/components/analysis/HNSSubstanceView.tsx +++ b/frontend/src/components/analysis/HNSSubstanceView.tsx @@ -52,8 +52,11 @@ export function HNSSubstanceView() { const [activeTab, setActiveTab] = useState(0) const [selectedCategory, setSelectedCategory] = useState('all') const [searchQuery, setSearchQuery] = useState('') + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [detailSearchName, setDetailSearchName] = useState('') + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [detailSearchCas, setDetailSearchCas] = useState('') + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [detailSearchSebc, setDetailSearchSebc] = useState('전체 거동분류') /* Panel 3: 물질 상세검색 state */ const [hmsSearchType, setHmsSearchType] = useState<'abbr' | 'korName' | 'engName' | 'cas' | 'un'>('abbr') @@ -100,6 +103,7 @@ ${styles} }) /* Detail search filter for Panel 3 (legacy) */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars const detailFiltered = substances.filter(s => { const qName = detailSearchName.toLowerCase() const qCas = detailSearchCas.toLowerCase() @@ -111,16 +115,16 @@ ${styles} /* Panel 3: HNS 통합 검색 필터 */ const hmsFiltered = useMemo(() => { - const q = hmsSearchInput.toLowerCase().replace(/[\s\-\.\/]/g, '') + const q = hmsSearchInput.toLowerCase().replace(/[\s\-./]/g, '') return HNS_SEARCH_DB.filter(s => { // SEBC 필터 if (hmsFilterSebc !== '전체 거동분류' && !s.sebc.startsWith(hmsFilterSebc.split(' ')[0])) return false if (!q) return true switch (hmsSearchType) { - case 'abbr': return s.abbreviation.toLowerCase().replace(/[\s\-\.\/]/g, '').includes(q) || s.cargoCodes.some(c => c.code.toLowerCase().replace(/[\s\-\.\/]/g, '').includes(q)) + case 'abbr': return s.abbreviation.toLowerCase().replace(/[\s\-./]/g, '').includes(q) || s.cargoCodes.some(c => c.code.toLowerCase().replace(/[\s\-./]/g, '').includes(q)) case 'korName': return s.nameKr.includes(hmsSearchInput) || s.synonymsKr.includes(hmsSearchInput) case 'engName': return s.nameEn.toLowerCase().includes(q) || s.synonymsEn.toLowerCase().includes(q) - case 'cas': return s.casNumber.replace(/\-/g, '').includes(q.replace(/\-/g, '')) + case 'cas': return s.casNumber.replace(/-/g, '').includes(q.replace(/-/g, '')) case 'un': return s.unNumber.includes(hmsSearchInput) default: return true } @@ -629,6 +633,7 @@ ${styles} function HmsDetailPanel({ substance: s, activeTab, onTabChange }: { substance: HNSSearchSubstance; activeTab: number; onTabChange: (t: number) => void }) { const tabLabels = ['📊 물질특성·위험정보', '🛡 방제거리·PPE·MSDS', '⚓ IBC CODE·EmS 대응', '🔗 화물적부도·항구별 코드'] const nfpa = s.nfpa + // eslint-disable-next-line @typescript-eslint/no-unused-vars const sebcColor = s.sebc.startsWith('G') ? 'var(--purple)' : s.sebc.startsWith('E') ? 'var(--red)' : s.sebc.startsWith('F') ? 'var(--yellow)' : s.sebc.startsWith('D') ? 'var(--cyan)' : s.sebc.startsWith('S') ? 'var(--green)' : 'var(--t2)' return ( diff --git a/frontend/src/components/analysis/RecalcModal.tsx b/frontend/src/components/analysis/RecalcModal.tsx index feea224..664484e 100755 --- a/frontend/src/components/analysis/RecalcModal.tsx +++ b/frontend/src/components/analysis/RecalcModal.tsx @@ -50,6 +50,7 @@ export function RecalcModal({ const [phase, setPhase] = useState('editing') // Sync when modal opens + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (isOpen) { setOilType(initOilType) @@ -62,6 +63,7 @@ export function RecalcModal({ setPhase('editing') } }, [isOpen, initOilType, initSpillAmount, initSpillType, initPredictionTime, initCoord.lat, initCoord.lon, initModels]) + /* eslint-enable react-hooks/set-state-in-effect */ useEffect(() => { const handler = (e: MouseEvent) => { diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx new file mode 100644 index 0000000..69672fd --- /dev/null +++ b/frontend/src/components/auth/LoginPage.tsx @@ -0,0 +1,346 @@ +import { useState, useEffect } from 'react' +import { useAuthStore } from '../../store/authStore' + +/* Demo accounts (개발 모드 전용) */ +const DEMO_ACCOUNTS = [ + { id: 'admin', password: 'admin1234', label: '관리자 (경정)' }, +] + +export function LoginPage() { + const [userId, setUserId] = useState('') + const [password, setPassword] = useState('') + const [remember, setRemember] = useState(false) + const { login, isLoading, error, clearError } = useAuthStore() + + useEffect(() => { + const saved = localStorage.getItem('wing_remember') + if (saved) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setUserId(saved) + setRemember(true) + } + }, []) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + clearError() + + if (!userId.trim() || !password.trim()) { + return + } + + try { + await login(userId.trim(), password) + if (remember) { + localStorage.setItem('wing_remember', userId.trim()) + } else { + localStorage.removeItem('wing_remember') + } + } catch { + // 에러는 authStore에서 관리 + } + } + + return ( +
+ {/* Background image */} +
+ {/* Overlay */} +
+
+ + {/* Center: Login Form */} +
+
+ {/* Logo */} +
+ WING 해양환경 위기대응 통합시스템 +
+ + {/* Form card */} +
+ +
+ {/* User ID */} +
+ +
+ + + + { setUserId(e.target.value); clearError() }} + placeholder="사용자 아이디 입력" + autoComplete="username" + autoFocus + style={{ + width: '100%', padding: '11px 14px 11px 38px', + background: 'var(--bg2)', border: '1px solid var(--bd)', + borderRadius: 8, color: 'var(--t1)', fontSize: 13, + fontFamily: 'var(--fK)', outline: 'none', + transition: 'border-color 0.2s, box-shadow 0.2s', + }} + onFocus={(e) => { + e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)' + e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)' + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = 'var(--bd)' + e.currentTarget.style.boxShadow = 'none' + }} + /> +
+
+ + {/* Password */} +
+ +
+ + + + { setPassword(e.target.value); clearError() }} + placeholder="비밀번호 입력" + autoComplete="current-password" + style={{ + width: '100%', padding: '11px 14px 11px 38px', + background: 'var(--bg2)', border: '1px solid var(--bd)', + borderRadius: 8, color: 'var(--t1)', fontSize: 13, + fontFamily: 'var(--fK)', outline: 'none', + transition: 'border-color 0.2s, box-shadow 0.2s', + }} + onFocus={(e) => { + e.currentTarget.style.borderColor = 'rgba(6,182,212,0.4)' + e.currentTarget.style.boxShadow = '0 0 0 3px rgba(6,182,212,0.08)' + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = 'var(--bd)' + e.currentTarget.style.boxShadow = 'none' + }} + /> +
+
+ + {/* Remember + Forgot */} +
+ + +
+ + {/* Error */} + {error && ( +
+ + + + {error} +
+ )} + + {/* Login button */} + +
+ + {/* Divider */} +
+
+ 또는 +
+
+ + {/* SSO / Certificate */} +
+ + +
+ + {/* Demo accounts info (DEV only) */} + {import.meta.env.DEV && ( +
+
+ 데모 계정 +
+
+ {DEMO_ACCOUNTS.map((acc) => ( +
{ setUserId(acc.id); setPassword(acc.password); clearError() }} + style={{ + display: 'flex', justifyContent: 'space-between', alignItems: 'center', + padding: '4px 6px', borderRadius: 4, cursor: 'pointer', + transition: 'background 0.15s', + }} + onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(6,182,212,0.06)'} + onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'} + > + + {acc.id} / {acc.password} + + + {acc.label} + +
+ ))} +
+
+ )} +
{/* end form card */} + + {/* Footer */} +
+
WING V2.0 | 해양경찰청 기동방제과 위기대응 통합시스템
+
+ © 2026 Korea Coast Guard. All rights reserved. +
+
+
+
+
+ ) +} diff --git a/frontend/src/components/board/BoardWriteForm.tsx b/frontend/src/components/board/BoardWriteForm.tsx index 8e64bf7..8d077db 100755 --- a/frontend/src/components/board/BoardWriteForm.tsx +++ b/frontend/src/components/board/BoardWriteForm.tsx @@ -33,6 +33,7 @@ export function BoardWriteForm({ post, onSave, onCancel }: BoardWriteFormProps) useEffect(() => { if (post) { + // eslint-disable-next-line react-hooks/set-state-in-effect setCategory(post.category) setTitle(post.title) setContent(post.content) diff --git a/frontend/src/components/layout/HNSLeftPanel.tsx b/frontend/src/components/layout/HNSLeftPanel.tsx index 692f364..6da3479 100755 --- a/frontend/src/components/layout/HNSLeftPanel.tsx +++ b/frontend/src/components/layout/HNSLeftPanel.tsx @@ -13,6 +13,7 @@ interface HNSLeftPanelProps { export function HNSLeftPanel({ activeSubTab, + // eslint-disable-next-line @typescript-eslint/no-unused-vars onSubTabChange, incidentCoord, onCoordChange, diff --git a/frontend/src/components/layout/LeftPanel.tsx b/frontend/src/components/layout/LeftPanel.tsx index 6a4f8a2..e9eb1c1 100755 --- a/frontend/src/components/layout/LeftPanel.tsx +++ b/frontend/src/components/layout/LeftPanel.tsx @@ -98,6 +98,7 @@ export function LeftPanel({ }) // API에서 레이어 트리 데이터 가져오기 + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { data: layerTree, isLoading, error } = useLayerTree() const [layerColors, setLayerColors] = useState>({}) diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 0bec1eb..1ad5a87 100755 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -1,5 +1,6 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useMemo } from 'react' import type { MainTab } from '../../App' +import { useAuthStore } from '../../store/authStore' interface Tab { id: MainTab @@ -7,7 +8,7 @@ interface Tab { icon: string } -const tabs: Tab[] = [ +const ALL_TABS: Tab[] = [ { id: 'prediction', label: '유출유 확산예측', icon: '🛢️' }, { id: 'hns', label: 'HNS·대기확산', icon: '🧪' }, { id: 'rescue', label: '긴급구난', icon: '🚨' }, @@ -29,6 +30,12 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { const [showQuickMenu, setShowQuickMenu] = useState(false) const [mapToggles, setMapToggles] = useState({ s57: true, s101: false, threeD: false, satellite: false }) const quickMenuRef = useRef(null) + const { hasPermission, user, logout } = useAuthStore() + + const tabs = useMemo( + () => ALL_TABS.filter((tab) => hasPermission(tab.id)), + [hasPermission, user?.permissions] + ) useEffect(() => { const handler = (e: MouseEvent) => { @@ -96,19 +103,30 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) { - - + {hasPermission('admin') && ( + + )} + {user && ( +
+ {user.name} + +
+ )} {/* Quick Menu */}
diff --git a/frontend/src/components/map/MapView.tsx b/frontend/src/components/map/MapView.tsx index 242874d..0d81357 100755 --- a/frontend/src/components/map/MapView.tsx +++ b/frontend/src/components/map/MapView.tsx @@ -698,6 +698,7 @@ function TimelineControl({ {/* 정보 표시 */}
+ {/* eslint-disable-next-line react-hooks/purity */}
+{currentTime.toFixed(0)}h — {new Date(Date.now() + currentTime * 3600000).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} KST
diff --git a/frontend/src/components/reports/OilSpillReportTemplate.tsx b/frontend/src/components/reports/OilSpillReportTemplate.tsx index cd23244..39a443e 100755 --- a/frontend/src/components/reports/OilSpillReportTemplate.tsx +++ b/frontend/src/components/reports/OilSpillReportTemplate.tsx @@ -39,6 +39,7 @@ export interface OilSpillReportData { result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string } } +// eslint-disable-next-line react-refresh/only-export-components export function createEmptyReport(): OilSpillReportData { const now = new Date() const ts = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}` @@ -80,6 +81,7 @@ export function createEmptyReport(): OilSpillReportData { } } +// eslint-disable-next-line react-refresh/only-export-components export function createSampleReport(): OilSpillReportData { return { id: `RPT-${Date.now()}`, @@ -142,6 +144,7 @@ export function createSampleReport(): OilSpillReportData { // ─── localStorage helpers ─────────────────────────────────── const STORAGE_KEY = 'wing-reports' +// eslint-disable-next-line react-refresh/only-export-components export function loadReports(): OilSpillReportData[] { try { const raw = localStorage.getItem(STORAGE_KEY) @@ -149,6 +152,7 @@ export function loadReports(): OilSpillReportData[] { } catch { return [] } } +// eslint-disable-next-line react-refresh/only-export-components export function saveReportToStorage(report: OilSpillReportData) { const list = loadReports() const idx = list.findIndex(r => r.id === report.id) @@ -158,6 +162,7 @@ export function saveReportToStorage(report: OilSpillReportData) { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)) } +// eslint-disable-next-line react-refresh/only-export-components export function deleteReportFromStorage(id: string) { const list = loadReports().filter(r => r.id !== id) localStorage.setItem(STORAGE_KEY, JSON.stringify(list)) @@ -240,9 +245,9 @@ function Page1({ data, editing, onChange }: { data: OilSpillReportData; editing: } function Page2({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { - const setTide = (i: number, k: string, v: string) => { const t = [...data.tide]; (t[i] as any)[k] = v; onChange({ ...data, tide: t }) } - const setWeather = (i: number, k: string, v: string) => { const w = [...data.weather]; (w[i] as any)[k] = v; onChange({ ...data, weather: w }) } - const setSpread = (i: number, k: string, v: string) => { const s = [...data.spread]; (s[i] as any)[k] = v; onChange({ ...data, spread: s }) } + const setTide = (i: number, k: string, v: string) => { const t = [...data.tide]; t[i] = { ...t[i], [k]: v }; onChange({ ...data, tide: t }) } + const setWeather = (i: number, k: string, v: string) => { const w = [...data.weather]; w[i] = { ...w[i], [k]: v }; onChange({ ...data, weather: w }) } + const setSpread = (i: number, k: string, v: string) => { const s = [...data.spread]; s[i] = { ...s[i], [k]: v }; onChange({ ...data, spread: s }) } return (
@@ -334,7 +339,7 @@ function Page3({ data, editing, onChange }: { data: OilSpillReportData; editing: function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { const setArr = >(key: keyof OilSpillReportData, arr: T[], i: number, k: string, v: string) => { - const copy = [...arr]; (copy[i] as any)[k] = v; onChange({ ...data, [key]: copy }) + const copy = [...arr]; copy[i] = { ...copy[i], [k]: v }; onChange({ ...data, [key]: copy }) } return ( @@ -447,7 +452,7 @@ function Page5({ data, editing, onChange }: { data: OilSpillReportData; editing: } function Page6({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { - const setVessel = (i: number, k: string, v: string) => { const vs = [...data.vessels]; (vs[i] as any)[k] = v; onChange({ ...data, vessels: vs }) } + const setVessel = (i: number, k: string, v: string) => { const vs = [...data.vessels]; vs[i] = { ...vs[i], [k]: v }; onChange({ ...data, vessels: vs }) } return (
해양오염방제지원시스템
@@ -487,7 +492,7 @@ function Page6({ data, editing, onChange }: { data: OilSpillReportData; editing: } function Page7({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) { - const setRec = (i: number, k: string, v: string) => { const r = [...data.recovery]; (r[i] as any)[k] = v; onChange({ ...data, recovery: r }) } + const setRec = (i: number, k: string, v: string) => { const r = [...data.recovery]; r[i] = { ...r[i], [k]: v }; onChange({ ...data, recovery: r }) } const setRes = (k: string, v: string) => onChange({ ...data, result: { ...data.result, [k]: v } }) return (
@@ -535,16 +540,18 @@ export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Pr const editing = mode === 'edit' useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect if (initialData) setData(initialData) }, [initialData]) const handleSave = useCallback(() => { + let reportData = data if (!data.title) { const title = data.incident.name || `보고서 ${new Date().toLocaleDateString('ko-KR')}` - data.title = title + reportData = { ...data, title } } - saveReportToStorage(data) - onSave?.(data) + saveReportToStorage(reportData) + onSave?.(reportData) }, [data, onSave]) const pages = [ diff --git a/frontend/src/components/views/AdminView.tsx b/frontend/src/components/views/AdminView.tsx index ae91241..6a39455 100755 --- a/frontend/src/components/views/AdminView.tsx +++ b/frontend/src/components/views/AdminView.tsx @@ -1,21 +1,33 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useSubMenu } from '../../hooks/useSubMenu' - -// Mock data -const mockUsers = [ - { id: 1, name: '김해양', email: 'kim@kosg.go.kr', dept: '방제기술과', role: 'admin', status: 'active', lastLogin: '2026-02-16 09:30' }, - { id: 2, name: '이방제', email: 'lee@kosg.go.kr', dept: '해양환경과', role: 'manager', status: 'active', lastLogin: '2026-02-15 14:20' }, - { id: 3, name: '박구난', email: 'park@kosg.go.kr', dept: '긴급대응팀', role: 'user', status: 'active', lastLogin: '2026-02-16 08:15' }, - { id: 4, name: '최분석', email: 'choi@kosg.go.kr', dept: '방제기술과', role: 'user', status: 'active', lastLogin: '2026-02-14 16:45' }, - { id: 5, name: '정예측', email: 'jung@kosg.go.kr', dept: '해양환경과', role: 'user', status: 'inactive', lastLogin: '2026-01-20 11:00' }, - { id: 6, name: '한기상', email: 'han@kosg.go.kr', dept: '기상관측팀', role: 'viewer', status: 'active', lastLogin: '2026-02-16 07:50' }, -] +import { + fetchUsers, + fetchRoles, + updatePermissionsApi, + updateUserApi, + updateRoleDefaultApi, + approveUserApi, + rejectUserApi, + fetchRegistrationSettings, + updateRegistrationSettingsApi, + type UserListItem, + type RoleWithPermissions, + type RegistrationSettings, +} from '../../services/authApi' const roleLabels: Record = { - admin: { label: '관리자', color: 'var(--red)' }, - manager: { label: '매니저', color: 'var(--orange)' }, - user: { label: '사용자', color: 'var(--cyan)' }, - viewer: { label: '뷰어', color: 'var(--t3)' }, + ADMIN: { label: '관리자', color: 'var(--red)' }, + MANAGER: { label: '매니저', color: 'var(--orange)' }, + USER: { label: '사용자', color: 'var(--cyan)' }, + VIEWER: { label: '뷰어', color: 'var(--t3)' }, +} + +const statusLabels: Record = { + PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' }, + ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' }, + LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' }, + INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' }, + REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' }, } const mockMenus = [ @@ -31,24 +43,96 @@ const mockMenus = [ { id: 'weather', label: '기상정보', enabled: true, order: 10 }, ] +// ─── 사용자 관리 패널 ───────────────────────────────────────── function UsersPanel() { const [searchTerm, setSearchTerm] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) - const filtered = mockUsers.filter(u => - u.name.includes(searchTerm) || u.email.includes(searchTerm) || u.dept.includes(searchTerm) - ) + const loadUsers = useCallback(async () => { + setLoading(true) + try { + const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined) + setUsers(data) + } catch (err) { + console.error('사용자 목록 조회 실패:', err) + } finally { + setLoading(false) + } + }, [searchTerm, statusFilter]) + + useEffect(() => { + loadUsers() + }, [loadUsers]) + + const handleUnlock = async (userId: string) => { + try { + await updateUserApi(userId, { status: 'ACTIVE' }) + await loadUsers() + } catch (err) { + console.error('계정 잠금 해제 실패:', err) + } + } + + const handleApprove = async (userId: string) => { + try { + await approveUserApi(userId) + await loadUsers() + } catch (err) { + console.error('사용자 승인 실패:', err) + } + } + + const handleReject = async (userId: string) => { + try { + await rejectUserApi(userId) + await loadUsers() + } catch (err) { + console.error('사용자 거절 실패:', err) + } + } + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString('ko-KR', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', + }) + } + + const pendingCount = users.filter(u => u.status === 'PENDING').length return (
-
-

사용자 관리

-

총 {mockUsers.length}명

+
+
+

사용자 관리

+

총 {users.length}명

+
+ {pendingCount > 0 && ( + + 승인대기 {pendingCount}명 + + )}
+ setSearchTerm(e.target.value)} className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" @@ -60,97 +144,185 @@ function UsersPanel() {
- - - - - - - - - - - - - - {filtered.map((user) => ( - - - - - - - - + {loading ? ( +
불러오는 중...
+ ) : ( +
이름이메일부서역할상태최근 로그인관리
{user.name}{user.email}{user.dept} - - {roleLabels[user.role].label} - - - - - {user.status === 'active' ? '활성' : '비활성'} - - {user.lastLogin} -
- - -
-
+ + + + + + + + + - ))} - -
이름계정소속역할상태최근 로그인관리
+ + + {users.map((user) => { + const primaryRole = user.roles[0] || 'USER' + const roleInfo = roleLabels[primaryRole] || roleLabels.USER + const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE + return ( + + {user.name} + {user.account} + {user.orgAbbr || '-'} + + + {roleInfo.label} + + + + + + {statusInfo.label} + + + {formatDate(user.lastLogin)} + +
+ {user.status === 'PENDING' && ( + <> + + + + )} + {user.status === 'LOCKED' && ( + + )} + +
+ + + ) + })} + + + )}
) } +// ─── 권한 관리 패널 ───────────────────────────────────────── +const PERM_RESOURCES = [ + { id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' }, + { id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' }, + { id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' }, + { id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' }, + { id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' }, + { id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' }, + { id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' }, + { id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' }, + { id: 'board', label: '게시판', desc: '게시판 접근' }, + { id: 'weather', label: '기상정보', desc: '기상 정보 조회' }, + { id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' }, +] + function PermissionsPanel() { - const roles = ['admin', 'manager', 'user', 'viewer'] - const permissions = [ - { id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' }, - { id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' }, - { id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' }, - { id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' }, - { id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' }, - { id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' }, - { id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' }, - { id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' }, - ] + const [roles, setRoles] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [dirty, setDirty] = useState(false) - const [matrix, setMatrix] = useState>>(() => { - const m: Record> = {} - permissions.forEach(p => { - m[p.id] = { - admin: true, - manager: p.id !== 'admin', - user: !['admin', 'assets'].includes(p.id), - viewer: !['admin', 'assets', 'reports'].includes(p.id), + useEffect(() => { + loadRoles() + }, []) + + const loadRoles = async () => { + setLoading(true) + try { + const data = await fetchRoles() + setRoles(data) + setDirty(false) + } catch (err) { + console.error('역할 목록 조회 실패:', err) + } finally { + setLoading(false) + } + } + + const getPermGranted = (roleSn: number, resourceCode: string): boolean => { + const role = roles.find(r => r.sn === roleSn) + if (!role) return false + const perm = role.permissions.find(p => p.resourceCode === resourceCode) + return perm?.granted ?? false + } + + const togglePerm = (roleSn: number, resourceCode: string) => { + setRoles(prev => prev.map(role => { + if (role.sn !== roleSn) return role + const perms = role.permissions.map(p => + p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p + ) + if (!perms.find(p => p.resourceCode === resourceCode)) { + perms.push({ sn: 0, resourceCode, granted: true }) } - }) - return m - }) - - const toggle = (permId: string, role: string) => { - setMatrix(prev => ({ - ...prev, - [permId]: { ...prev[permId], [role]: !prev[permId][role] } + return { ...role, permissions: perms } })) + setDirty(true) + } + + const toggleDefault = async (roleSn: number) => { + const role = roles.find(r => r.sn === roleSn) + if (!role) return + const newValue = !role.isDefault + try { + await updateRoleDefaultApi(roleSn, newValue) + setRoles(prev => prev.map(r => + r.sn === roleSn ? { ...r, isDefault: newValue } : r + )) + } catch (err) { + console.error('기본 역할 변경 실패:', err) + } + } + + const handleSave = async () => { + setSaving(true) + try { + for (const role of roles) { + const permissions = PERM_RESOURCES.map(r => ({ + resourceCode: r.id, + granted: getPermGranted(role.sn, r.id), + })) + await updatePermissionsApi(role.sn, permissions) + } + setDirty(false) + } catch (err) { + console.error('권한 저장 실패:', err) + } finally { + setSaving(false) + } + } + + if (loading) { + return
불러오는 중...
} return ( @@ -160,8 +332,14 @@ function PermissionsPanel() {

사용자 권한 관리

역할별 메뉴 접근 권한을 설정합니다

-
@@ -170,31 +348,47 @@ function PermissionsPanel() { 기능 - {roles.map(role => ( - - {roleLabels[role].label} - - ))} + {roles.map(role => { + const info = roleLabels[role.code] || { label: role.name, color: 'var(--t3)' } + return ( + +
+ {info.label} +
+ + + ) + })} - {permissions.map((perm) => ( + {PERM_RESOURCES.map((perm) => (
{perm.label}
{perm.desc}
{roles.map(role => ( - + ))} @@ -207,6 +401,7 @@ function PermissionsPanel() { ) } +// ─── 메뉴 관리 패널 ───────────────────────────────────────── function MenusPanel() { const [menus, setMenus] = useState(mockMenus) @@ -296,6 +491,152 @@ function MenusPanel() { ) } +// ─── 시스템 설정 패널 ──────────────────────────────────────── +function SettingsPanel() { + const [settings, setSettings] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + loadSettings() + }, []) + + const loadSettings = async () => { + setLoading(true) + try { + const data = await fetchRegistrationSettings() + setSettings(data) + } catch (err) { + console.error('설정 조회 실패:', err) + } finally { + setLoading(false) + } + } + + const handleToggle = async (key: keyof RegistrationSettings) => { + if (!settings) return + const newValue = !settings[key] + setSaving(true) + try { + const updated = await updateRegistrationSettingsApi({ [key]: newValue }) + setSettings(updated) + } catch (err) { + console.error('설정 변경 실패:', err) + } finally { + setSaving(false) + } + } + + if (loading) { + return
불러오는 중...
+ } + + return ( +
+
+

시스템 설정

+

사용자 등록 및 권한 관련 시스템 설정을 관리합니다

+
+ +
+
+ {/* 사용자 등록 설정 */} +
+
+

사용자 등록 설정

+

신규 사용자 등록 시 적용되는 정책을 설정합니다

+
+ +
+ {/* 자동 승인 */} +
+
+
자동 승인
+

+ 활성화하면 신규 사용자가 등록 즉시 ACTIVE 상태가 됩니다. + 비활성화하면 관리자 승인 전까지 PENDING 상태로 대기합니다. +

+
+ +
+ + {/* 기본 역할 자동 할당 */} +
+
+
기본 역할 자동 할당
+

+ 활성화하면 신규 사용자에게 기본 역할이 자동으로 할당됩니다. + 기본 역할은 권한 관리 탭에서 설정할 수 있습니다. +

+
+ +
+
+
+ + {/* 현재 설정 상태 요약 */} +
+
+

설정 상태 요약

+
+
+
+
+ + + 신규 사용자 등록 시{' '} + {settings?.autoApprove ? ( + 즉시 활성화 + ) : ( + 관리자 승인 필요 + )} + +
+
+ + + 기본 역할 자동 할당{' '} + {settings?.defaultRole ? ( + 활성 + ) : ( + 비활성 + )} + +
+
+
+
+
+
+
+ ) +} + +// ─── AdminView ──────────────────────────────────────────── export function AdminView() { const { activeSubTab } = useSubMenu('admin') @@ -305,6 +646,7 @@ export function AdminView() { {activeSubTab === 'users' && } {activeSubTab === 'permissions' && } {activeSubTab === 'menus' && } + {activeSubTab === 'settings' && }
) diff --git a/frontend/src/components/views/AerialView.tsx b/frontend/src/components/views/AerialView.tsx index ef61422..249cb70 100755 --- a/frontend/src/components/views/AerialView.tsx +++ b/frontend/src/components/views/AerialView.tsx @@ -53,6 +53,19 @@ const mediaTagCls = (t: string) => // ── Tab 0: 영상·사진 관리 ── +const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => ( + +) + function MediaManagementTab() { const [selectedIds, setSelectedIds] = useState>(new Set()) const [equipFilter, setEquipFilter] = useState('all') @@ -93,7 +106,7 @@ function MediaManagementTab() { const toggleId = (id: number) => { setSelectedIds(prev => { const next = new Set(prev) - next.has(id) ? next.delete(id) : next.add(id) + if (next.has(id)) { next.delete(id) } else { next.add(id) } return next }) } @@ -109,7 +122,7 @@ function MediaManagementTab() { const toggleTypeFilter = (t: string) => { setTypeFilter(prev => { const next = new Set(prev) - next.has(t) ? next.delete(t) : next.add(t) + if (next.has(t)) { next.delete(t) } else { next.add(t) } return next }) } @@ -118,19 +131,6 @@ function MediaManagementTab() { const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length - const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => ( - - ) - return (
{/* Filters */} @@ -827,6 +827,19 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string const isWire = viewMode === 'wire' const isPoint = viewMode === 'point' + const [vesselPoints] = useState(() => + Array.from({ length: 300 }, (_, i) => { + const x = 35 + Math.random() * 355 + const y = 15 + Math.random() * 160 + const inHull = y > 60 && y < 175 && x > 35 && x < 390 + const inBridge = x > 260 && x < 330 && y > 25 && y < 60 + if (!inHull && !inBridge && Math.random() > 0.15) return null + const alpha = 0.15 + Math.random() * 0.55 + const r = 0.8 + Math.random() * 0.8 + return { i, x, y, r, alpha } + }) + ) + // 선박 SVG 와이어프레임/솔리드 3D 투시 return (
@@ -894,15 +907,9 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string {/* 포인트 클라우드 모드 */} {isPoint && - {Array.from({ length: 300 }, (_, i) => { - const x = 35 + Math.random() * 355 - const y = 15 + Math.random() * 160 - const inHull = y > 60 && y < 175 && x > 35 && x < 390 - const inBridge = x > 260 && x < 330 && y > 25 && y < 60 - if (!inHull && !inBridge && Math.random() > 0.15) return null - const alpha = 0.15 + Math.random() * 0.55 - return - })} + {vesselPoints.map(p => p && ( + + ))} } {/* 선수/선미 표시 */} @@ -939,6 +946,22 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri const isWire = viewMode === 'wire' const isPoint = viewMode === 'point' + const [pollutionPoints] = useState(() => + Array.from({ length: 400 }, (_, i) => { + const cx = 190, cy = 145, rx = 130, ry = 75 + const angle = Math.random() * Math.PI * 2 + const r = Math.sqrt(Math.random()) + const x = cx + r * rx * Math.cos(angle) + const y = cy + r * ry * Math.sin(angle) + if (x < 40 || x > 340 || y < 50 || y > 230) return null + const dist = Math.sqrt(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2) + const intensity = Math.max(0.1, 1 - dist) + const color = dist < 0.4 ? `rgba(239,68,68,${intensity * 0.7})` : dist < 0.7 ? `rgba(249,115,22,${intensity * 0.5})` : `rgba(234,179,8,${intensity * 0.3})` + const circleR = 0.6 + Math.random() * 1.2 + return { i, x, y, r: circleR, color } + }) + ) + return (
@@ -983,18 +1006,9 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri {/* 포인트 클라우드 */} {isPoint && - {Array.from({ length: 400 }, (_, i) => { - const cx = 190, cy = 145, rx = 130, ry = 75 - const angle = Math.random() * Math.PI * 2 - const r = Math.sqrt(Math.random()) - const x = cx + r * rx * Math.cos(angle) - const y = cy + r * ry * Math.sin(angle) - if (x < 40 || x > 340 || y < 50 || y > 230) return null - const dist = Math.sqrt(((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2) - const intensity = Math.max(0.1, 1 - dist) - const color = dist < 0.4 ? `rgba(239,68,68,${intensity * 0.7})` : dist < 0.7 ? `rgba(249,115,22,${intensity * 0.5})` : `rgba(234,179,8,${intensity * 0.3})` - return - })} + {pollutionPoints.map(p => p && ( + + ))} } {/* 두께 색상 범례 */} @@ -2428,6 +2442,7 @@ export function AerialView() { // 서브 메뉴로 탭 동기화 useEffect(() => { if (activeSubTab === 'media' || activeSubTab === 'analysis' || activeSubTab === 'realtime' || activeSubTab === 'sensor') { + // eslint-disable-next-line react-hooks/set-state-in-effect setActiveTab(activeSubTab as AerialTab) } }, [activeSubTab]) diff --git a/frontend/src/components/views/AssetsView.tsx b/frontend/src/components/views/AssetsView.tsx index 07faf2d..5c03b74 100755 --- a/frontend/src/components/views/AssetsView.tsx +++ b/frontend/src/components/views/AssetsView.tsx @@ -1002,6 +1002,7 @@ function AssetManagementTab() { const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize) // 필터 변경 시 첫 페이지로 + // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { setCurrentPage(1) }, [regionFilter, typeFilterVal, searchTerm]) const regionShort = (j: string) => { diff --git a/frontend/src/components/views/HNSView.tsx b/frontend/src/components/views/HNSView.tsx index 130ac53..8d77bb2 100755 --- a/frontend/src/components/views/HNSView.tsx +++ b/frontend/src/components/views/HNSView.tsx @@ -253,6 +253,7 @@ export function HNSView() { const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 }) const [isSelectingLocation, setIsSelectingLocation] = useState(false) const [isRunningPrediction, setIsRunningPrediction] = useState(false) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const [dispersionResult, setDispersionResult] = useState(null) const [recalcModalOpen, setRecalcModalOpen] = useState(false) diff --git a/frontend/src/components/views/IncidentsView.tsx b/frontend/src/components/views/IncidentsView.tsx index 8962215..71fbca4 100755 --- a/frontend/src/components/views/IncidentsView.tsx +++ b/frontend/src/components/views/IncidentsView.tsx @@ -795,7 +795,8 @@ function TabInfo({ v }: { v: Vessel }) { } /* ── Tab 1: 항해정보 ─────────────────────────────── */ -function TabNav({ v: _v }: { v: Vessel }) { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function TabNav(_props: { v: Vessel }) { const hours = ['08', '09', '10', '11', '12', '13', '14'] const heights = [45, 60, 78, 82, 70, 85, 75] const colors = ['rgba(34,197,94,.3)', 'rgba(34,197,94,.4)', 'rgba(59,130,246,.4)', 'rgba(59,130,246,.5)', 'rgba(59,130,246,.5)', 'rgba(59,130,246,.6)', 'rgba(6,182,212,.5)'] @@ -890,7 +891,8 @@ function TabSpec({ v }: { v: Vessel }) { } /* ── Tab 3: 보험정보 ─────────────────────────────── */ -function TabInsurance({ v: _v }: { v: Vessel }) { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function TabInsurance(_props: { v: Vessel }) { return ( <> diff --git a/frontend/src/components/views/OilSpillView.tsx b/frontend/src/components/views/OilSpillView.tsx index cee949b..cea74a0 100755 --- a/frontend/src/components/views/OilSpillView.tsx +++ b/frontend/src/components/views/OilSpillView.tsx @@ -15,6 +15,7 @@ import { TOTAL_REPLAY_FRAMES } from '../../types/backtrack' import { MOCK_CONDITIONS, MOCK_VESSELS, MOCK_REPLAY_SHIPS, MOCK_COLLISION } from '../../data/backtrackMockData' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' +// eslint-disable-next-line react-refresh/only-export-components export const ALL_MODELS: PredictionModel[] = ['KOSPS', 'POSEIDON', 'OpenDrift'] export function OilSpillView() { diff --git a/frontend/src/components/views/PreScatView.tsx b/frontend/src/components/views/PreScatView.tsx index 496c956..c7a3369 100755 --- a/frontend/src/components/views/PreScatView.tsx +++ b/frontend/src/components/views/PreScatView.tsx @@ -105,7 +105,6 @@ const substrateESI: Record = { '모래': { esi: '3A', n: 3 }, '모래자갈혼합': { esi: '5', n: 5 }, '자갈·왕자갈': { esi: '6A', n: 6 }, '수평암반': { esi: '8A', n: 8 }, '수직암반': { esi: '1A', n: 1 }, } -const coastalForms = ['개방형', '폐쇄형', '반폐쇄형'] const scatTagSets = [['🦪 양식장'], ['🏖 해수욕장'], ['⛵ 항구'], ['🪸 산호'], ['🌿 보호구역'], ['🐢 생태보전'], ['🏛 문화재'], ['⛰ 해안절벽'], ['🔧 인공구조물'], ['🌊 올레길']] const sensFromESI = (n: number): ScatSegment['sensitivity'] => n >= 9 ? '최상' : n >= 7 ? '상' : n >= 5 ? '중' : '하' const statusArr: ScatSegment['status'][] = ['완료', '완료', '완료', '완료', '진행중', '미조사'] @@ -752,7 +751,6 @@ function ScatMap({ const doneCount = segments.filter(s => s.status === '완료').length const progCount = segments.filter(s => s.status === '진행중').length - const notCount = segments.filter(s => s.status === '미조사').length const totalLen = segments.reduce((a, s) => a + s.lengthM, 0) const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0) const highSens = segments.filter(s => s.sensitivity === '최상' || s.sensitivity === '상').reduce((a, s) => a + s.lengthM, 0) diff --git a/frontend/src/components/views/ReportsView.tsx b/frontend/src/components/views/ReportsView.tsx index 4732fed..7222f85 100755 --- a/frontend/src/components/views/ReportsView.tsx +++ b/frontend/src/components/views/ReportsView.tsx @@ -39,7 +39,8 @@ function generateReportHTML(
${rows}` } -function exportAsPDF(html: string, filename: string) { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function exportAsPDF(html: string, _filename: string) { const sanitizedHtml = sanitizeHtml(html) const blob = new Blob([sanitizedHtml], { type: 'text/html; charset=utf-8' }) const url = URL.createObjectURL(blob) @@ -359,6 +360,7 @@ function TemplateFormEditor({ onSave, onBack }: { onSave: () => void; onBack: () const incFields = ['name', 'occurTime', 'location', 'shipName', 'accidentType', 'pollutant', 'spillAmount', 'lat', 'lon', 'depth', 'seabed'] as const incFields.forEach(f => { const val = formData[`incident.${f}`] + // eslint-disable-next-line @typescript-eslint/no-explicit-any if (val) (report.incident as any)[f] = val }) report.analysis = formData['spreadAnalysis'] || formData['initialResponse'] || formData['responseStatus'] || formData['responseDetail'] || '' @@ -729,6 +731,7 @@ function ReportGenerator({ onSave }: { onSave: () => void }) { useEffect(() => { const hint = consumeReportGenCategory() if (hint === 0 || hint === 1 || hint === 2) { + // eslint-disable-next-line react-hooks/set-state-in-effect setActiveCat(hint) setSelectedTemplate(0) } @@ -1234,11 +1237,13 @@ export function ReportsView() { const [previewReport, setPreviewReport] = useState(null) const refreshList = () => setReports(loadReports()) + // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { refreshList() }, []) // SubMenuBar 탭과 내부 view 동기화 useEffect(() => { if (activeSubTab === 'report-list') { + // eslint-disable-next-line react-hooks/set-state-in-effect setView({ screen: 'list' }) refreshList() } else if (activeSubTab === 'template') { @@ -1270,7 +1275,7 @@ export function ReportsView() { return true }) - const toggleSelect = (id: string) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n }) + const toggleSelect = (id: string) => setSelectedIds(prev => { const n = new Set(prev); if (n.has(id)) { n.delete(id) } else { n.add(id) } return n }) const toggleAll = () => selectedIds.size === filteredReports.length ? setSelectedIds(new Set()) : setSelectedIds(new Set(filteredReports.map(r => r.id))) const jurisdictions: Jurisdiction[] = ['남해청', '서해청', '중부청', '동해청', '제주청'] @@ -1469,8 +1474,10 @@ export function ReportsView() { if (key === 'author') return previewReport.author if (key.startsWith('incident.')) { const f = key.split('.')[1] + // eslint-disable-next-line @typescript-eslint/no-explicit-any return (previewReport.incident as any)[f] || '' } + // eslint-disable-next-line @typescript-eslint/no-explicit-any return (previewReport as any)[key] || '' } const html = generateReportHTML(tpl.label, { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }, tpl.sections, getVal) @@ -1490,8 +1497,10 @@ export function ReportsView() { if (key === 'author') return previewReport.author if (key.startsWith('incident.')) { const f = key.split('.')[1] + // eslint-disable-next-line @typescript-eslint/no-explicit-any return (previewReport.incident as any)[f] || '' } + // eslint-disable-next-line @typescript-eslint/no-explicit-any return (previewReport as any)[key] || '' } const html = generateReportHTML(tpl.label, { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }, tpl.sections, getVal) diff --git a/frontend/src/components/views/RescueView.tsx b/frontend/src/components/views/RescueView.tsx index 7e5b6bc..a5142f0 100755 --- a/frontend/src/components/views/RescueView.tsx +++ b/frontend/src/components/views/RescueView.tsx @@ -521,7 +521,8 @@ function RescuePanel({ activeType }: { activeType: AccidentType }) { } /* ─── 손상 복원성 패널 ─── */ -function DamageStabilityPanel({ activeType }: { activeType: AccidentType }) { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function DamageStabilityPanel(_props: { activeType: AccidentType }) { return (
손상 복원성 (DAMAGE STABILITY)
@@ -629,7 +630,8 @@ function DamageStabilityPanel({ activeType }: { activeType: AccidentType }) { } /* ─── 종강도 패널 ─── */ -function LongStrengthPanel({ activeType }: { activeType: AccidentType }) { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function LongStrengthPanel(_props: { activeType: AccidentType }) { return (
종강도 분석 (LONGITUDINAL STRENGTH)
diff --git a/frontend/src/components/views/WeatherView.tsx b/frontend/src/components/views/WeatherView.tsx index 9aabf29..811f7b5 100755 --- a/frontend/src/components/views/WeatherView.tsx +++ b/frontend/src/components/views/WeatherView.tsx @@ -113,6 +113,7 @@ export function WeatherView() { // Set initial selected station when data loads useEffect(() => { if (weatherStations.length > 0 && !selectedStation) { + // eslint-disable-next-line react-hooks/set-state-in-effect setSelectedStation(weatherStations[0]) } }, [weatherStations, selectedStation]) diff --git a/frontend/src/components/weather/OceanCurrentLayer.tsx b/frontend/src/components/weather/OceanCurrentLayer.tsx index a377bc9..ab94d8a 100755 --- a/frontend/src/components/weather/OceanCurrentLayer.tsx +++ b/frontend/src/components/weather/OceanCurrentLayer.tsx @@ -133,6 +133,7 @@ export function OceanCurrentLayer({ visible, opacity = 0.7 }: OceanCurrentLayerP interactive: false, // 회전 각도 적용 rotationAngle: current.direction + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) // CSS로 회전 적용 diff --git a/frontend/src/components/weather/OceanForecastOverlay.tsx b/frontend/src/components/weather/OceanForecastOverlay.tsx index b4f2c55..e34b530 100755 --- a/frontend/src/components/weather/OceanForecastOverlay.tsx +++ b/frontend/src/components/weather/OceanForecastOverlay.tsx @@ -20,6 +20,7 @@ export function OceanForecastOverlay({ opacity = 0.6, visible = true }: OceanForecastOverlayProps) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const map = useMap() const [imageLoaded, setImageLoaded] = useState(false) @@ -31,6 +32,7 @@ export function OceanForecastOverlay({ img.onerror = () => setImageLoaded(false) img.src = forecast.filePath } else { + // eslint-disable-next-line react-hooks/set-state-in-effect setImageLoaded(false) } }, [forecast?.filePath]) diff --git a/frontend/src/components/weather/WindParticleLayer.tsx b/frontend/src/components/weather/WindParticleLayer.tsx index b107454..226fae8 100755 --- a/frontend/src/components/weather/WindParticleLayer.tsx +++ b/frontend/src/components/weather/WindParticleLayer.tsx @@ -152,7 +152,6 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) // 배경 그라디언트 렌더링 function drawWindOverlay() { if (!ctx || !canvas) return - const bounds = map.getBounds() const gridSize = 30 for (let x = 0; x < canvas.width; x += gridSize) { diff --git a/frontend/src/hooks/useSubMenu.ts b/frontend/src/hooks/useSubMenu.ts index 1f32681..ce3b04a 100755 --- a/frontend/src/hooks/useSubMenu.ts +++ b/frontend/src/hooks/useSubMenu.ts @@ -57,7 +57,8 @@ const subMenuConfigs: Record = { admin: [ { id: 'users', label: '사용자 관리', icon: '👥' }, { id: 'permissions', label: '사용자 권한 관리', icon: '🔐' }, - { id: 'menus', label: '메뉴 관리', icon: '📑' } + { id: 'menus', label: '메뉴 관리', icon: '📑' }, + { id: 'settings', label: '시스템 설정', icon: '⚙️' } ] } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d4709f5..904a372 100755 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -7,17 +7,29 @@ export const api = axios.create({ headers: { 'Content-Type': 'application/json', }, + withCredentials: true, // JWT 쿠키 자동 포함 timeout: 30000, // 30초 타임아웃 maxContentLength: 10 * 1024 * 1024, // 응답 최대 10MB maxBodyLength: 1 * 1024 * 1024, // 요청 최대 1MB }) -// 응답 인터셉터: 민감한 에러 정보 노출 최소화 +// 응답 인터셉터: 민감한 에러 정보 노출 최소화 + 401 세션 만료 처리 api.interceptors.response.use( (response) => response, (error) => { if (error.response) { const { status, data } = error.response + + // 401: 인증 만료 → 로그아웃 처리 (로그인 요청 제외) + if (status === 401 && !error.config?.url?.includes('/auth/login')) { + import('../store/authStore').then(({ useAuthStore }) => { + const { isAuthenticated } = useAuthStore.getState() + if (isAuthenticated) { + useAuthStore.getState().logout() + } + }) + } + return Promise.reject({ status, message: data?.error || data?.message || '요청 처리 중 오류가 발생했습니다.', diff --git a/frontend/src/services/authApi.ts b/frontend/src/services/authApi.ts new file mode 100644 index 0000000..d3190ff --- /dev/null +++ b/frontend/src/services/authApi.ts @@ -0,0 +1,145 @@ +import { api } from './api' + +export interface AuthUser { + id: string + account: string + name: string + rank: string | null + org: { sn: number; name: string; abbr: string } | null + roles: string[] + permissions: string[] +} + +interface LoginResponse { + success: boolean + user: AuthUser +} + +export async function loginApi(account: string, password: string): Promise { + const response = await api.post('/auth/login', { account, password }) + return response.data.user +} + +export async function logoutApi(): Promise { + await api.post('/auth/logout') +} + +export async function fetchMe(): Promise { + const response = await api.get('/auth/me') + return response.data +} + +// 사용자 관리 API (ADMIN 전용) +export interface UserListItem { + id: string + account: string + name: string + rank: string | null + orgSn: number | null + orgName: string | null + orgAbbr: string | null + status: string + failCount: number + lastLogin: string | null + roles: string[] + regDtm: string +} + +export async function fetchUsers(search?: string, status?: string): Promise { + const params = new URLSearchParams() + if (search) params.set('search', search) + if (status) params.set('status', status) + const response = await api.get(`/users?${params}`) + return response.data +} + +export async function fetchUser(id: string): Promise { + const response = await api.get(`/users/${id}`) + return response.data +} + +export async function createUserApi(data: { + account: string + password: string + name: string + rank?: string + orgSn?: number + roleSns?: number[] +}): Promise<{ id: string }> { + const response = await api.post<{ id: string }>('/users', data) + return response.data +} + +export async function updateUserApi(id: string, data: { + name?: string + rank?: string + orgSn?: number | null + status?: string +}): Promise { + await api.put(`/users/${id}`, data) +} + +export async function changePasswordApi(id: string, password: string): Promise { + await api.put(`/users/${id}/password`, { password }) +} + +export async function assignRolesApi(id: string, roleSns: number[]): Promise { + await api.put(`/users/${id}/roles`, { roleSns }) +} + +// 역할/권한 API (ADMIN 전용) +export interface RoleWithPermissions { + sn: number + code: string + name: string + description: string | null + isDefault: boolean + permissions: Array<{ + sn: number + resourceCode: string + granted: boolean + }> +} + +export async function fetchRoles(): Promise { + const response = await api.get('/roles') + return response.data +} + +export async function updatePermissionsApi( + roleSn: number, + permissions: Array<{ resourceCode: string; granted: boolean }> +): Promise { + await api.put(`/roles/${roleSn}/permissions`, { permissions }) +} + +export async function updateRoleDefaultApi(roleSn: number, isDefault: boolean): Promise { + await api.put(`/roles/${roleSn}/default`, { isDefault }) +} + +// 사용자 승인/거절 API (ADMIN 전용) +export async function approveUserApi(id: string): Promise { + await api.put(`/users/${id}/approve`) +} + +export async function rejectUserApi(id: string): Promise { + await api.put(`/users/${id}/reject`) +} + +// 시스템 설정 API (ADMIN 전용) +export interface RegistrationSettings { + autoApprove: boolean + defaultRole: boolean +} + +export async function fetchRegistrationSettings(): Promise { + const response = await api.get('/settings/registration') + return response.data +} + +export async function updateRegistrationSettingsApi( + settings: Partial +): Promise { + const response = await api.put('/settings/registration', settings) + return response.data +} diff --git a/frontend/src/services/vesselService.ts b/frontend/src/services/vesselService.ts index c737f84..13b3121 100755 --- a/frontend/src/services/vesselService.ts +++ b/frontend/src/services/vesselService.ts @@ -149,10 +149,10 @@ export async function getVesselsInArea(bbox: BoundingBox): Promise ({ + return response.data.map((vessel: Record) => ({ ...vessel, - shipTypeText: getShipTypeText(vessel.shipType), - navStatusText: getNavStatusText(vessel.navStatus), + shipTypeText: getShipTypeText(vessel.shipType as number), + navStatusText: getNavStatusText(vessel.navStatus as number), })) } catch (error) { console.error('영역 내 선박 조회 오류:', error) @@ -198,10 +198,10 @@ export async function getVesselTrack( }, }) - return response.data.map((position: any) => ({ + return response.data.map((position: Record) => ({ ...position, - shipTypeText: getShipTypeText(position.shipType), - navStatusText: getNavStatusText(position.navStatus), + shipTypeText: getShipTypeText(position.shipType as number), + navStatusText: getNavStatusText(position.navStatus as number), })) } catch (error) { console.error(`선박 경로 조회 오류 (MMSI: ${mmsi}):`, error) diff --git a/frontend/src/services/weatherApi.ts b/frontend/src/services/weatherApi.ts index f67c140..5765745 100755 --- a/frontend/src/services/weatherApi.ts +++ b/frontend/src/services/weatherApi.ts @@ -65,9 +65,9 @@ export async function getUltraShortForecast( // 데이터를 시간대별로 그룹화 const forecasts: WeatherForecastData[] = [] - const grouped = new Map() + const grouped = new Map>() - items.forEach((item: any) => { + items.forEach((item: Record) => { const key = `${item.fcstDate}-${item.fcstTime}` if (!grouped.has(key)) { grouped.set(key, { diff --git a/frontend/src/services/weatherService.ts b/frontend/src/services/weatherService.ts index 7901c4b..cc39f2b 100755 --- a/frontend/src/services/weatherService.ts +++ b/frontend/src/services/weatherService.ts @@ -82,8 +82,8 @@ export async function getUltraShortNowcast(lat: number, lng: number) { const items = response.data.response.body.items.item // 데이터 파싱 - const weather: any = {} - items.forEach((item: any) => { + const weather: Record = {} + items.forEach((item: Record) => { switch (item.category) { case 'T1H': // 기온 weather.temperature = parseFloat(item.obsrValue) @@ -122,7 +122,7 @@ export async function getShortForecast(lat: number, lng: number) { const baseHours = [2, 5, 8, 11, 14, 17, 20, 23] const currentHour = now.getHours() - let baseHour = baseHours.reduce((prev, curr) => + const baseHour = baseHours.reduce((prev, curr) => curr <= currentHour ? curr : prev ) @@ -146,8 +146,8 @@ export async function getShortForecast(lat: number, lng: number) { const items = response.data.response.body.items.item // 시간별로 그룹화 - const forecastByTime: any = {} - items.forEach((item: any) => { + const forecastByTime: Record> = {} + items.forEach((item: Record) => { const key = `${item.fcstDate}_${item.fcstTime}` if (!forecastByTime[key]) { forecastByTime[key] = { @@ -199,6 +199,7 @@ export function windDirectionToText(degree: number): string { } // 해상 기상 정보 (Mock - 실제로는 해양기상청 API 사용) +// eslint-disable-next-line @typescript-eslint/no-unused-vars export async function getMarineWeather(lat: number, lng: number) { // TODO: 해양기상청 API 연동 // 현재는 Mock 데이터 반환 diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts new file mode 100644 index 0000000..3bc20fe --- /dev/null +++ b/frontend/src/store/authStore.ts @@ -0,0 +1,61 @@ +import { create } from 'zustand' +import { loginApi, logoutApi, fetchMe } from '../services/authApi' +import type { AuthUser } from '../services/authApi' + +interface AuthState { + user: AuthUser | null + isAuthenticated: boolean + isLoading: boolean + error: string | null + login: (account: string, password: string) => Promise + logout: () => Promise + checkSession: () => Promise + hasPermission: (resource: string) => boolean + clearError: () => void +} + +export const useAuthStore = create((set, get) => ({ + user: null, + isAuthenticated: false, + isLoading: true, + error: null, + + login: async (account: string, password: string) => { + set({ isLoading: true, error: null }) + try { + const user = await loginApi(account, password) + set({ user, isAuthenticated: true, isLoading: false }) + } catch (err) { + const message = (err as { message?: string })?.message || '로그인에 실패했습니다.' + set({ isLoading: false, error: message }) + throw err + } + }, + + logout: async () => { + try { + await logoutApi() + } catch { + // 로그아웃 실패해도 클라이언트 상태는 초기화 + } + set({ user: null, isAuthenticated: false, isLoading: false, error: null }) + }, + + checkSession: async () => { + set({ isLoading: true }) + try { + const user = await fetchMe() + set({ user, isAuthenticated: true, isLoading: false }) + } catch { + set({ user: null, isAuthenticated: false, isLoading: false }) + } + }, + + hasPermission: (resource: string) => { + const { user } = get() + if (!user) return false + return user.permissions.includes(resource) + }, + + clearError: () => set({ error: null }), +})) diff --git a/frontend/src/utils/geo.ts b/frontend/src/utils/geo.ts index a9d2827..b522dd8 100755 --- a/frontend/src/utils/geo.ts +++ b/frontend/src/utils/geo.ts @@ -99,6 +99,7 @@ export function generateAIBoomLines( const totalDist = haversineDistance(incident, centroid) // 입자 분산 폭 계산 (최종 시간 기준) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const perpBearing = (mainBearing + 90) % 360 let maxSpread = 0 for (const p of finalPoints) { diff --git a/frontend/src/utils/sanitize.ts b/frontend/src/utils/sanitize.ts index 070f40d..3ae78ac 100755 --- a/frontend/src/utils/sanitize.ts +++ b/frontend/src/utils/sanitize.ts @@ -35,6 +35,7 @@ export function stripHtmlTags(html: string): string { * 안전한 HTML 살균 * 허용된 태그만 남기고 위험한 태그/속성 제거 */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars const ALLOWED_TAGS = new Set([ 'b', 'i', 'u', 'strong', 'em', 'br', 'p', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',