Merge pull request 'release: v1.1.0 인증 시스템 릴리즈' (#2) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 21s

Reviewed-on: #2
This commit is contained in:
htlee 2026-02-27 15:55:27 +09:00
커밋 8e09415dc3
51개의 변경된 파일2946개의 추가작업 그리고 209개의 파일을 삭제

파일 보기

@ -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"
}
}
}
}

파일 보기

@ -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"
}

파일 보기

@ -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()
}
}

파일 보기

@ -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

파일 보기

@ -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<AuthUserInfo> {
const userResult = await authPool.query<AuthUserRow>(
`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<AuthUserInfo> {
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<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
async function recordLoginHistory(
userId: string,
ipAddr: string,
userAgent: string,
success: boolean
): Promise<void> {
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'
}
}

파일 보기

@ -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, string>): string | null {
return cookies[COOKIE_NAME] || null
}

33
backend/src/db/authDb.ts Normal file
파일 보기

@ -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<boolean> {
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 }

파일 보기

@ -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

파일 보기

@ -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<RoleWithPermissions[]> {
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<void> {
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<void> {
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']
)
}
}
}

파일 보기

@ -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) {

파일 보기

@ -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()
})

파일 보기

@ -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

파일 보기

@ -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<string | null> {
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<boolean> {
const val = await getSetting(key)
if (val === null) return defaultValue
return val === 'true'
}
export async function setSetting(key: string, value: string): Promise<void> {
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<void> {
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<SettingItem[]> {
const result = await authPool.query<SettingRow>(
'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,
}))
}

파일 보기

@ -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

파일 보기

@ -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<UserListItem[]> {
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<UserListItem> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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]
)
}
}

288
database/auth_init.sql Normal file
파일 보기

@ -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 인증 시스템 - 사용자 인증, 역할, 권한 관리';

파일 보기

@ -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"]

파일 보기

@ -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<MainTab>('prediction')
const { isAuthenticated, isLoading, checkSession } = useAuthStore()
useEffect(() => {
checkSession()
}, [checkSession])
useEffect(() => {
registerMainTabSwitcher(setActiveMainTab)
}, [])
// 세션 확인 중 스플래시
if (isLoading) {
return (
<div style={{
width: '100vw', height: '100vh', display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: '#001028', gap: 16,
}}>
<img src="/wing_logo_text_white.svg" alt="WING" style={{ height: 28, opacity: 0.8 }} />
<div style={{
width: 32, height: 32, border: '3px solid rgba(6,182,212,0.2)',
borderTop: '3px solid rgba(6,182,212,0.8)', borderRadius: '50%',
animation: 'loginSpin 0.8s linear infinite',
}} />
</div>
)
}
// 미인증 → 로그인 페이지
if (!isAuthenticated) {
return <LoginPage />
}
const renderView = () => {
switch (activeMainTab) {
case 'prediction':

파일 보기

@ -32,6 +32,7 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProp
const [phase, setPhase] = useState<RecalcPhase>('editing')
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (isOpen) setPhase('editing')
}, [isOpen])

파일 보기

@ -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

파일 보기

@ -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 (

파일 보기

@ -50,6 +50,7 @@ export function RecalcModal({
const [phase, setPhase] = useState<RecalcPhase>('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) => {

파일 보기

@ -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 (
<div style={{
width: '100vw', height: '100vh', display: 'flex',
background: '#001028', overflow: 'hidden', position: 'relative',
}}>
{/* Background image */}
<div style={{
position: 'absolute', inset: 0,
backgroundImage: 'url(/24.png)',
backgroundSize: 'cover',
backgroundPosition: 'center center',
backgroundRepeat: 'no-repeat',
}} />
{/* Overlay */}
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(90deg, rgba(0,8,20,0.4) 0%, rgba(0,8,20,0.2) 25%, rgba(0,8,20,0.05) 50%, rgba(0,8,20,0.15) 75%, rgba(0,8,20,0.5) 100%)',
}} />
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(180deg, rgba(0,6,18,0.15) 0%, transparent 30%, transparent 70%, rgba(0,6,18,0.4) 100%)',
}} />
{/* Center: Login Form */}
<div style={{
width: '100%', display: 'flex', flexDirection: 'column',
alignItems: 'flex-start', justifyContent: 'center',
padding: '40px 50px 40px 120px', position: 'relative', zIndex: 1,
}}>
<div style={{ width: '100%', maxWidth: 360 }}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 36 }}>
<img
src="/wing_logo_text_white.svg"
alt="WING 해양환경 위기대응 통합시스템"
style={{ height: 28, margin: '0 auto', display: 'block' }}
/>
</div>
{/* Form card */}
<div style={{
padding: '32px 28px', borderRadius: 12,
background: 'linear-gradient(180deg, rgba(4,16,36,0.88) 0%, rgba(2,10,26,0.92) 100%)',
border: '1px solid rgba(60,120,180,0.12)',
backdropFilter: 'blur(20px)',
boxShadow: '0 8px 48px rgba(0,0,0,0.5)',
}}>
<form onSubmit={handleSubmit}>
{/* User ID */}
<div style={{ marginBottom: 16 }}>
<label style={{
display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)',
fontFamily: 'var(--fK)', marginBottom: 6, letterSpacing: '0.3px',
}}>
</label>
<div style={{ position: 'relative' }}>
<span style={{
position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)',
fontSize: 14, color: 'var(--t3)', pointerEvents: 'none',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</span>
<input
type="text"
value={userId}
onChange={(e) => { 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'
}}
/>
</div>
</div>
{/* Password */}
<div style={{ marginBottom: 20 }}>
<label style={{
display: 'block', fontSize: 10, fontWeight: 600, color: 'var(--t3)',
fontFamily: 'var(--fK)', marginBottom: 6, letterSpacing: '0.3px',
}}>
</label>
<div style={{ position: 'relative' }}>
<span style={{
position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)',
fontSize: 14, color: 'var(--t3)', pointerEvents: 'none',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<input
type="password"
value={password}
onChange={(e) => { 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'
}}
/>
</div>
</div>
{/* Remember + Forgot */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 20,
}}>
<label style={{
display: 'flex', alignItems: 'center', gap: 6,
fontSize: 11, color: 'var(--t3)', fontFamily: 'var(--fK)', cursor: 'pointer',
}}>
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
style={{ accentColor: 'var(--cyan)' }}
/>
</label>
<button type="button" style={{
fontSize: 11, color: 'var(--cyan)', fontFamily: 'var(--fK)',
background: 'none', border: 'none', cursor: 'pointer',
textDecoration: 'none',
}}
onMouseEnter={(e) => e.currentTarget.style.textDecoration = 'underline'}
onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'}
>
</button>
</div>
{/* Error */}
{error && (
<div style={{
padding: '8px 12px', marginBottom: 16, borderRadius: 6,
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)',
fontSize: 11, color: '#f87171', fontFamily: 'var(--fK)',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ fontSize: 13 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
</span>
{error}
</div>
)}
{/* Login button */}
<button type="submit" disabled={isLoading} style={{
width: '100%', padding: '12px',
background: isLoading
? 'rgba(6,182,212,0.15)'
: 'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))',
border: '1px solid rgba(6,182,212,0.3)',
borderRadius: 8, color: 'var(--cyan)',
fontSize: 14, fontWeight: 700, cursor: isLoading ? 'wait' : 'pointer',
fontFamily: 'var(--fK)',
transition: 'all 0.2s',
boxShadow: '0 4px 16px rgba(6,182,212,0.1)',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(6,182,212,0.3), rgba(59,130,246,0.2))'
e.currentTarget.style.boxShadow = '0 6px 24px rgba(6,182,212,0.15)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(6,182,212,0.2), rgba(59,130,246,0.15))'
e.currentTarget.style.boxShadow = '0 4px 16px rgba(6,182,212,0.1)'
}
}}
>
{isLoading ? (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<span style={{
width: 14, height: 14, border: '2px solid rgba(6,182,212,0.3)',
borderTop: '2px solid var(--cyan)', borderRadius: '50%',
animation: 'loginSpin 0.8s linear infinite', display: 'inline-block',
}} />
...
</span>
) : '로그인'}
</button>
</form>
{/* Divider */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12, margin: '24px 0',
}}>
<div style={{ flex: 1, height: 1, background: 'var(--bd)' }} />
<span style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}></span>
<div style={{ flex: 1, height: 1, background: 'var(--bd)' }} />
</div>
{/* SSO / Certificate */}
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" style={{
flex: 1, padding: '10px', borderRadius: 8,
background: 'var(--bg3)', border: '1px solid var(--bd)',
color: 'var(--t2)', fontSize: 11, fontWeight: 600,
fontFamily: 'var(--fK)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bgH)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--bg3)'}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
</button>
<button type="button" style={{
flex: 1, padding: '10px', borderRadius: 8,
background: 'var(--bg3)', border: '1px solid var(--bd)',
color: 'var(--t2)', fontSize: 11, fontWeight: 600,
fontFamily: 'var(--fK)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bgH)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--bg3)'}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78Zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
SSO
</button>
</div>
{/* Demo accounts info (DEV only) */}
{import.meta.env.DEV && (
<div style={{
marginTop: 24, padding: '10px 12px', borderRadius: 8,
background: 'rgba(6,182,212,0.04)', border: '1px solid rgba(6,182,212,0.08)',
}}>
<div style={{ fontSize: 9, fontWeight: 700, color: 'var(--cyan)', fontFamily: 'var(--fK)', marginBottom: 6 }}>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{DEMO_ACCOUNTS.map((acc) => (
<div key={acc.id}
onClick={() => { 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'}
>
<span style={{ fontSize: 9, color: 'var(--t2)', fontFamily: 'var(--fM)' }}>
{acc.id} / {acc.password}
</span>
<span style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
{acc.label}
</span>
</div>
))}
</div>
</div>
)}
</div>{/* end form card */}
{/* Footer */}
<div style={{
marginTop: 24, textAlign: 'center', fontSize: 9,
color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.6,
}}>
<div>WING V2.0 | </div>
<div style={{ marginTop: 2, color: 'rgba(134,144,166,0.6)' }}>
&copy; 2026 Korea Coast Guard. All rights reserved.
</div>
</div>
</div>
</div>
</div>
)
}

파일 보기

@ -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)

파일 보기

@ -13,6 +13,7 @@ interface HNSLeftPanelProps {
export function HNSLeftPanel({
activeSubTab,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onSubTabChange,
incidentCoord,
onCoordChange,

파일 보기

@ -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<Record<string, string>>({})

파일 보기

@ -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<HTMLDivElement>(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,6 +103,7 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
<button className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all">
🔔
</button>
{hasPermission('admin') && (
<button
onClick={() => onTabChange('admin')}
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
@ -106,9 +114,19 @@ export function TopBar({ activeTab, onTabChange }: TopBarProps) {
>
</button>
<button className="w-9 h-9 rounded-sm border border-border bg-bg-3 text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all">
👤
)}
{user && (
<div className="flex items-center gap-2 pl-2 border-l border-border">
<span className="text-[11px] text-text-2 font-korean">{user.name}</span>
<button
onClick={() => logout()}
className="px-2 py-1 text-[10px] font-semibold text-text-3 border border-border rounded hover:bg-bg-hover hover:text-text-1 transition-all font-korean"
title="로그아웃"
>
</button>
</div>
)}
{/* Quick Menu */}
<div ref={quickMenuRef} className="relative">

파일 보기

@ -698,6 +698,7 @@ function TimelineControl({
{/* 정보 표시 */}
<div className="tli">
{/* eslint-disable-next-line react-hooks/purity */}
<div className="tlct">+{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</div>
<div className="tlss">
<div className="tls">

파일 보기

@ -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 (
<div style={S.page}>
@ -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 = <T extends Record<string, string>>(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 (
<div style={S.page}>
<div style={{ position: 'absolute', top: 10, right: 16, fontSize: '9px', color: 'var(--t3)', fontWeight: 600 }}></div>
@ -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 (
<div style={S.page}>
@ -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 = [

파일 보기

@ -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<string, { label: string; color: string }> = {
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<string, { label: string; color: string; dot: string }> = {
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<string>('')
const [users, setUsers] = useState<UserListItem[]>([])
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 (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> {mockUsers.length}</p>
<p className="text-xs text-text-3 mt-1 font-korean"> {users.length}</p>
</div>
{pendingCount > 0 && (
<span className="px-2.5 py-1 text-[10px] font-bold rounded-full bg-[rgba(250,204,21,0.15)] text-yellow-400 border border-[rgba(250,204,21,0.3)] animate-pulse font-korean">
{pendingCount}
</span>
)}
</div>
<div className="flex items-center gap-3">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
<option value=""> </option>
<option value="PENDING"></option>
<option value="ACTIVE"></option>
<option value="LOCKED"></option>
<option value="INACTIVE"></option>
<option value="REJECTED"></option>
</select>
<input
type="text"
placeholder="이름, 이메일, 부서 검색..."
placeholder="이름, 계정 검색..."
value={searchTerm}
onChange={(e) => 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,12 +144,15 @@ function UsersPanel() {
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1">
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"> </th>
@ -73,84 +160,169 @@ function UsersPanel() {
</tr>
</thead>
<tbody>
{filtered.map((user) => (
{users.map((user) => {
const primaryRole = user.roles[0] || 'USER'
const roleInfo = roleLabels[primaryRole] || roleLabels.USER
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE
return (
<tr key={user.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-6 py-3 text-[12px] text-text-1 font-semibold font-korean">{user.name}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-mono">{user.email}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.dept}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.orgAbbr || '-'}</td>
<td className="px-6 py-3">
<span
className="px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
style={{
background: `${roleLabels[user.role].color}20`,
color: roleLabels[user.role].color,
border: `1px solid ${roleLabels[user.role].color}40`
background: `${roleInfo.color}20`,
color: roleInfo.color,
border: `1px solid ${roleInfo.color}40`
}}
>
{roleLabels[user.role].label}
{roleInfo.label}
</span>
</td>
<td className="px-6 py-3">
<span className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${
user.status === 'active' ? 'text-green-400' : 'text-text-3'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${
user.status === 'active' ? 'bg-green-400' : 'bg-text-3'
}`} />
{user.status === 'active' ? '활성' : '비활성'}
<span className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${statusInfo.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
{statusInfo.label}
</span>
</td>
<td className="px-6 py-3 text-[11px] text-text-3 font-mono">{user.lastLogin}</td>
<td className="px-6 py-3 text-[11px] text-text-3 font-mono">{formatDate(user.lastLogin)}</td>
<td className="px-6 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{user.status === 'PENDING' && (
<>
<button
onClick={() => handleApprove(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
>
</button>
<button
onClick={() => handleReject(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
>
</button>
</>
)}
{user.status === 'LOCKED' && (
<button
onClick={() => handleUnlock(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
>
</button>
)}
<button className="px-2 py-1 text-[10px] font-semibold text-primary-cyan border border-primary-cyan rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean">
</button>
<button className="px-2 py-1 text-[10px] font-semibold text-status-red border border-status-red rounded hover:bg-[rgba(239,68,68,0.1)] transition-all font-korean">
</button>
</div>
</td>
</tr>
))}
)
})}
</tbody>
</table>
)}
</div>
</div>
)
}
function PermissionsPanel() {
const roles = ['admin', 'manager', 'user', 'viewer']
const permissions = [
// ─── 권한 관리 패널 ─────────────────────────────────────────
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: '시스템 관리 기능 접근' },
]
const [matrix, setMatrix] = useState<Record<string, Record<string, boolean>>>(() => {
const m: Record<string, Record<string, boolean>> = {}
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),
}
})
return m
})
function PermissionsPanel() {
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const toggle = (permId: string, role: string) => {
setMatrix(prev => ({
...prev,
[permId]: { ...prev[permId], [role]: !prev[permId][role] }
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 { ...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 <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
}
return (
@ -160,8 +332,14 @@ function PermissionsPanel() {
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> </p>
</div>
<button className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean">
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
</div>
@ -170,31 +348,47 @@ function PermissionsPanel() {
<thead>
<tr className="border-b border-border bg-bg-1">
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[200px]"></th>
{roles.map(role => (
<th key={role} className="px-6 py-3 text-center text-[11px] font-semibold font-korean min-w-[100px]" style={{ color: roleLabels[role].color }}>
{roleLabels[role].label}
{roles.map(role => {
const info = roleLabels[role.code] || { label: role.name, color: 'var(--t3)' }
return (
<th key={role.sn} className="px-6 py-3 text-center min-w-[100px]">
<div className="text-[11px] font-semibold font-korean" style={{ color: info.color }}>
{info.label}
</div>
<button
onClick={() => toggleDefault(role.sn)}
className={`mt-1 px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
role.isDefault
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan'
: 'text-text-3 border border-transparent hover:border-border'
}`}
title="신규 사용자에게 자동 할당되는 기본 역할"
>
{role.isDefault ? '기본역할' : '기본역할 설정'}
</button>
</th>
))}
)
})}
</tr>
</thead>
<tbody>
{permissions.map((perm) => (
{PERM_RESOURCES.map((perm) => (
<tr key={perm.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-6 py-3">
<div className="text-[12px] text-text-1 font-semibold font-korean">{perm.label}</div>
<div className="text-[10px] text-text-3 font-korean mt-0.5">{perm.desc}</div>
</td>
{roles.map(role => (
<td key={role} className="px-6 py-3 text-center">
<td key={role.sn} className="px-6 py-3 text-center">
<button
onClick={() => toggle(perm.id, role)}
onClick={() => togglePerm(role.sn, perm.id)}
className={`w-8 h-8 rounded-md border text-sm transition-all ${
matrix[perm.id]?.[role]
getPermGranted(role.sn, perm.id)
? 'bg-[rgba(6,182,212,0.15)] border-primary-cyan text-primary-cyan'
: 'bg-bg-2 border-border text-text-3 hover:border-text-3'
}`}
>
{matrix[perm.id]?.[role] ? '✓' : '—'}
{getPermGranted(role.sn, perm.id) ? '✓' : '—'}
</button>
</td>
))}
@ -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<RegistrationSettings | null>(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 <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
}
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> </p>
</div>
<div className="flex-1 overflow-auto px-6 py-6">
<div className="max-w-[640px] flex flex-col gap-6">
{/* 사용자 등록 설정 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
<p className="text-[11px] text-text-3 mt-0.5 font-korean"> </p>
</div>
<div className="divide-y divide-border">
{/* 자동 승인 */}
<div className="px-5 py-4 flex items-center justify-between">
<div className="flex-1 mr-4">
<div className="text-[13px] font-semibold text-text-1 font-korean"> </div>
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
<span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span> .
</p>
</div>
<button
onClick={() => handleToggle('autoApprove')}
disabled={saving}
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
settings?.autoApprove ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
} ${saving ? 'opacity-50' : ''}`}
>
<span
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
settings?.autoApprove ? 'left-[26px]' : 'left-0.5'
}`}
/>
</button>
</div>
{/* 기본 역할 자동 할당 */}
<div className="px-5 py-4 flex items-center justify-between">
<div className="flex-1 mr-4">
<div className="text-[13px] font-semibold text-text-1 font-korean"> </div>
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
<span className="text-primary-cyan font-semibold"> </span> .
.
</p>
</div>
<button
onClick={() => handleToggle('defaultRole')}
disabled={saving}
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
settings?.defaultRole ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
} ${saving ? 'opacity-50' : ''}`}
>
<span
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
settings?.defaultRole ? 'left-[26px]' : 'left-0.5'
}`}
/>
</button>
</div>
</div>
</div>
{/* 현재 설정 상태 요약 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
</div>
<div className="px-5 py-4">
<div className="flex flex-col gap-3 text-[12px] font-korean">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`} />
<span className="text-text-2">
{' '}
{settings?.autoApprove ? (
<span className="text-green-400 font-semibold"> </span>
) : (
<span className="text-yellow-400 font-semibold"> </span>
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-text-3'}`} />
<span className="text-text-2">
{' '}
{settings?.defaultRole ? (
<span className="text-green-400 font-semibold"></span>
) : (
<span className="text-text-3 font-semibold"></span>
)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// ─── AdminView ────────────────────────────────────────────
export function AdminView() {
const { activeSubTab } = useSubMenu('admin')
@ -305,6 +646,7 @@ export function AdminView() {
{activeSubTab === 'users' && <UsersPanel />}
{activeSubTab === 'permissions' && <PermissionsPanel />}
{activeSubTab === 'menus' && <MenusPanel />}
{activeSubTab === 'settings' && <SettingsPanel />}
</div>
</div>
)

파일 보기

@ -53,6 +53,19 @@ const mediaTagCls = (t: string) =>
// ── Tab 0: 영상·사진 관리 ──
const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) => (
<button
onClick={onClick}
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
active
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
{label}
</button>
)
function MediaManagementTab() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [equipFilter, setEquipFilter] = useState<string>('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 }) => (
<button
onClick={onClick}
className={`px-2.5 py-1 text-[10px] font-semibold rounded font-korean transition-colors ${
active
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
}`}
>
{label}
</button>
)
return (
<div className="flex flex-col h-full">
{/* 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 (
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
@ -894,15 +907,9 @@ function Vessel3DModel({ viewMode, status }: { viewMode: string; status: string
{/* 포인트 클라우드 모드 */}
{isPoint && <g>
{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 <circle key={i} cx={x} cy={y} r={0.8 + Math.random() * 0.8} fill={`rgba(6,182,212,${alpha})`} />
})}
{vesselPoints.map(p => p && (
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={`rgba(6,182,212,${p.alpha})`} />
))}
</g>}
{/* 선수/선미 표시 */}
@ -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 (
<div className="absolute inset-0 flex items-center justify-center" style={{ perspective: '800px' }}>
<div style={{ transform: 'rotateX(40deg) rotateY(-10deg)', transformStyle: 'preserve-3d', position: 'relative', width: '380px', height: '260px' }}>
@ -983,18 +1006,9 @@ function Pollution3DModel({ viewMode, status }: { viewMode: string; status: stri
{/* 포인트 클라우드 */}
{isPoint && <g>
{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 <circle key={i} cx={x} cy={y} r={0.6 + Math.random() * 1.2} fill={color} />
})}
{pollutionPoints.map(p => p && (
<circle key={p.i} cx={p.x} cy={p.y} r={p.r} fill={p.color} />
))}
</g>}
{/* 두께 색상 범례 */}
@ -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])

파일 보기

@ -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) => {

파일 보기

@ -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<any>(null)
const [recalcModalOpen, setRecalcModalOpen] = useState(false)

파일 보기

@ -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 (
<>
<Sec title="🏢 선주 / 운항사">

파일 보기

@ -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() {

파일 보기

@ -105,7 +105,6 @@ const substrateESI: Record<string, { esi: string; n: number }> = {
'모래': { 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)

파일 보기

@ -39,7 +39,8 @@ function generateReportHTML(
</div>${rows}</body></html>`
}
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<OilSpillReportData | null>(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)

파일 보기

@ -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 (
<div className="flex flex-col p-2.5 gap-2">
<div className="text-[12px] font-bold text-text-1 font-korean"> (DAMAGE STABILITY)</div>
@ -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 (
<div className="flex flex-col p-2.5 gap-2">
<div className="text-[12px] font-bold text-text-1 font-korean"> (LONGITUDINAL STRENGTH)</div>

파일 보기

@ -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])

파일 보기

@ -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로 회전 적용

파일 보기

@ -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])

파일 보기

@ -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) {

파일 보기

@ -57,7 +57,8 @@ const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
admin: [
{ id: 'users', label: '사용자 관리', icon: '👥' },
{ id: 'permissions', label: '사용자 권한 관리', icon: '🔐' },
{ id: 'menus', label: '메뉴 관리', icon: '📑' }
{ id: 'menus', label: '메뉴 관리', icon: '📑' },
{ id: 'settings', label: '시스템 설정', icon: '⚙️' }
]
}

파일 보기

@ -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 || '요청 처리 중 오류가 발생했습니다.',

파일 보기

@ -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<AuthUser> {
const response = await api.post<LoginResponse>('/auth/login', { account, password })
return response.data.user
}
export async function logoutApi(): Promise<void> {
await api.post('/auth/logout')
}
export async function fetchMe(): Promise<AuthUser> {
const response = await api.get<AuthUser>('/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<UserListItem[]> {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (status) params.set('status', status)
const response = await api.get<UserListItem[]>(`/users?${params}`)
return response.data
}
export async function fetchUser(id: string): Promise<UserListItem> {
const response = await api.get<UserListItem>(`/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<void> {
await api.put(`/users/${id}`, data)
}
export async function changePasswordApi(id: string, password: string): Promise<void> {
await api.put(`/users/${id}/password`, { password })
}
export async function assignRolesApi(id: string, roleSns: number[]): Promise<void> {
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<RoleWithPermissions[]> {
const response = await api.get<RoleWithPermissions[]>('/roles')
return response.data
}
export async function updatePermissionsApi(
roleSn: number,
permissions: Array<{ resourceCode: string; granted: boolean }>
): Promise<void> {
await api.put(`/roles/${roleSn}/permissions`, { permissions })
}
export async function updateRoleDefaultApi(roleSn: number, isDefault: boolean): Promise<void> {
await api.put(`/roles/${roleSn}/default`, { isDefault })
}
// 사용자 승인/거절 API (ADMIN 전용)
export async function approveUserApi(id: string): Promise<void> {
await api.put(`/users/${id}/approve`)
}
export async function rejectUserApi(id: string): Promise<void> {
await api.put(`/users/${id}/reject`)
}
// 시스템 설정 API (ADMIN 전용)
export interface RegistrationSettings {
autoApprove: boolean
defaultRole: boolean
}
export async function fetchRegistrationSettings(): Promise<RegistrationSettings> {
const response = await api.get<RegistrationSettings>('/settings/registration')
return response.data
}
export async function updateRegistrationSettingsApi(
settings: Partial<RegistrationSettings>
): Promise<RegistrationSettings> {
const response = await api.put<RegistrationSettings>('/settings/registration', settings)
return response.data
}

파일 보기

@ -149,10 +149,10 @@ export async function getVesselsInArea(bbox: BoundingBox): Promise<VesselPositio
},
})
return response.data.map((vessel: any) => ({
return response.data.map((vessel: Record<string, unknown>) => ({
...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<string, unknown>) => ({
...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)

파일 보기

@ -65,9 +65,9 @@ export async function getUltraShortForecast(
// 데이터를 시간대별로 그룹화
const forecasts: WeatherForecastData[] = []
const grouped = new Map<string, any>()
const grouped = new Map<string, Record<string, unknown>>()
items.forEach((item: any) => {
items.forEach((item: Record<string, string>) => {
const key = `${item.fcstDate}-${item.fcstTime}`
if (!grouped.has(key)) {
grouped.set(key, {

파일 보기

@ -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<string, number> = {}
items.forEach((item: Record<string, string>) => {
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<string, Record<string, string | number>> = {}
items.forEach((item: Record<string, string>) => {
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 데이터 반환

파일 보기

@ -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<void>
logout: () => Promise<void>
checkSession: () => Promise<void>
hasPermission: (resource: string) => boolean
clearError: () => void
}
export const useAuthStore = create<AuthState>((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 }),
}))

파일 보기

@ -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) {

파일 보기

@ -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',