diff --git a/.claude/scripts/on-commit.sh b/.claude/scripts/on-commit.sh new file mode 100755 index 0000000..f473403 --- /dev/null +++ b/.claude/scripts/on-commit.sh @@ -0,0 +1,14 @@ +#!/bin/bash +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "") +if echo "$COMMAND" | grep -qE 'git commit'; then + cat </dev/null || echo "") +if [ -z "$CWD" ]; then + CWD=$(pwd) +fi +PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g') +MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory" +CONTEXT="" +if [ -f "$MEMORY_DIR/MEMORY.md" ]; then + SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}" +fi +if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then + SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null) + CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}" +fi +if [ -n "$CONTEXT" ]; then + CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요." + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}" +else + echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}" +fi diff --git a/.claude/scripts/on-pre-compact.sh b/.claude/scripts/on-pre-compact.sh new file mode 100755 index 0000000..3f52f09 --- /dev/null +++ b/.claude/scripts/on-pre-compact.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가) +INPUT=$(cat) +cat < frontend/.npmrc echo "//nexus.gc-si.dev/repository/npm-public/:_auth=${{ secrets.NEXUS_NPM_AUTH }}" >> frontend/.npmrc + echo "registry=https://nexus.gc-si.dev/repository/npm-public/" > backend/.npmrc + echo "//nexus.gc-si.dev/repository/npm-public/:_auth=${{ secrets.NEXUS_NPM_AUTH }}" >> backend/.npmrc - - name: Install dependencies + # ── Frontend ── + - name: Install frontend dependencies run: | cd frontend npm ci - name: Build frontend + env: + VITE_API_URL: /api + VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} run: | cd frontend npx vite build - - name: Deploy + - name: Deploy frontend run: | rm -rf /deploy/wing-demo/* cp -r frontend/dist/* /deploy/wing-demo/ - echo "Deployed at $(date '+%Y-%m-%d %H:%M:%S')" - ls -la /deploy/wing-demo/ + echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')" + + # ── Backend ── + - name: Install backend dependencies + run: | + cd backend + npm ci --omit=dev + + - name: Build backend + run: | + cd backend + npx tsc + + - name: Deploy backend + run: | + mkdir -p /deploy/wing-demo-backend/dist + cp -r backend/dist/* /deploy/wing-demo-backend/dist/ + cp -r backend/node_modules /deploy/wing-demo-backend/ + cp backend/package.json /deploy/wing-demo-backend/ + date '+%s' > /deploy/wing-demo-backend/.deploy-trigger + echo "Backend deployed at $(date '+%Y-%m-%d %H:%M:%S')" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a9bcc80 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# WING-OPS (해양 방제 운영 지원 시스템) + +## 프로젝트 개요 +해양 오염 사고 대응을 위한 방제 운영 지원 시스템. +유류/HNS 확산 예측, 역추적 분석, 구조 시나리오, 항공 방제, 자산 관리, SCAT 조사, 기상/해상 정보를 통합 제공한다. + +- **프로젝트 타입**: react-ts (모노레포) +- **Frontend**: React 19 + Vite 7 + TypeScript 5.9 + Tailwind CSS 3 +- **Backend**: Express 4 + better-sqlite3 + TypeScript +- **상태관리**: Zustand (클라이언트), TanStack Query (서버) +- **지도**: Leaflet, OpenLayers +- **실시간**: Socket.IO + +## 빌드/실행 + +### Frontend +```bash +cd frontend +npm install +npm run dev # 개발 서버 (Vite, localhost:5173) +npm run build # 프로덕션 빌드 (tsc -b && vite build) +npm run lint # ESLint 검증 +npm run preview # 빌드 미리보기 +``` + +### Backend +```bash +cd backend +npm install +npm run dev # 개발 서버 (tsx watch, localhost:3001) +npm run build # TypeScript 컴파일 (tsc) +npm start # 프로덕션 실행 +npm run db:seed # DB 초기 데이터 +``` + +### Docker +```bash +docker-compose up -d # PostgreSQL 16 + PostGIS + pgAdmin +``` + +## 테스트 +테스트 프레임워크 미구성. 향후 Vitest + React Testing Library 도입 예정. + +## Lint/Format +```bash +cd frontend && npx eslint . # ESLint (flat config) +npx prettier --check . # Prettier 검증 +npx prettier --write . # Prettier 자동 수정 +``` + +## 프로젝트 구조 +``` +wing/ +├── frontend/ React 19 + Vite + TypeScript + Tailwind +│ └── src/ +│ ├── App.tsx 메인 (MainTab 라우팅, 11개 탭) +│ ├── components/ UI 컴포넌트 (13개 서브디렉토리) +│ │ ├── analysis/ HNS/Oil/Rescue 분석 시나리오 +│ │ ├── board/ 게시판 +│ │ ├── incidents/ 사건/사고 관리 +│ │ ├── layer/ 레이어 트리 +│ │ ├── layout/ MainLayout, TopBar, LeftPanel, RightPanel +│ │ ├── map/ MapView, BacktrackReplay +│ │ ├── reports/ 보고서 +│ │ ├── views/ 각 탭별 페이지 뷰 +│ │ └── weather/ 해양/기상 시각화 +│ ├── hooks/ 커스텀 훅 (useLayers, useOceanForecast 등) +│ ├── services/ API 서비스 (api, khoaApi, weatherApi 등) +│ ├── store/ Zustand 상태관리 +│ ├── types/ 타입 정의 +│ └── utils/ 유틸리티 (coordinates, geo, sanitize) +├── backend/ Express + better-sqlite3 +│ └── src/ +│ ├── server.ts Express 진입점 +│ ├── routes/ layers, simulation +│ ├── middleware/ security (입력 살균, rate-limit) +│ └── db/ database, seed +├── database/ SQL 초기화 스크립트 +├── docs/ 문서 +├── .claude/ 팀 워크플로우 (rules, skills, scripts) +├── .githooks/ Git hooks (pre-commit, commit-msg, post-checkout) +└── docker-compose.yml PostgreSQL + PostGIS + pgAdmin +``` + +## 팀 컨벤션 +`.claude/rules/` 디렉토리 참조: +- `team-policy.md` — 보안/품질 정책 +- `git-workflow.md` — 브랜치/커밋/MR 규칙 +- `code-style.md` — TypeScript/React 코드 스타일 +- `naming.md` — 네이밍 규칙 +- `testing.md` — 테스트 규칙 + +## 환경 설정 +- Node.js 20 (`.node-version`, fnm 사용) +- npm registry: Nexus proxy (`.npmrc`) +- Git hooks: `.githooks/` (core.hooksPath 설정됨) diff --git a/backend/package-lock.json b/backend/package-lock.json index c7d90e9..8bb0b5a 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "cors": "^2.8.5", "express": "^4.21.2", "express-rate-limit": "^8.2.1", + "google-auth-library": "^10.6.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "pg": "^8.19.0" @@ -474,6 +475,33 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@types/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", @@ -665,12 +693,51 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -716,6 +783,15 @@ "prebuild-install": "^7.1.1" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -787,6 +863,15 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -861,6 +946,24 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -933,6 +1036,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -999,6 +1142,12 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1014,6 +1163,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1207,6 +1362,35 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -1246,6 +1430,34 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1294,6 +1506,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1350,6 +1591,53 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1415,6 +1703,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1465,6 +1766,45 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -1550,6 +1890,12 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1631,6 +1977,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1640,6 +2001,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -1688,6 +2058,44 @@ "node": "^18 || ^20 || >= 21" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -1741,6 +2149,12 @@ "wrappy": "1" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1750,6 +2164,31 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -2023,6 +2462,21 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2121,6 +2575,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -2193,6 +2668,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -2265,6 +2752,102 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -2410,6 +2993,121 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 3d0f3d6..e09efd0 100755 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "cors": "^2.8.5", "express": "^4.21.2", "express-rate-limit": "^8.2.1", + "google-auth-library": "^10.6.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "pg": "^8.19.0" diff --git a/backend/src/auth/authRouter.ts b/backend/src/auth/authRouter.ts index b39af87..c374f8e 100644 --- a/backend/src/auth/authRouter.ts +++ b/backend/src/auth/authRouter.ts @@ -1,5 +1,6 @@ import { Router } from 'express' import { login, getUserInfo, AuthError } from './authService.js' +import { googleLogin } from './oauthService.js' import { clearTokenCookie } from './jwtProvider.js' import { requireAuth } from './authMiddleware.js' @@ -31,6 +32,32 @@ router.post('/login', async (req, res) => { } }) +// POST /api/auth/oauth/google +router.post('/oauth/google', async (req, res) => { + try { + const { credential } = req.body + + if (!credential) { + res.status(400).json({ error: 'Google 인증 토큰이 필요합니다.' }) + return + } + + const ipAddr = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || '' + const userAgent = req.headers['user-agent'] || '' + + const userInfo = await googleLogin(credential, 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] Google OAuth 오류:', err) + res.status(500).json({ error: 'Google 로그인 처리 중 오류가 발생했습니다.' }) + } +}) + // POST /api/auth/logout router.post('/logout', requireAuth, (_req, res) => { clearTokenCookie(res) diff --git a/backend/src/auth/authService.ts b/backend/src/auth/authService.ts index 30bfe14..1be0467 100644 --- a/backend/src/auth/authService.ts +++ b/backend/src/auth/authService.ts @@ -9,7 +9,7 @@ const SALT_ROUNDS = 10 interface AuthUserRow { user_id: string user_acnt: string - pswd_hash: string + pswd_hash: string | null user_nm: string rnkp_nm: string | null org_sn: number | null @@ -64,6 +64,10 @@ export async function login( throw new AuthError('가입이 거절된 계정입니다. 관리자에게 문의하세요.', 403) } + if (!user.pswd_hash) { + throw new AuthError('이 계정은 Google 로그인만 지원합니다.', 401) + } + const passwordValid = await bcrypt.compare(password, user.pswd_hash) if (!passwordValid) { diff --git a/backend/src/auth/oauthService.ts b/backend/src/auth/oauthService.ts new file mode 100644 index 0000000..f0b9732 --- /dev/null +++ b/backend/src/auth/oauthService.ts @@ -0,0 +1,178 @@ +import { OAuth2Client } from 'google-auth-library' +import { authPool } from '../db/authDb.js' +import { signToken, setTokenCookie } from './jwtProvider.js' +import { getSetting } from '../settings/settingsService.js' +import { AuthError, getUserInfo } from './authService.js' +import type { Response } from 'express' + +const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '' +const googleClient = new OAuth2Client(GOOGLE_CLIENT_ID) + +interface GoogleProfile { + sub: string + email: string + name: string + picture?: string + hd?: string // hosted domain (Google Workspace) +} + +export async function googleLogin( + credential: string, + ipAddr: string, + userAgent: string, + res: Response +) { + const profile = await verifyGoogleToken(credential) + + // 1. OAUTH_SUB로 기존 사용자 조회 + let userResult = await authPool.query( + 'SELECT USER_ID as user_id, USER_STTS_CD as user_stts_cd FROM AUTH_USER WHERE OAUTH_PROVIDER = $1 AND OAUTH_SUB = $2', + ['GOOGLE', profile.sub] + ) + + let userId: string + + if (userResult.rows.length > 0) { + // 기존 OAuth 사용자 + const user = userResult.rows[0] + userId = user.user_id + + 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) + } + } else { + // EMAIL로 기존 PW 사용자 조회 (계정 연결) + userResult = await authPool.query( + 'SELECT USER_ID as user_id, USER_STTS_CD as user_stts_cd FROM AUTH_USER WHERE EMAIL = $1 OR USER_ACNT = $1', + [profile.email] + ) + + if (userResult.rows.length > 0) { + // 기존 계정에 OAuth 연결 + const user = userResult.rows[0] + userId = user.user_id + + await authPool.query( + 'UPDATE AUTH_USER SET OAUTH_PROVIDER = $1, OAUTH_SUB = $2, EMAIL = $3, MDFCN_DTM = NOW() WHERE USER_ID = $4', + ['GOOGLE', profile.sub, profile.email, userId] + ) + + if (user.user_stts_cd !== 'ACTIVE') { + throw new AuthError('계정이 활성 상태가 아닙니다. 관리자에게 문의하세요.', 403) + } + } else { + // 신규 사용자 생성 + userId = await createOAuthUser(profile) + } + } + + // 로그인 처리 + await authPool.query( + 'UPDATE AUTH_USER SET LAST_LOGIN_DTM = NOW(), MDFCN_DTM = NOW() WHERE USER_ID = $1', + [userId] + ) + + await recordLoginHistory(userId, ipAddr, userAgent) + + const userInfo = await getUserInfo(userId) + + // PENDING 사용자는 JWT 발급하지 않음 + if (userInfo.roles.length === 0) { + const userStatus = await authPool.query( + 'SELECT USER_STTS_CD as user_stts_cd FROM AUTH_USER WHERE USER_ID = $1', + [userId] + ) + if (userStatus.rows[0]?.user_stts_cd === 'PENDING') { + throw new AuthError('계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.', 403) + } + } + + const token = signToken({ + sub: userInfo.id, + acnt: userInfo.account, + name: userInfo.name, + roles: userInfo.roles, + }) + + setTokenCookie(res, token) + return userInfo +} + +async function verifyGoogleToken(credential: string): Promise { + try { + const ticket = await googleClient.verifyIdToken({ + idToken: credential, + audience: GOOGLE_CLIENT_ID, + }) + + const payload = ticket.getPayload() + if (!payload || !payload.email || !payload.sub) { + throw new AuthError('Google 인증 정보가 유효하지 않습니다.', 401) + } + + return { + sub: payload.sub, + email: payload.email, + name: payload.name || payload.email.split('@')[0], + picture: payload.picture, + hd: payload.hd, + } + } catch (err) { + if (err instanceof AuthError) throw err + throw new AuthError('Google 인증 토큰 검증에 실패했습니다.', 401) + } +} + +async function createOAuthUser(profile: GoogleProfile): Promise { + const domain = profile.email.split('@')[1] + + // 자동 승인 도메인 확인 + const autoApproveDomains = await getSetting('oauth.auto-approve-domains') + const allowedDomains = autoApproveDomains + ? autoApproveDomains.split(',').map(d => d.trim().toLowerCase()) + : [] + + const isAutoApproved = allowedDomains.includes(domain.toLowerCase()) + const status = isAutoApproved ? 'ACTIVE' : 'PENDING' + + // 사용자 생성 + const result = await authPool.query( + `INSERT INTO AUTH_USER (USER_ACNT, USER_NM, EMAIL, OAUTH_PROVIDER, OAUTH_SUB, USER_STTS_CD) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING USER_ID as user_id`, + [profile.email, profile.name, profile.email, 'GOOGLE', profile.sub, status] + ) + + const userId = result.rows[0].user_id + + // 자동 승인된 사용자에게 기본 역할 할당 + if (isAutoApproved) { + await authPool.query( + `INSERT INTO AUTH_USER_ROLE (USER_ID, ROLE_SN) + SELECT $1, ROLE_SN FROM AUTH_ROLE WHERE DFLT_YN = 'Y'`, + [userId] + ) + } + + return userId +} + +async function recordLoginHistory( + userId: string, + ipAddr: string, + userAgent: string +): Promise { + await authPool.query( + `INSERT INTO AUTH_LOGIN_HIST (USER_ID, IP_ADDR, USER_AGENT, SUCCESS_YN) + VALUES ($1, $2, $3, 'Y')`, + [userId, ipAddr, userAgent?.substring(0, 500)] + ) +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 8ebbeea..d8afe60 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -38,7 +38,7 @@ app.use(helmet({ scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "blob:"], - connectSrc: ["'self'", "http://localhost:*", "https://*.data.go.kr", "https://*.khoa.go.kr"], + connectSrc: ["'self'", "http://localhost:*", "https://*.gc-si.dev", "https://*.data.go.kr", "https://*.khoa.go.kr"], fontSrc: ["'self'"], objectSrc: ["'none'"], frameSrc: ["'none'"], @@ -56,7 +56,8 @@ const allowedOrigins = [ 'http://localhost:5173', // Vite dev server 'http://localhost:5174', 'http://localhost:3000', - process.env.FRONTEND_URL, // 운영 환경 프론트엔드 URL + 'https://wing-demo.gc-si.dev', + process.env.FRONTEND_URL, // 운영 환경 프론트엔드 URL (추가 도메인) ].filter(Boolean) as string[] app.use(cors({ diff --git a/backend/src/settings/settingsRouter.ts b/backend/src/settings/settingsRouter.ts index f87f08c..a5e638c 100644 --- a/backend/src/settings/settingsRouter.ts +++ b/backend/src/settings/settingsRouter.ts @@ -3,6 +3,8 @@ import { requireAuth, requireRole } from '../auth/authMiddleware.js' import { getRegistrationSettings, updateRegistrationSettings, + getOAuthSettings, + updateOAuthSettings, getAllSettings, } from './settingsService.js' @@ -46,4 +48,28 @@ router.put('/registration', async (req, res) => { } }) +// GET /api/settings/oauth — OAuth 설정 조회 +router.get('/oauth', async (_req, res) => { + try { + const settings = await getOAuthSettings() + res.json(settings) + } catch (err) { + console.error('[settings] OAuth 설정 조회 오류:', err) + res.status(500).json({ error: 'OAuth 설정 조회 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/settings/oauth — OAuth 설정 수정 +router.put('/oauth', async (req, res) => { + try { + const { autoApproveDomains } = req.body + await updateOAuthSettings({ autoApproveDomains }) + const updated = await getOAuthSettings() + res.json(updated) + } catch (err) { + console.error('[settings] OAuth 설정 수정 오류:', err) + res.status(500).json({ error: 'OAuth 설정 수정 중 오류가 발생했습니다.' }) + } +}) + export default router diff --git a/backend/src/settings/settingsService.ts b/backend/src/settings/settingsService.ts index 719dfb4..d320faf 100644 --- a/backend/src/settings/settingsService.ts +++ b/backend/src/settings/settingsService.ts @@ -57,6 +57,21 @@ export async function updateRegistrationSettings(settings: { } } +export async function getOAuthSettings(): Promise<{ + autoApproveDomains: string +}> { + const autoApproveDomains = (await getSetting('oauth.auto-approve-domains')) || '' + return { autoApproveDomains } +} + +export async function updateOAuthSettings(settings: { + autoApproveDomains?: string +}): Promise { + if (settings.autoApproveDomains !== undefined) { + await setSetting('oauth.auto-approve-domains', settings.autoApproveDomains) + } +} + export async function getAllSettings(): Promise { const result = await authPool.query( 'SELECT SETTING_KEY, SETTING_VAL, SETTING_DC FROM AUTH_SETTING ORDER BY SETTING_KEY' diff --git a/backend/src/users/userService.ts b/backend/src/users/userService.ts index 36b135c..eb4092a 100644 --- a/backend/src/users/userService.ts +++ b/backend/src/users/userService.ts @@ -15,6 +15,8 @@ interface UserListItem { lastLogin: string | null roles: string[] regDtm: string + oauthProvider: string | null + email: string | null } interface CreateUserInput { @@ -39,7 +41,8 @@ export async function listUsers(search?: string, status?: string): Promise r.role_cd), regDtm: row.reg_dtm, + oauthProvider: row.oauth_provider, + email: row.email, }) } @@ -94,7 +99,8 @@ export async function getUser(userId: string): Promise { 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 + u.LAST_LOGIN_DTM as last_login, u.REG_DTM as reg_dtm, + u.OAUTH_PROVIDER as oauth_provider, u.EMAIL as email FROM AUTH_USER u LEFT JOIN AUTH_ORG o ON u.ORG_SN = o.ORG_SN WHERE u.USER_ID = $1`, @@ -125,6 +131,8 @@ export async function getUser(userId: string): Promise { lastLogin: row.last_login, roles: rolesResult.rows.map((r: { role_cd: string }) => r.role_cd), regDtm: row.reg_dtm, + oauthProvider: row.oauth_provider, + email: row.email, } } diff --git a/database/auth_init.sql b/database/auth_init.sql index 18ba464..0383e08 100644 --- a/database/auth_init.sql +++ b/database/auth_init.sql @@ -74,13 +74,16 @@ COMMENT ON COLUMN AUTH_ROLE.REG_DTM IS '등록일시'; 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, + PSWD_HASH VARCHAR(255), 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, + OAUTH_PROVIDER VARCHAR(20), + OAUTH_SUB VARCHAR(255), + EMAIL VARCHAR(255), REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT PK_AUTH_USER PRIMARY KEY (USER_ID), @@ -100,6 +103,9 @@ COMMENT ON COLUMN AUTH_USER.USER_STTS_CD IS '사용자상태코드 (PENDING: 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.OAUTH_PROVIDER IS 'OAuth제공자 (GOOGLE 등)'; +COMMENT ON COLUMN AUTH_USER.OAUTH_SUB IS 'OAuth고유식별자'; +COMMENT ON COLUMN AUTH_USER.EMAIL IS '이메일주소'; COMMENT ON COLUMN AUTH_USER.MDFCN_DTM IS '수정일시'; @@ -191,6 +197,8 @@ COMMENT ON COLUMN AUTH_SETTING.MDFCN_DTM IS '수정일시'; -- ============================================================ CREATE INDEX IDX_AUTH_USER_STTS ON AUTH_USER (USER_STTS_CD); CREATE INDEX IDX_AUTH_USER_ORG ON AUTH_USER (ORG_SN); +CREATE UNIQUE INDEX UK_AUTH_USER_OAUTH ON AUTH_USER(OAUTH_PROVIDER, OAUTH_SUB) WHERE OAUTH_PROVIDER IS NOT NULL; +CREATE UNIQUE INDEX UK_AUTH_USER_EMAIL ON AUTH_USER(EMAIL) WHERE EMAIL IS NOT NULL; 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); @@ -272,7 +280,8 @@ SELECT USER_ID, 1 FROM AUTH_USER WHERE USER_ACNT = 'admin'; -- ============================================================ 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) 자동 할당 여부'); +('registration.default-role', 'true', '신규 사용자에게 기본 역할(DFLT_YN=Y) 자동 할당 여부'), +('oauth.auto-approve-domains', 'gcsc.co.kr', 'OAuth 자동 승인 도메인 (쉼표 구분)'); -- ============================================================ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e738047..7e427d4 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@react-oauth/google": "^0.13.4", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.5", "leaflet": "^1.9.4", @@ -82,7 +83,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1090,6 +1090,16 @@ "react-dom": "^19.0.0" } }, + "node_modules/@react-oauth/google": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz", + "integrity": "sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1561,7 +1571,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1578,7 +1587,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1638,7 +1646,6 @@ "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", @@ -1900,7 +1907,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2127,7 +2133,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2554,7 +2559,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3238,7 +3242,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3324,8 +3327,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/lerc": { "version": "3.0.0", @@ -3724,7 +3726,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3772,7 +3773,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4001,7 +4001,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4011,7 +4010,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4474,7 +4472,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4586,7 +4583,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4759,7 +4755,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 8388dcf..2091db7 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@react-oauth/google": "^0.13.4", "@tanstack/react-query": "^5.90.21", "axios": "^1.13.5", "leaflet": "^1.9.4", diff --git a/frontend/public/24.png b/frontend/public/24.png new file mode 100644 index 0000000..bea566b Binary files /dev/null and b/frontend/public/24.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ce7590d..a6fdb0e 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { GoogleOAuthProvider } from '@react-oauth/google' import { MainLayout } from './components/layout/MainLayout' import { LoginPage } from './components/auth/LoginPage' import { registerMainTabSwitcher } from './hooks/useSubMenu' @@ -17,6 +18,8 @@ import { RescueView } from './components/views/RescueView' export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin' +const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || '' + function App() { const [activeMainTab, setActiveMainTab] = useState('prediction') const { isAuthenticated, isLoading, checkSession } = useAuthStore() @@ -88,4 +91,15 @@ function App() { ) } -export default App +function AppWithProviders() { + if (!GOOGLE_CLIENT_ID) { + return + } + return ( + + + + ) +} + +export default AppWithProviders diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx index 69672fd..b91a940 100644 --- a/frontend/src/components/auth/LoginPage.tsx +++ b/frontend/src/components/auth/LoginPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react' +import { GoogleLogin, type CredentialResponse } from '@react-oauth/google' import { useAuthStore } from '../../store/authStore' /* Demo accounts (개발 모드 전용) */ @@ -10,7 +11,8 @@ export function LoginPage() { const [userId, setUserId] = useState('') const [password, setPassword] = useState('') const [remember, setRemember] = useState(false) - const { login, isLoading, error, clearError } = useAuthStore() + const { login, googleLogin, isLoading, error, clearError } = useAuthStore() + const GOOGLE_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID useEffect(() => { const saved = localStorage.getItem('wing_remember') @@ -21,6 +23,17 @@ export function LoginPage() { } }, []) + const handleGoogleSuccess = async (response: CredentialResponse) => { + if (response.credential) { + clearError() + try { + await googleLogin(response.credential) + } catch { + // 에러는 authStore에서 관리 + } + } + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() clearError() @@ -263,10 +276,25 @@ export function LoginPage() {
- {/* SSO / Certificate */} -
+ {/* Google / Certificate */} +
+ {GOOGLE_ENABLED && ( +
+ { /* 팝업 닫힘 등 — 별도 처리 불필요 */ }} + theme="filled_black" + size="large" + shape="rectangular" + width={304} + /> +
+ )} -
{/* Demo accounts info (DEV only) */} diff --git a/frontend/src/components/views/AdminView.tsx b/frontend/src/components/views/AdminView.tsx index 6a39455..3a1e204 100755 --- a/frontend/src/components/views/AdminView.tsx +++ b/frontend/src/components/views/AdminView.tsx @@ -10,9 +10,12 @@ import { rejectUserApi, fetchRegistrationSettings, updateRegistrationSettingsApi, + fetchOAuthSettings, + updateOAuthSettingsApi, type UserListItem, type RoleWithPermissions, type RegistrationSettings, + type OAuthSettings, } from '../../services/authApi' const roleLabels: Record = { @@ -154,6 +157,7 @@ function UsersPanel() { 계정 소속 역할 + 인증 상태 최근 로그인 관리 @@ -181,6 +185,26 @@ function UsersPanel() { {roleInfo.label} + + {user.oauthProvider ? ( + + + Google + + ) : ( + + + ID/PW + + )} + @@ -494,8 +518,11 @@ function MenusPanel() { // ─── 시스템 설정 패널 ──────────────────────────────────────── function SettingsPanel() { const [settings, setSettings] = useState(null) + const [oauthSettings, setOauthSettings] = useState(null) + const [oauthDomainInput, setOauthDomainInput] = useState('') const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + const [savingOAuth, setSavingOAuth] = useState(false) useEffect(() => { loadSettings() @@ -504,8 +531,13 @@ function SettingsPanel() { const loadSettings = async () => { setLoading(true) try { - const data = await fetchRegistrationSettings() - setSettings(data) + const [regData, oauthData] = await Promise.all([ + fetchRegistrationSettings(), + fetchOAuthSettings(), + ]) + setSettings(regData) + setOauthSettings(oauthData) + setOauthDomainInput(oauthData.autoApproveDomains) } catch (err) { console.error('설정 조회 실패:', err) } finally { @@ -598,6 +630,68 @@ function SettingsPanel() {
+ {/* OAuth 설정 */} +
+
+

Google OAuth 설정

+

Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다

+
+
+
+
자동 승인 도메인
+

+ 지정된 도메인의 Google 계정은 가입 즉시 ACTIVE 상태가 됩니다. + 미지정 도메인은 PENDING 상태로 관리자 승인이 필요합니다. + 여러 도메인은 쉼표(,)로 구분합니다. +

+
+ setOauthDomainInput(e.target.value)} + placeholder="gcsc.co.kr, example.com" + className="flex-1 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-mono" + /> + +
+
+ {oauthSettings?.autoApproveDomains && ( +
+ {oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => ( + + @{domain} + + ))} +
+ )} +
+
+ {/* 현재 설정 상태 요약 */}
@@ -627,6 +721,17 @@ function SettingsPanel() { )}
+
+ + + Google OAuth 자동 승인 도메인{' '} + {oauthSettings?.autoApproveDomains ? ( + {oauthSettings.autoApproveDomains} + ) : ( + 미설정 + )} + +
diff --git a/frontend/src/services/authApi.ts b/frontend/src/services/authApi.ts index d3190ff..86d39f9 100644 --- a/frontend/src/services/authApi.ts +++ b/frontend/src/services/authApi.ts @@ -20,6 +20,11 @@ export async function loginApi(account: string, password: string): Promise { + const response = await api.post('/auth/oauth/google', { credential }) + return response.data.user +} + export async function logoutApi(): Promise { await api.post('/auth/logout') } @@ -43,6 +48,8 @@ export interface UserListItem { lastLogin: string | null roles: string[] regDtm: string + oauthProvider: string | null + email: string | null } export async function fetchUsers(search?: string, status?: string): Promise { @@ -143,3 +150,20 @@ export async function updateRegistrationSettingsApi( const response = await api.put('/settings/registration', settings) return response.data } + +// OAuth 설정 API (ADMIN 전용) +export interface OAuthSettings { + autoApproveDomains: string +} + +export async function fetchOAuthSettings(): Promise { + const response = await api.get('/settings/oauth') + return response.data +} + +export async function updateOAuthSettingsApi( + settings: Partial +): Promise { + const response = await api.put('/settings/oauth', settings) + return response.data +} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 3bc20fe..2478313 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import { loginApi, logoutApi, fetchMe } from '../services/authApi' +import { loginApi, googleLoginApi, logoutApi, fetchMe } from '../services/authApi' import type { AuthUser } from '../services/authApi' interface AuthState { @@ -8,6 +8,7 @@ interface AuthState { isLoading: boolean error: string | null login: (account: string, password: string) => Promise + googleLogin: (credential: string) => Promise logout: () => Promise checkSession: () => Promise hasPermission: (resource: string) => boolean @@ -32,6 +33,18 @@ export const useAuthStore = create((set, get) => ({ } }, + googleLogin: async (credential: string) => { + set({ isLoading: true, error: null }) + try { + const user = await googleLoginApi(credential) + set({ user, isAuthenticated: true, isLoading: false }) + } catch (err) { + const message = (err as { message?: string })?.message || 'Google 로그인에 실패했습니다.' + set({ isLoading: false, error: message }) + throw err + } + }, + logout: async () => { try { await logoutApi()