Compare commits
5 Commits
main
...
feature/sp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba76345958 | ||
| ae628bc797 | |||
| bd2f52ddcd | |||
| 1294987314 | |||
| 6c518bb231 |
52
.qa/SPRINT-006-review-1.md
Normal file
52
.qa/SPRINT-006-review-1.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# SPRINT-006 QA Review — Iteration 1
|
||||
|
||||
- **검증일시:** 2026-04-04 15:10 KST
|
||||
- **검증자:** 다랑이 (Evaluator)
|
||||
- **결과:** ❌ FAILED (blocking 5건)
|
||||
|
||||
## 검증 항목
|
||||
|
||||
| 항목 | 결과 |
|
||||
|------|------|
|
||||
| npm install | ✅ |
|
||||
| prisma generate | ✅ |
|
||||
| npm test (backend) | ✅ 26/26 pass |
|
||||
| npm run build (backend) | ✅ |
|
||||
| npm run build (frontend) | ✅ 16 routes |
|
||||
|
||||
## 🔴 Blocking 이슈
|
||||
|
||||
### B1: JWT_SECRET fallback 'changeme' — 3곳 (Critical)
|
||||
- **파일:** auth.service.ts:85, auth.module.ts:22, events.gateway.ts:48
|
||||
- **문제:** JWT_SECRET 미설정 시 'changeme'로 토큰 서명 → 공격자가 유효한 JWT 위조 가능
|
||||
- **모순:** jwt.strategy.ts는 throw하는데 나머지는 fallback → 불일치
|
||||
- **수정:** 3곳 모두 `?? 'changeme'` 제거, ConfigService.getOrThrow 또는 throw 처리
|
||||
|
||||
### B2: User.role 기본값 "admin" (Critical)
|
||||
- **파일:** prisma/schema.prisma
|
||||
- **문제:** 모든 가입자 자동 admin → 초대코드만 알면 SSH restart, 하네스 수정 등 전체 인프라 제어
|
||||
- **수정:** `@default("viewer")` + admin 승격 별도 로직 + AdminController에 RoleGuard 추가
|
||||
|
||||
### B3: WebSocket JWT 실패 시 disconnect 안 함
|
||||
- **파일:** events.gateway.ts:52-53
|
||||
- **문제:** invalid token 시 연결 유지 → 미인증 실시간 데이터 수신
|
||||
- **수정:** catch 블록에서 `client.disconnect()` 호출
|
||||
|
||||
### B4: `throw new Error` → HttpException
|
||||
- **파일:** auth.controller.ts:32
|
||||
- **문제:** refresh token 없을 때 `throw new Error` → 500 반환
|
||||
- **수정:** `throw new BadRequestException('Refresh token required')`
|
||||
|
||||
### B5: register 엔드포인트 rate limit 없음
|
||||
- **파일:** auth.controller.ts:18-20
|
||||
- **문제:** 초대코드 brute-force 가능
|
||||
- **수정:** `@UseGuards(ThrottlerGuard) @Throttle({ default: { limit: 3, ttl: 60000 } })` 추가
|
||||
|
||||
## Non-blocking 이슈 (7건)
|
||||
- N1: refresh token localStorage 평문 (httpOnly cookie 전환 권장)
|
||||
- N2: AuthProvider 토큰 만료 시 자동 refresh 안 함
|
||||
- N3: register에서 refreshToken 미저장
|
||||
- N4: login 후 userId: 0 하드코딩
|
||||
- N5: DTO 서비스 파일 내 정의 (별도 파일 권장)
|
||||
- N6: adminFetch 주석-코드 불일치
|
||||
- N7: refresh secret suffix 방식 → 별도 env 권장
|
||||
23
.qa/SPRINT-006-review-2.md
Normal file
23
.qa/SPRINT-006-review-2.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# SPRINT-006 QA Review — Iteration 2
|
||||
|
||||
- **검증일시:** 2026-04-04 15:17 KST
|
||||
- **검증자:** 다랑이 (Evaluator)
|
||||
- **결과:** ✅ PASSED
|
||||
|
||||
## Blocking 수정 확인
|
||||
|
||||
| ID | 이슈 | 수정 확인 |
|
||||
|----|------|----------|
|
||||
| B1 (Critical) | JWT_SECRET 'changeme' 4곳 | ✅ 전부 제거, 0건 grep 확인 |
|
||||
| B2 (Critical) | User.role "admin" | ✅ @default("viewer") + RoleGuard + @Roles('admin') on Admin/Costs |
|
||||
| B3 | WS disconnect | ✅ catch 블록에서 client.disconnect() 호출 |
|
||||
| B4 | throw Error | ✅ BadRequestException 변경 |
|
||||
| B5 | register Throttle | ✅ @Throttle 3회/분 적용 |
|
||||
|
||||
## 검증 항목
|
||||
|
||||
| 항목 | 결과 |
|
||||
|------|------|
|
||||
| git fetch + reset | ✅ |
|
||||
| npm test | ✅ 26/26 pass |
|
||||
| npm run build | ✅ |
|
||||
223
backend/package-lock.json
generated
223
backend/package-lock.json
generated
@@ -12,13 +12,16 @@
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.18",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.18",
|
||||
"@prisma/adapter-mariadb": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"axios": "^1.14.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dotenv": "^17.4.0",
|
||||
@@ -27,6 +30,7 @@
|
||||
"node-ssh": "^13.2.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-http-bearer": "^1.0.1",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"prisma": "^7.6.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -38,10 +42,12 @@
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-http-bearer": "^1.0.42",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
@@ -2288,6 +2294,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/jwt": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz",
|
||||
"integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"jsonwebtoken": "9.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/passport": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz",
|
||||
@@ -2477,6 +2496,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/throttler": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/websockets": {
|
||||
"version": "11.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.18.tgz",
|
||||
@@ -3110,6 +3140,16 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"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/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -3286,6 +3326,16 @@
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/keygrip": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz",
|
||||
@@ -3327,6 +3377,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
@@ -3358,6 +3414,28 @@
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "*",
|
||||
"@types/passport-strategy": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport-strategy": {
|
||||
"version": "0.2.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
|
||||
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*",
|
||||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
@@ -4637,6 +4715,20 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"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/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
@@ -4800,6 +4892,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/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
@@ -5622,6 +5720,15 @@
|
||||
"dev": true,
|
||||
"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",
|
||||
"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",
|
||||
@@ -8212,6 +8319,49 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"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/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -8315,6 +8465,42 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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.memoize": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
@@ -8329,6 +8515,12 @@
|
||||
"dev": true,
|
||||
"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/log-symbols": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
|
||||
@@ -8773,6 +8965,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-emoji": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
|
||||
@@ -8789,6 +8990,17 @@
|
||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -9124,6 +9336,16 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-jwt": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
"integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
@@ -9828,7 +10050,6 @@
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
||||
@@ -24,13 +24,16 @@
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.18",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.18",
|
||||
"@prisma/adapter-mariadb": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"axios": "^1.14.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dotenv": "^17.4.0",
|
||||
@@ -39,6 +42,7 @@
|
||||
"node-ssh": "^13.2.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-http-bearer": "^1.0.1",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"prisma": "^7.6.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
@@ -53,10 +57,12 @@
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-http-bearer": "^1.0.42",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
||||
@@ -93,3 +93,12 @@ model CostLog {
|
||||
@@index([sisterName, recordedAt])
|
||||
@@index([recordedAt])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
password String // bcrypt hash
|
||||
role String @default("viewer")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AdminService } from './admin.service';
|
||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||
import { CompositeGuard } from '../auth/jwt.guard';
|
||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||
import type { SisterName } from '../common/sister-name.pipe';
|
||||
|
||||
@@ -23,7 +24,8 @@ class UpdateHarnessDto {
|
||||
}
|
||||
|
||||
@Controller('api/admin')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@UseGuards(CompositeGuard, RoleGuard)
|
||||
@Roles('admin')
|
||||
export class AdminController {
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -18,6 +19,7 @@ import { CostsModule } from './costs/costs.module';
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
|
||||
PrismaModule,
|
||||
SistersModule,
|
||||
HealthModule,
|
||||
|
||||
@@ -18,13 +18,13 @@ export class ApiKeyStrategy extends PassportStrategy(Strategy, 'api-key') {
|
||||
);
|
||||
}
|
||||
|
||||
validate(token: string): boolean {
|
||||
validate(token: string): { role: string; username: string } {
|
||||
if (!this.validKeys.size) {
|
||||
throw new UnauthorizedException('ADMIN_API_KEYS not configured');
|
||||
}
|
||||
if (!this.validKeys.has(token)) {
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
return true;
|
||||
return { role: "admin", username: "api-key" };
|
||||
}
|
||||
}
|
||||
|
||||
44
backend/src/auth/auth.controller.ts
Normal file
44
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Headers,
|
||||
UseGuards,
|
||||
Request,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService, RegisterDto, LoginDto } from './auth.service';
|
||||
import { JwtGuard } from './jwt.guard';
|
||||
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
@Controller('api/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@Post('register')
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
refresh(@Headers('x-refresh-token') token: string) {
|
||||
if (!token) throw new BadRequestException('Refresh token required');
|
||||
return this.authService.refresh(token);
|
||||
}
|
||||
|
||||
@UseGuards(JwtGuard)
|
||||
@Get('me')
|
||||
getMe(@Request() req: any) {
|
||||
return this.authService.getMe(req.user.userId);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { ApiKeyStrategy } from './api-key.strategy';
|
||||
import { JwtGuard, CompositeGuard } from './jwt.guard';
|
||||
import { ApiKeyGuard } from './api-key.guard';
|
||||
import { RoleGuard } from './role.guard';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PassportModule, ConfigModule],
|
||||
providers: [ApiKeyStrategy, ApiKeyGuard],
|
||||
exports: [ApiKeyGuard],
|
||||
imports: [
|
||||
PassportModule,
|
||||
ConfigModule,
|
||||
PrismaModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: (() => { const s = config.get<string>('JWT_SECRET'); if (!s) throw new Error('JWT_SECRET is not configured'); return s; })(),
|
||||
signOptions: { expiresIn: '15m' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, ApiKeyStrategy, JwtGuard, CompositeGuard, ApiKeyGuard, RoleGuard],
|
||||
exports: [JwtGuard, CompositeGuard, ApiKeyGuard, RoleGuard, AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
79
backend/src/auth/auth.service.spec.ts
Normal file
79
backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { BadRequestException, ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
const mockPrisma = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockJwt = {
|
||||
sign: jest.fn().mockReturnValue('mock.jwt.token'),
|
||||
verify: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
get: jest.fn().mockImplementation((key: string) => {
|
||||
if (key === 'JWT_SECRET') return 'test-secret';
|
||||
if (key === 'INVITE_CODE') return 'VALID_CODE';
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: JwtService, useValue: mockJwt },
|
||||
{ provide: ConfigService, useValue: mockConfig },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('register: 잘못된 초대 코드 → BadRequestException', async () => {
|
||||
await expect(
|
||||
service.register({ username: 'test', password: 'password123', inviteCode: 'WRONG' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('register: 중복 username → ConflictException', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ id: 1, username: 'test' });
|
||||
await expect(
|
||||
service.register({ username: 'test', password: 'password123', inviteCode: 'VALID_CODE' }),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('register: 성공 시 토큰 반환', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.user.create.mockResolvedValue({ id: 1, username: 'newuser', role: 'admin' });
|
||||
|
||||
const result = await service.register({
|
||||
username: 'newuser',
|
||||
password: 'password123',
|
||||
inviteCode: 'VALID_CODE',
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('accessToken');
|
||||
expect(result).toHaveProperty('refreshToken');
|
||||
expect(result.username).toBe('newuser');
|
||||
});
|
||||
|
||||
it('login: 존재하지 않는 사용자 → UnauthorizedException', async () => {
|
||||
mockPrisma.user.findUnique.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.login({ username: 'unknown', password: 'password123' }),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
101
backend/src/auth/auth.service.ts
Normal file
101
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Injectable, UnauthorizedException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString() @IsNotEmpty() @MaxLength(32)
|
||||
username!: string;
|
||||
|
||||
@IsString() @MinLength(8) @MaxLength(64)
|
||||
password!: string;
|
||||
|
||||
@IsString() @IsNotEmpty()
|
||||
inviteCode!: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsString() @IsNotEmpty()
|
||||
username!: string;
|
||||
|
||||
@IsString() @IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async register(dto: RegisterDto) {
|
||||
const inviteCode = this.config.get<string>('INVITE_CODE');
|
||||
if (!inviteCode) throw new BadRequestException('Invite code not configured');
|
||||
if (dto.inviteCode !== inviteCode) throw new BadRequestException('Invalid invite code');
|
||||
|
||||
const existing = await this.prisma.user.findUnique({ where: { username: dto.username } });
|
||||
if (existing) throw new ConflictException('Username already taken');
|
||||
|
||||
const hash = await bcrypt.hash(dto.password, 12);
|
||||
const user = await this.prisma.user.create({
|
||||
data: { username: dto.username, password: hash },
|
||||
});
|
||||
|
||||
return this.signTokens(user.id, user.username, user.role);
|
||||
}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { username: dto.username } });
|
||||
if (!user) throw new UnauthorizedException('Invalid credentials');
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.password);
|
||||
if (!ok) throw new UnauthorizedException('Invalid credentials');
|
||||
|
||||
return this.signTokens(user.id, user.username, user.role);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
try {
|
||||
const payload = this.jwt.verify<{ sub: number; username: string; role: string }>(
|
||||
refreshToken,
|
||||
{ secret: this.config.get<string>('JWT_SECRET') + '_refresh' },
|
||||
);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
return this.signTokens(user.id, user.username, user.role);
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
async getMe(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, username: true, role: true, createdAt: true },
|
||||
});
|
||||
if (!user) throw new UnauthorizedException();
|
||||
return user;
|
||||
}
|
||||
|
||||
private signTokens(userId: number, username: string, role: string) {
|
||||
const secret = this.config.get<string>('JWT_SECRET');
|
||||
if (!secret) throw new Error('JWT_SECRET is not configured');
|
||||
const payload = { sub: userId, username, role };
|
||||
|
||||
const accessToken = this.jwt.sign(payload, {
|
||||
secret,
|
||||
expiresIn: '15m',
|
||||
});
|
||||
|
||||
const refreshToken = this.jwt.sign(payload, {
|
||||
secret: secret + '_refresh',
|
||||
expiresIn: '7d',
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken, username, role };
|
||||
}
|
||||
}
|
||||
22
backend/src/auth/jwt.guard.ts
Normal file
22
backend/src/auth/jwt.guard.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard('jwt') {
|
||||
handleRequest<T>(err: Error, user: T): T {
|
||||
if (err || !user) throw new UnauthorizedException('JWT authentication required');
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 먼저 시도 → 실패 시 API Key fallback
|
||||
* Sprint 008까지 하위호환 유지
|
||||
*/
|
||||
@Injectable()
|
||||
export class CompositeGuard extends AuthGuard(['jwt', 'api-key']) {
|
||||
handleRequest<T>(err: Error, user: T): T {
|
||||
if (err || !user) throw new UnauthorizedException('Authentication required');
|
||||
return user;
|
||||
}
|
||||
}
|
||||
28
backend/src/auth/jwt.strategy.ts
Normal file
28
backend/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(config: ConfigService) {
|
||||
const secret = config.get<string>('JWT_SECRET');
|
||||
if (!secret) throw new Error('JWT_SECRET not set');
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload) {
|
||||
if (!payload?.sub) throw new UnauthorizedException();
|
||||
return { userId: payload.sub, username: payload.username, role: payload.role };
|
||||
}
|
||||
}
|
||||
29
backend/src/auth/role.guard.ts
Normal file
29
backend/src/auth/role.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
@Injectable()
|
||||
export class RoleGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) throw new ForbiddenException('Authentication required');
|
||||
|
||||
const hasRole = requiredRoles.includes(user.role);
|
||||
if (!hasRole) throw new ForbiddenException(`Role '${user.role}' not authorized. Required: ${requiredRoles.join(', ')}`);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Controller, Get, Post, Query, Param, UseGuards } from '@nestjs/common';
|
||||
import { CostsService, CostPeriod } from './costs.service';
|
||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||
import { CompositeGuard } from '../auth/jwt.guard';
|
||||
import { RoleGuard, Roles } from '../auth/role.guard';
|
||||
import { SisterNamePipe } from '../common/sister-name.pipe';
|
||||
import type { SisterName } from '../common/sister-name.pipe';
|
||||
|
||||
@Controller('api/admin/costs')
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@UseGuards(CompositeGuard, RoleGuard)
|
||||
@Roles('admin')
|
||||
export class CostsController {
|
||||
constructor(private readonly costsService: CostsService) {}
|
||||
|
||||
|
||||
@@ -49,19 +49,20 @@ export class CostsService {
|
||||
if (!sister) return;
|
||||
|
||||
try {
|
||||
// OpenClaw 세션에서 토큰 사용량 파싱
|
||||
// OpenClaw 세션에서 토큰 사용량 파싱 (python3 → node.js → jq fallback)
|
||||
const result = await this.ssh.executeCommand(
|
||||
sister.ip,
|
||||
sister.user,
|
||||
keyPath,
|
||||
`cat ~/.openclaw/sessions/main.json 2>/dev/null | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
usage=d.get('usage',{})
|
||||
print(d.get('model','unknown'), usage.get('input_tokens',0), usage.get('output_tokens',0))
|
||||
except: print('unknown 0 0')
|
||||
" 2>/dev/null || echo "unknown 0 0"`,
|
||||
`SESSION_FILE=~/.openclaw/sessions/main.json; \
|
||||
if [ ! -f "$SESSION_FILE" ]; then echo "unknown 0 0"; \
|
||||
elif command -v node >/dev/null 2>&1; then \
|
||||
node -e "try{const d=require('fs').readFileSync(process.env.HOME+'/.openclaw/sessions/main.json','utf8');const j=JSON.parse(d);const u=j.usage||{};console.log((j.model||'unknown')+' '+(u.input_tokens||0)+' '+(u.output_tokens||0))}catch(e){console.log('unknown 0 0')}" 2>/dev/null; \
|
||||
elif command -v python3 >/dev/null 2>&1; then \
|
||||
cat $SESSION_FILE | python3 -c "import json,sys;d=json.load(sys.stdin);u=d.get('usage',{});print(d.get('model','unknown'),u.get('input_tokens',0),u.get('output_tokens',0))" 2>/dev/null; \
|
||||
elif command -v jq >/dev/null 2>&1; then \
|
||||
echo "$(jq -r '.model // "unknown"' $SESSION_FILE) $(jq -r '.usage.input_tokens // 0' $SESSION_FILE) $(jq -r '.usage.output_tokens // 0' $SESSION_FILE)" 2>/dev/null; \
|
||||
else echo "unknown 0 0"; fi`,
|
||||
);
|
||||
|
||||
const parts = result.stdout.trim().split(' ');
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
@@ -26,12 +28,41 @@ export class EventsGateway
|
||||
|
||||
private readonly logger = new Logger(EventsGateway.name);
|
||||
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
afterInit() {
|
||||
this.logger.log('WebSocket Gateway initialized');
|
||||
}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.debug(`Client connected: ${client.id}`);
|
||||
// JWT 인증 필수 — 토큰 없거나 유효하지 않으면 disconnect
|
||||
const token =
|
||||
(client.handshake.auth?.token as string) ??
|
||||
(client.handshake.headers.authorization as string)?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
this.logger.debug(`WS rejected (no token): ${client.id}`);
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const secret = this.config.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify(token, { secret });
|
||||
(client as any).user = payload;
|
||||
this.logger.debug(`WS client connected: ${client.id} (${payload.username})`);
|
||||
} catch {
|
||||
this.logger.debug(`WS rejected (invalid token): ${client.id}`);
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EventsGateway } from './events.gateway';
|
||||
import { EventsScheduler } from './events.scheduler';
|
||||
import { SistersModule } from '../sisters/sisters.module';
|
||||
|
||||
@Module({
|
||||
imports: [SistersModule],
|
||||
imports: [
|
||||
SistersModule,
|
||||
ConfigModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: (() => { const s = config.get<string>('JWT_SECRET'); if (!s) throw new Error('JWT_SECRET is not configured'); return s; })(),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [EventsGateway, EventsScheduler],
|
||||
exports: [EventsGateway],
|
||||
})
|
||||
|
||||
@@ -136,6 +136,15 @@ export default function HarnessPage() {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
setResult({ success: false, msg: '인증 실패 — API Key 또는 JWT 토큰을 확인해' });
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
setResult({ success: false, msg: `서버 오류 ${res.status}: ${text.slice(0, 100)}` });
|
||||
return;
|
||||
}
|
||||
const d = await res.json();
|
||||
if (d.success) {
|
||||
setSaved(content);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import LogTerminal from '@/components/admin/LogTerminal';
|
||||
import { adminFetch } from '@/lib/adminFetch';
|
||||
@@ -82,6 +82,11 @@ export default function LogsPage() {
|
||||
}
|
||||
}, [sister, lines]);
|
||||
|
||||
// 마운트 시 자동 로드
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controls>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import StyledComponentsRegistry from '@/lib/registry';
|
||||
import GlobalStyle from '@/styles/GlobalStyle';
|
||||
import LayoutShell from '@/components/common/LayoutShell';
|
||||
import AppShell from '@/components/common/AppShell';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '하나랑 대시보드',
|
||||
@@ -18,7 +18,7 @@ export default function RootLayout({
|
||||
<body>
|
||||
<StyledComponentsRegistry>
|
||||
<GlobalStyle />
|
||||
<LayoutShell>{children}</LayoutShell>
|
||||
<AppShell>{children}</AppShell>
|
||||
</StyledComponentsRegistry>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
189
frontend/app/login/page.tsx
Normal file
189
frontend/app/login/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const Page = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-main);
|
||||
`;
|
||||
|
||||
const Box = styled.div`
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: var(--space-xl);
|
||||
`;
|
||||
|
||||
const Logo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: var(--space-xxl);
|
||||
`;
|
||||
|
||||
const CircleFull = styled.div`
|
||||
width: 20px; height: 20px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
const CircleHalf = styled.div`
|
||||
width: 10px; height: 20px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 0 20px 20px 0;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
letter-spacing: -0.02em;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: var(--space-xl);
|
||||
`;
|
||||
|
||||
const Form = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const FieldLabel = styled.label`
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-xs);
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus { border-color: var(--border-hover); }
|
||||
&::placeholder { color: var(--text-secondary); opacity: 0.5; }
|
||||
`;
|
||||
|
||||
const SubmitBtn = styled.button`
|
||||
background: var(--text-primary);
|
||||
border: 1px solid var(--text-primary);
|
||||
color: #000;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: var(--space-sm);
|
||||
|
||||
&:hover { background: transparent; color: var(--text-primary); }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
const ErrorMsg = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: #ff5f5f;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid #ff5f5f44;
|
||||
background: rgba(255,95,95,0.05);
|
||||
`;
|
||||
|
||||
const FooterLink = styled.div`
|
||||
margin-top: var(--space-lg);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
text-align: center;
|
||||
|
||||
a { color: var(--text-primary); text-decoration: none;
|
||||
&:hover { text-decoration: underline; } }
|
||||
`;
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
setError((err as Error).message ?? '로그인 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Box>
|
||||
<Logo><CircleFull /><CircleHalf /></Logo>
|
||||
<Title>하나랑 대시보드</Title>
|
||||
<Subtitle>SYSTEM_ACCESS // AUTHENTICATE</Subtitle>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<FieldLabel>USERNAME</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>PASSWORD</FieldLabel>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorMsg>ERR: {error}</ErrorMsg>}
|
||||
<SubmitBtn type="submit" disabled={loading}>
|
||||
{loading ? 'AUTHENTICATING...' : 'LOGIN'}
|
||||
</SubmitBtn>
|
||||
</Form>
|
||||
|
||||
<FooterLink>
|
||||
계정 없음? <Link href="/register">REGISTER</Link>
|
||||
</FooterLink>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
215
frontend/app/register/page.tsx
Normal file
215
frontend/app/register/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { API_URL } from '@/lib/config';
|
||||
|
||||
const Page = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-main);
|
||||
`;
|
||||
|
||||
const Box = styled.div`
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: var(--space-xl);
|
||||
`;
|
||||
|
||||
const Logo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: var(--space-xxl);
|
||||
`;
|
||||
|
||||
const CircleFull = styled.div`
|
||||
width: 20px; height: 20px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
const CircleHalf = styled.div`
|
||||
width: 10px; height: 20px;
|
||||
background: var(--text-primary);
|
||||
border-radius: 0 20px 20px 0;
|
||||
`;
|
||||
|
||||
const Title = styled.h1`
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-xs);
|
||||
letter-spacing: -0.02em;
|
||||
`;
|
||||
|
||||
const Subtitle = styled.p`
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
margin-bottom: var(--space-xl);
|
||||
`;
|
||||
|
||||
const Form = styled.form`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
`;
|
||||
|
||||
const FieldLabel = styled.label`
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-xs);
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const Input = styled.input`
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:focus { border-color: var(--border-hover); }
|
||||
&::placeholder { color: var(--text-secondary); opacity: 0.5; }
|
||||
`;
|
||||
|
||||
const SubmitBtn = styled.button`
|
||||
background: var(--text-primary);
|
||||
border: 1px solid var(--text-primary);
|
||||
color: #000;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-top: var(--space-sm);
|
||||
|
||||
&:hover { background: transparent; color: var(--text-primary); }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
`;
|
||||
|
||||
const ErrorMsg = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: #ff5f5f;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid #ff5f5f44;
|
||||
background: rgba(255,95,95,0.05);
|
||||
`;
|
||||
|
||||
const SuccessMsg = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: #00FF00;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid rgba(0,255,0,0.3);
|
||||
background: rgba(0,255,0,0.03);
|
||||
`;
|
||||
|
||||
const FooterLink = styled.div`
|
||||
margin-top: var(--space-lg);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
text-align: center;
|
||||
|
||||
a { color: var(--text-primary); text-decoration: none;
|
||||
&:hover { text-decoration: underline; } }
|
||||
`;
|
||||
|
||||
const InviteNote = styled.div`
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
margin-top: 4px;
|
||||
`;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [form, setForm] = useState({ username: '', password: '', inviteCode: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const d = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(Array.isArray(d.message) ? d.message[0] : (d.message ?? '등록 실패'));
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('hanarang_access_token', d.accessToken);
|
||||
setSuccess('등록 완료. 대시보드로 이동...');
|
||||
setTimeout(() => router.push('/'), 1000);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((p) => ({ ...p, [key]: e.target.value }));
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Box>
|
||||
<Logo><CircleFull /><CircleHalf /></Logo>
|
||||
<Title>계정 등록</Title>
|
||||
<Subtitle>SYSTEM_ACCESS // REGISTER_NEW_NODE</Subtitle>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<FieldLabel>USERNAME</FieldLabel>
|
||||
<Input type="text" placeholder="username" value={form.username} onChange={set('username')} required />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>PASSWORD</FieldLabel>
|
||||
<Input type="password" placeholder="•••••••• (8자 이상)" value={form.password} onChange={set('password')} required minLength={8} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>INVITE CODE</FieldLabel>
|
||||
<Input type="text" placeholder="초대 코드" value={form.inviteCode} onChange={set('inviteCode')} required />
|
||||
<InviteNote>관리자에게 초대 코드를 요청하세요</InviteNote>
|
||||
</div>
|
||||
{error && <ErrorMsg>ERR: {error}</ErrorMsg>}
|
||||
{success && <SuccessMsg>✓ {success}</SuccessMsg>}
|
||||
<SubmitBtn type="submit" disabled={loading}>
|
||||
{loading ? 'REGISTERING...' : 'REGISTER'}
|
||||
</SubmitBtn>
|
||||
</Form>
|
||||
|
||||
<FooterLink>
|
||||
이미 계정 있음? <Link href="/login">LOGIN</Link>
|
||||
</FooterLink>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
50
frontend/components/common/AppShell.tsx
Normal file
50
frontend/components/common/AppShell.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||
import LayoutShell from './LayoutShell';
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/register'];
|
||||
|
||||
function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const isPublic = PUBLIC_PATHS.includes(pathname);
|
||||
if (!isAuthenticated && !isPublic) {
|
||||
router.replace('/login');
|
||||
}
|
||||
if (isAuthenticated && isPublic) {
|
||||
router.replace('/');
|
||||
}
|
||||
}, [isAuthenticated, loading, pathname, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', background: '#151515', color: '#8A8A8A',
|
||||
fontFamily: 'monospace', fontSize: '12px',
|
||||
}}>
|
||||
AUTHENTICATING...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPublic = PUBLIC_PATHS.includes(pathname);
|
||||
if (isPublic) return <>{children}</>;
|
||||
|
||||
return <LayoutShell>{children}</LayoutShell>;
|
||||
}
|
||||
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AuthGate>{children}</AuthGate>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import { useSidebar } from '@/lib/SidebarContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -144,6 +145,7 @@ const TabItem = styled(Link)<{ $active: boolean }>`
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const isActive = (href: string) =>
|
||||
href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||
@@ -166,6 +168,25 @@ export default function Sidebar() {
|
||||
))}
|
||||
</NavMenu>
|
||||
</nav>
|
||||
{user && (
|
||||
<div style={{ marginTop: 'auto', paddingTop: 'var(--space-xl)' }}>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)', marginBottom: 'var(--space-sm)' }}>
|
||||
[{user.username}]
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--text-secondary)',
|
||||
fontSize: '13px', cursor: 'pointer', padding: 0, textAlign: 'left',
|
||||
transition: 'color 0.15s', fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SidebarWrapper>
|
||||
|
||||
<BottomTabBar>
|
||||
|
||||
91
frontend/lib/AuthContext.tsx
Normal file
91
frontend/lib/AuthContext.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { API_URL } from './config';
|
||||
|
||||
interface AuthUser {
|
||||
userId: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
user: null,
|
||||
loading: true,
|
||||
login: async () => {},
|
||||
logout: () => {},
|
||||
isAuthenticated: false,
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
const token = localStorage.getItem('hanarang_access_token');
|
||||
if (!token) { setLoading(false); return; }
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser({ userId: data.id, username: data.username, role: data.role });
|
||||
} else {
|
||||
localStorage.removeItem('hanarang_access_token');
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadUser(); }, [loadUser]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const res = await fetch(`${API_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const d = await res.json();
|
||||
throw new Error(d.message ?? '로그인 실패');
|
||||
}
|
||||
|
||||
const d = await res.json();
|
||||
localStorage.setItem('hanarang_access_token', d.accessToken);
|
||||
if (d.refreshToken) {
|
||||
localStorage.setItem('hanarang_refresh_token', d.refreshToken);
|
||||
}
|
||||
setUser({ userId: 0, username: d.username, role: d.role });
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('hanarang_access_token');
|
||||
localStorage.removeItem('hanarang_refresh_token');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, isAuthenticated: !!user }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
/**
|
||||
* 관리자 API 호출 유틸.
|
||||
* API_URL이 빈 문자열이면 상대경로 → Next.js rewrites → BE 프록시.
|
||||
* Authorization 헤더에 localStorage의 admin key 삽입.
|
||||
* - CSR: 상대경로 /api/... → Next.js rewrites → BE
|
||||
* - JWT access token (우선) 또는 API Key fallback으로 Authorization 헤더 설정
|
||||
*/
|
||||
export function adminFetch(path: string, options?: RequestInit) {
|
||||
const apiKey =
|
||||
typeof window !== 'undefined'
|
||||
? (localStorage.getItem('hanarang_admin_key') ?? '')
|
||||
: '';
|
||||
|
||||
// CSR: path가 /api/... 이면 상대경로 그대로 사용
|
||||
// SSR에서는 adminFetch 미사용 (모든 admin 페이지가 'use client')
|
||||
return fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
export function getAdminToken(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return (
|
||||
localStorage.getItem('hanarang_access_token') ??
|
||||
localStorage.getItem('hanarang_admin_key') ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
export function adminFetch(path: string, options?: RequestInit) {
|
||||
const token = getAdminToken();
|
||||
|
||||
// Authorization: options?.headers보다 우선 (덮어쓰기 방지)
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers as Record<string, string> ?? {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
|
||||
return fetch(path, { ...options, headers });
|
||||
}
|
||||
|
||||
@@ -16,12 +16,14 @@ export function useSocket(options: UseSocketOptions = {}) {
|
||||
// next.config.ts rewrites가 없는 경우를 위해 빈 origin 처리
|
||||
// 브라우저에서 WebSocket은 rewrites 대상이 아니므로 직접 BE URL 필요
|
||||
const wsUrl = process.env.NEXT_PUBLIC_WS_URL ?? window.location.origin;
|
||||
const token = localStorage.getItem('hanarang_access_token') ?? '';
|
||||
|
||||
const socket = io(`${wsUrl}/ws`, {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 3000,
|
||||
auth: { token },
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
|
||||
Reference in New Issue
Block a user