release: v1.1.0 인증 시스템 릴리즈 #2
359
backend/package-lock.json
generated
359
backend/package-lock.json
generated
@ -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"
|
||||
}
|
||||
|
||||
45
backend/src/auth/authMiddleware.ts
Normal file
45
backend/src/auth/authMiddleware.ts
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
55
backend/src/auth/authRouter.ts
Normal file
55
backend/src/auth/authRouter.ts
Normal file
@ -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
|
||||
183
backend/src/auth/authService.ts
Normal file
183
backend/src/auth/authService.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
45
backend/src/auth/jwtProvider.ts
Normal file
45
backend/src/auth/jwtProvider.ts
Normal file
@ -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
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 }
|
||||
59
backend/src/roles/roleRouter.ts
Normal file
59
backend/src/roles/roleRouter.ts
Normal file
@ -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
|
||||
77
backend/src/roles/roleService.ts
Normal file
77
backend/src/roles/roleService.ts
Normal file
@ -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()
|
||||
})
|
||||
|
||||
49
backend/src/settings/settingsRouter.ts
Normal file
49
backend/src/settings/settingsRouter.ts
Normal file
@ -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
|
||||
69
backend/src/settings/settingsService.ts
Normal file
69
backend/src/settings/settingsService.ts
Normal file
@ -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,
|
||||
}))
|
||||
}
|
||||
148
backend/src/users/userRouter.ts
Normal file
148
backend/src/users/userRouter.ts
Normal file
@ -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
|
||||
294
backend/src/users/userService.ts
Normal file
294
backend/src/users/userService.ts
Normal file
@ -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
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) => {
|
||||
|
||||
346
frontend/src/components/auth/LoginPage.tsx
Normal file
346
frontend/src/components/auth/LoginPage.tsx
Normal file
@ -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)' }}>
|
||||
© 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,19 +103,30 @@ 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>
|
||||
<button
|
||||
onClick={() => onTabChange('admin')}
|
||||
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
|
||||
activeTab === 'admin'
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.15)] text-primary-cyan'
|
||||
: 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
⚙️
|
||||
</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">
|
||||
👤
|
||||
</button>
|
||||
{hasPermission('admin') && (
|
||||
<button
|
||||
onClick={() => onTabChange('admin')}
|
||||
className={`w-9 h-9 rounded-sm border flex items-center justify-center transition-all ${
|
||||
activeTab === 'admin'
|
||||
? 'border-primary-cyan bg-[rgba(6,182,212,0.15)] text-primary-cyan'
|
||||
: 'border-border bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1'
|
||||
}`}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
<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>
|
||||
<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">총 {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,97 +144,185 @@ function UsersPanel() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<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-right text-[11px] font-semibold text-text-3 font-korean">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((user) => (
|
||||
<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">
|
||||
<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`
|
||||
}}
|
||||
>
|
||||
{roleLabels[user.role].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>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-[11px] text-text-3 font-mono">{user.lastLogin}</td>
|
||||
<td className="px-6 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<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>
|
||||
{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-right text-[11px] font-semibold text-text-3 font-korean">관리</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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.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: `${roleInfo.color}20`,
|
||||
color: roleInfo.color,
|
||||
border: `1px solid ${roleInfo.color}40`
|
||||
}}
|
||||
>
|
||||
{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 ${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">{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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 권한 관리 패널 ─────────────────────────────────────────
|
||||
const PERM_RESOURCES = [
|
||||
{ id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' },
|
||||
{ id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' },
|
||||
{ id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' },
|
||||
{ id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' },
|
||||
{ id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' },
|
||||
{ id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' },
|
||||
{ id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' },
|
||||
{ id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' },
|
||||
{ id: 'board', label: '게시판', desc: '게시판 접근' },
|
||||
{ id: 'weather', label: '기상정보', desc: '기상 정보 조회' },
|
||||
{ id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' },
|
||||
]
|
||||
|
||||
function PermissionsPanel() {
|
||||
const roles = ['admin', 'manager', 'user', 'viewer']
|
||||
const permissions = [
|
||||
{ id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' },
|
||||
{ id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' },
|
||||
{ id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' },
|
||||
{ id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' },
|
||||
{ id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' },
|
||||
{ id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' },
|
||||
{ id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' },
|
||||
{ id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' },
|
||||
]
|
||||
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
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),
|
||||
useEffect(() => {
|
||||
loadRoles()
|
||||
}, [])
|
||||
|
||||
const loadRoles = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchRoles()
|
||||
setRoles(data)
|
||||
setDirty(false)
|
||||
} catch (err) {
|
||||
console.error('역할 목록 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPermGranted = (roleSn: number, resourceCode: string): boolean => {
|
||||
const role = roles.find(r => r.sn === roleSn)
|
||||
if (!role) return false
|
||||
const perm = role.permissions.find(p => p.resourceCode === resourceCode)
|
||||
return perm?.granted ?? false
|
||||
}
|
||||
|
||||
const togglePerm = (roleSn: number, resourceCode: string) => {
|
||||
setRoles(prev => prev.map(role => {
|
||||
if (role.sn !== roleSn) return role
|
||||
const perms = role.permissions.map(p =>
|
||||
p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p
|
||||
)
|
||||
if (!perms.find(p => p.resourceCode === resourceCode)) {
|
||||
perms.push({ sn: 0, resourceCode, granted: true })
|
||||
}
|
||||
})
|
||||
return m
|
||||
})
|
||||
|
||||
const toggle = (permId: string, role: string) => {
|
||||
setMatrix(prev => ({
|
||||
...prev,
|
||||
[permId]: { ...prev[permId], [role]: !prev[permId][role] }
|
||||
return { ...role, permissions: perms }
|
||||
}))
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const toggleDefault = async (roleSn: number) => {
|
||||
const role = roles.find(r => r.sn === roleSn)
|
||||
if (!role) return
|
||||
const newValue = !role.isDefault
|
||||
try {
|
||||
await updateRoleDefaultApi(roleSn, newValue)
|
||||
setRoles(prev => prev.map(r =>
|
||||
r.sn === roleSn ? { ...r, isDefault: newValue } : r
|
||||
))
|
||||
} catch (err) {
|
||||
console.error('기본 역할 변경 실패:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
for (const role of roles) {
|
||||
const permissions = PERM_RESOURCES.map(r => ({
|
||||
resourceCode: r.id,
|
||||
granted: getPermGranted(role.sn, r.id),
|
||||
}))
|
||||
await updatePermissionsApi(role.sn, permissions)
|
||||
}
|
||||
setDirty(false)
|
||||
} catch (err) {
|
||||
console.error('권한 저장 실패:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <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}
|
||||
</th>
|
||||
))}
|
||||
{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 || '요청 처리 중 오류가 발생했습니다.',
|
||||
|
||||
145
frontend/src/services/authApi.ts
Normal file
145
frontend/src/services/authApi.ts
Normal file
@ -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 데이터 반환
|
||||
|
||||
61
frontend/src/store/authStore.ts
Normal file
61
frontend/src/store/authStore.ts
Normal file
@ -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',
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user