회사 관리 기능 확장 + 테넌트/비번 보안 하드닝

- 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼,
  ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지
- 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code
  ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code)
  동시 반환
- 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼):
  CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 +
  CompanyMgmtController + SuperAdminGuard
- 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종
  (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport
- 프로비저닝 마법사: force_password_change 토글 반영
- 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode
  (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED)
  전역 리다이렉트
- 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:36:05 +09:00
parent 06998cd2a5
commit 68f85f3736
58 changed files with 6110 additions and 316 deletions
-1
View File
@@ -173,7 +173,6 @@ uploads/
*.hwpx *.hwpx
# ===== 기타 ===== # ===== 기타 =====
claude.md
# Agent Pipeline 로컬 파일 # Agent Pipeline 로컬 파일
_local/ _local/
+323
View File
@@ -0,0 +1,323 @@
# INVYONE — Claude 작업 컨벤션
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
(개인용 셋업은 `CLAUDE.local.md` — git 추적 제외, syncthing 동기화)
---
## 📝 분석 / 리포트 / 메모 MD 파일 저장 규칙 (★필수)
Claude 가 코드 분석, 보안 감사, 리팩토링 검토, 설계 문서, 회의록 등 **새 MD 파일을 작성**할 때는 다음 위치에 저장합니다.
### 저장 경로
```
notes/{git-user-name}/{YYYY-MM-DD}-{slug}.md
```
- `notes/` — 프로젝트 루트의 메모/리포트 모음 폴더 (이 폴더로 통일)
- `{git-user-name}``git config user.name` 으로 자동 결정 (예: `gbpark`, `park`)
- `{YYYY-MM-DD}-{slug}.md` — 날짜 prefix + 짧은 제목 slug (kebab-case)
**예시:**
```
notes/gbpark/2026-04-08-auth-security-audit.md
notes/gbpark/2026-04-12-component-v2-migration-plan.md
notes/park/2026-04-15-docker-port-conflict-resolution.md
```
### 규칙
1. **사용자 폴더가 이미 있으면 그 안에 넣는다** — 없으면 `mkdir -p notes/{git-user}` 로 생성
2. **파일명은 항상 날짜 + slug 조합** — 시간순 정렬되어 추적 용이
3. **README 나 docs/ 와는 분리**`README.md`, `docs/` 는 사용자/개발자용 공식 문서. `notes/` 는 작업 기록·분석·메모용
4. **MD 외 다른 산출물 (스크립트, JSON 등) 도 같이 둘 수 있음** — 필요하면 `notes/{git-user}/{slug}/` 식 하위 폴더 사용
5. **새 폴더/파일 작성 후엔 git add 권장** — syncthing 도 자동 동기화 (`notes/``.stignore-shared` 에 없음)
### 어디에 안 넣는가
- `_local/`, `_backup/`, `_pipeline/` — syncthing ignore. 머신 로컬용
- `docs/` — 공식 개발 문서. 작업 기록 아님
- 프로젝트 루트 직접 (`./STATUS.md`, `./PLAN.MD` 등) — 이미 기존에 있는 것 외에 새로 만들지 말 것
---
## 컨벤션이 적용되는 시나리오
| 사용자 요청 | 저장 위치 |
|---|---|
| "이 코드 분석해서 md 로 정리해줘" | `notes/{git-user}/{date}-{topic}.md` |
| "보안 감사 리포트 만들어줘" | `notes/{git-user}/{date}-security-audit.md` |
| "리팩토링 플랜 md 로 뽑아줘" | `notes/{git-user}/{date}-refactor-plan.md` |
| "회의 노트 정리해줘" | `notes/{git-user}/{date}-meeting-notes.md` |
| "마이그레이션 가이드 작성" | `notes/{git-user}/{date}-migration-guide.md` |
---
## Claude 사용 시 추가 주의사항
- **이 컨벤션은 사용자 명시 요청 없이도 자동 적용** — 사용자가 "md 만들어줘" 라고만 해도 위 경로에 저장
- **현재 git user 확인이 필요하면** `git config user.name` 실행
- **사용자 폴더가 처음이면** 만들면서 `.gitkeep` 정도만 두지 말고 바로 첫 노트 작성
---
## 🎨 공통 디자인 시스템 / CSS 참조 규칙 (★★★ 무조건 적용)
UI 작업(컴포넌트 작성, HTML 목업, 새 페이지/화면, 디자인 리빌딩, 스타일 수정 등)을 할 때는 **반드시** 아래 공통 CSS 파일들을 먼저 읽고 그 안의 토큰/클래스 컨벤션을 100% 따라야 합니다. 절대 새 색상/간격/라운드/그림자 값을 즉흥으로 만들지 말 것.
### "v5" 의 정체 (★ 헷갈리지 말 것)
**INVION v5 = 디자인 시안 5번째 = 최종 채택본. 현재 컨셉은 "Solid + Glow" (2026-04-21 개정)**
- 디자이너가 v1~v5 까지 5번 시안을 만들고 그 중 **v5 가 확정**되어 React 로 포팅됨
- 시안 원본 HTML (참고용, 여기엔 아직 glassmorphism 이 남아있음):
- `frontend/invion-layout-v5.html` (973줄, 풀 레이아웃 셸)
- `frontend/invion-preview-v5.html` (1049줄, 미리보기/모션 데모)
- 폐기된 시안: `frontend/invion-preview-v1~v4.html` (참고만, 적용 금지)
- **현재 적용 컨셉 (2026-04-21 개정)**:
- **로그인 페이지**: 우주(별/성운/별똥별/입자) 배경 + 글래스 카드 **유지**
- **메인 화면 이후 전부**: **반투명/blur/cosmic 배경 폐기**. 불투명 솔리드 카드 + primary-color 글로우 + 보라(`#6c5ce7`)/시안(`#00cec9`)/핑크(`#fd79a8`) 액센트
- v5 토큰이 옮겨진 곳: `frontend/styles/v5-layout.css`, `frontend/app/(auth)/login/login.css`
⚠️ **POP 디자이너의 "v5 그리드 시스템"** (`PopRenderer.tsx`, `pop-layout.ts` 등) 은 **별개 의미** — POP 화면 데이터 포맷의 5번째 버전. UI 디자인 v5 와 무관. 혼동 금지.
### 항상 먼저 읽어야 하는 파일
| 파일 | 역할 |
|---|---|
| `frontend/invion-layout-v5.html` | **v5 디자인 원본 (시안)** — 모든 v5 토큰/클래스의 진실의 원천. 포팅된 css 와 다르면 이게 정답 |
| `frontend/styles/v5-layout.css` | **INVION v5 React 포팅 메인**`--v5-*` CSS 변수, 헤더/사이드바/탭/모달 등 모든 v5- 컴포넌트 클래스 정의. UI 작업 전 무조건 먼저 읽기 |
| `frontend/app/globals.css` | shadcn/Tailwind 토큰 (`--background`, `--primary`, `--foreground` 등 HSL), 다크모드 변수, 전역 reset |
| `frontend/app/(auth)/login/login.css` | **로그인 전용** 코스믹 배경(별/성운/입자) + 글래스 카드. 이 컨셉은 **로그인에만** 적용 — 메인 화면에 옮기지 말 것 |
| `frontend/components/layout/AppLayout.tsx` | v5 클래스가 실제로 어떻게 조립되는지 — 헤더/사이드바/탭/플라이아웃 사용 예 |
### 필수 준수 사항
1. **디자인 토큰은 무조건 변수 사용**`--v5-primary`, `--v5-cyan`, `--v5-surface-solid`, `--v5-glow-sm/md/lg`, `hsl(var(--primary))` 등. 즉흥 hex/rgb 금지
2. **클래스명은 v5- 접두사 컨벤션 따르기** — 새 컴포넌트도 `.v5-card`, `.v5-btn`, `.v5-bdg` 처럼 같은 네이밍. shadcn 컴포넌트 사용 시 그대로 사용
3. **반투명/블러 금지 (★2026-04-21 신규)** — 메인 화면 이후 전 영역에서 `backdrop-filter: blur(...)`, `var(--v5-glass)`, `var(--v5-glass-strong)` 사용 **금지**. 카드/모달/사이드바/헤더 배경은 `var(--v5-surface-solid)` (라이트 `#ffffff` / 다크 `#11102a`) 를 쓰고, 테두리는 `border-border` 또는 `var(--v5-border)`. 예외: `frontend/app/(auth)/login/``frontend/styles/builder-ide.css` 는 별도 스코프라 기존 유지
4. **글로우는 유지** — 그림자는 검은 drop-shadow 대신 `var(--v5-glow-sm/md/lg)` (primary-color glow) 사용. 모달/강조 카드에 liberal 하게 사용 가능
5. **다크/라이트 모드는 둘 다 동작**`.dark` 변형 잊지 말 것. 다크에서 `--v5-surface-solid``#11102a`, 라이트는 `#ffffff`. 별/입자/별똥별/성운은 **로그인에만** 존재, 메인은 평범한 단색 배경
6. **컴팩트 폰트 사이즈 유지** — v5 는 0.55~0.85rem 의 컴팩트 UI. 새로 만들 때도 같은 스케일 따를 것
7. **새 UI 패턴은 v5-layout.css 에 합치는 것을 기본 방향으로** — 일회성 inline `<style>` 보다 공통화 우선
### 작업 순서 (UI 작업 시 반드시)
```
1) frontend/styles/v5-layout.css 읽기
2) 필요하면 globals.css, login.css 도 함께 읽기
3) 기존 v5- 클래스 중 재사용 가능한 것 찾기
4) 모자라는 부분만 같은 토큰/네이밍으로 추가
5) 작업 결과를 사용자에게 보여줄 때 "어떤 v5 토큰/클래스를 따랐는지" 명시
```
### 예외
- 단발성 디버그/실험 페이지(`debug-*`, `test-*`)는 임시 스타일 허용
- `notes/` 안의 1회성 HTML 목업은 v5 토큰을 inline 으로 가져와 standalone 이어도 됨 (단 토큰 값은 반드시 v5-layout.css 와 동일해야 함)
이 규칙은 사용자가 명시 요청하지 않아도 모든 UI 작업에 자동 적용됩니다.
---
## 🔧 백엔드 코딩 규칙 — 덕일 스타일 (★★★ 절대 규칙)
파이프라인 에이전트 규칙 원본: `~/agent-pipeline/test-vex/agents/pipeline-backend.md`, `pipeline-common-rules.md`
### 아키텍처: 3레이어 (Mapper Interface 금지)
```
Controller (@RestController)
Service (extends BaseService) — sqlSession 직접 사용
XML (resources/mapper/[module].xml) — 소문자 파일명
```
- **@Mapper 인터페이스 생성 금지** — `sqlSession.selectList("namespace.queryId", params)` 직접 호출
- Service는 반드시 `BaseService` 상속, `@Slf4j`, `@Autowired CommonService`
### 데이터: Map<String, Object> — DTO/엔티티 클래스 금지
로우코드 ERP: 테이블/컬럼이 런타임에 결정됨. DTO 클래스 사전 생성 불가.
- 모든 파라미터: `Map<String, Object>`
- 모든 응답: `Map<String, Object>` 또는 `List<Map<String, Object>>`
- `ApiResponse`만 유일한 DTO
### 네이밍 컨벤션 (절대 규칙)
| 위치 | 컨벤션 | 예시 |
|---|---|---|
| **Java 코드** | camelCase | `getOrderList()`, `String companyCode` |
| **Map 키 (params.put, row.get)** | snake_case | `params.put("company_code", ...)`, `row.get("table_name")` |
| **#{파라미터}** | snake_case | `#{company_code}`, `#{table_name}` |
| **SQL (키워드/테이블/컬럼)** | UPPER_SNAKE | `SELECT COMPANY_CODE FROM TEMPLATES` |
| **SELECT 쉼표** | 앞에 | `, COLUMN_NAME` |
| **XML 파일명** | 소문자, Mapper 안 붙임 | `meta.xml`, `template.xml` |
| **XML namespace** | 파일명과 동일 | `namespace="meta"` |
| **OGNL test** | 바깥 작은따옴표 | `test='company_code != "*"'` |
### 메서드명 패턴
| 조작 | 패턴 | 예시 |
|---|---|---|
| 목록 | `get[Module]List` | `getTemplateList` |
| 카운트 | `get[Module]ListCnt` | `getTemplateListCnt` |
| 단건 | `get[Module]Info` | `getTemplateInfo` |
| 등록 | `insert[Module]` | `insertTemplate` |
| 수정 | `update[Module]` | `updateTemplate` |
| 삭제 | `delete[Module]` | `deleteTemplate` |
**List API는 반드시 Count 쿼리 동반** (`getXxxList` + `getXxxListCnt` = 한 세트)
### common 레이어 필수
```xml
<include refid="common.companyCodeFilter"/>
<include refid="common.dynamicOrderBy"/>
<include refid="common.pagination"/>
```
---
## 🌐 프론트엔드 데이터 타입 규칙 (★★★ 절대 규칙)
### Record<string, any> — 별도 인터페이스 정의 금지
백엔드가 `Map<String, Object>`이므로 프론트도 `Record<string, any>`.
```typescript
// ❌ 금지 — 불필요한 인터페이스 정의
interface TableInfo {
table_name: string;
column_count: number;
}
// ✅ 올바름
const tables: Record<string, any>[] = await getTableList();
```
### 유일한 예외: invyone-component.ts 규격 타입
`frontend/types/invyone-component.ts`에 확정된 타입만 예외:
- `FieldConfig`, `FieldType`, `FieldRef`
- `Component`, `ComponentType`, `Position`
- `DataPort`, `Connection`
- `Template`, `ViewConfig`
-`ComponentTypeConfig` (TableConfig, FormConfig 등)
이것들은 시스템의 핵심 계약이므로 타입 유지. **그 외 전부 `Record<string, any>`.**
---
## 📐 INVYONE 로우코드 핵심 구조 (★ 모든 세션이 알아야 할 것)
### VEX → INVYONE 관계
INVYONE은 이미 운영 중인 로우코드 플랫폼 VEX(Node.js)의 2세대 리뉴얼(Java/Spring).
**핵심 원칙: 더 단순한 구조로 재설계하되, VEX 운영 기능은 전부 계승. 단순화 ≠ 기능 삭제.**
### FieldConfig 단일 규격
VEX의 ColumnConfig(354줄) + FilterConfig + FormField → **FieldConfig 하나(~30줄)로 통합**.
테이블/폼/검색 전부 같은 FieldConfig를 공유하며, 렌더러가 type을 보고 각자 다르게 렌더.
### 대시보드 = 메뉴
사이드바 메뉴 항목 = 대시보드. 별도 대시보드 UI가 아님.
`대시보드 생성("수주관리") → 사이드바 메뉴에 자동 등록 → 템플릿 카드 배치 → 화면 완성`
### 구현 순서
1. DB 메타 읽기 → FieldConfig 변환
2. 규격 기반 컴포넌트 (FcTable/FcForm/FcSearch)
3. 개발자 빌더 (수동 템플릿 구성)
4. 대시보드(=메뉴) 시스템
5. 제어 모드 (비즈니스 룰)
6. 자동생성/프리셋 (맨 마지막)
### 설계 문서 위치
| 문서 | 위치 |
|---|---|
| 컴포넌트 규격 v1.0 | `notes/gbpark/2026-04-08-invyone-component-spec.md` |
| 아키텍처 결정 | `notes/gbpark/2026-04-09-invyone-architecture.md` |
| 로우코드 플랫폼 SPEC | `notes/gbpark/2026-04-08-lowcode-platform-spec.md` |
| Phase 1~5 구현 설계 | `notes/gbpark/2026-04-10-phase{1~5}-*.md` |
| mockup (시각적 진실의 원천) | `notes/gbpark/2026-04-08-invyone-mockup/` |
| FieldConfig TS 타입 | `frontend/types/invyone-component.ts` |
---
## 🏢 멀티테넌시 · 서브도메인 라우팅 (★★★ 아키텍처 핵심)
INVYONE 은 **DB-per-tenant** 구조. 회사 하나 = 전용 PostgreSQL DB 하나 = 전용 서브도메인 하나.
`qnc.invyone.com` 접속 → `SubdomainResolverFilter` 가 Host 헤더 파싱 → `TenantRoutingDataSource``qnc_invyone` DB 로 자동 라우팅.
**전체 설명·플로우·배포 가이드는 반드시 읽을 것:**
- **`docs/MULTI_TENANCY_ARCHITECTURE.md`** ← 이거 하나만 읽으면 끝 (환경별 도메인 전략, CORS 패턴, 배포 체크리스트 포함)
### 절대 건드리면 안 되는 3가지
1. **테넌트 도메인에서 `NEXT_PUBLIC_API_URL=/api` 같은 Next rewrite 쓰지 말 것.**
Rewrite 가 Host 헤더를 변조해 `*.invyone.com` 서브도메인 파싱이 실패함.
`frontend/lib/api/client.ts` 의 "`.invyone.com` 이면 직접 `:8083/api`" 분기가 NEXT_PUBLIC_API_URL 체크보다 **앞에** 와야 함.
2. **CORS 는 `setAllowedOriginPatterns`** (`setAllowedOrigins` 아님). 와일드카드 매칭 필요.
**YAML 의 `[*]` 는 반드시 따옴표로 감쌀 것** (sequence 로 해석됨).
`CORS_ALLOWED_ORIGINS``.env` 에 있고 `.gitignore` 대상 → **서버마다 수동 세팅**.
3. **회사별 Hikari 풀은 반드시 `minIdle=0`.**
회사 N개 × minIdle=2 하면 Postgres `max_connections` 금방 초과.
`TenantDataSourceFactory.createTenant(...)` 만 사용할 것.
### 회사 생성 = API 호출 한 번
```
POST /api/admin/provisioning/companies
→ 6단계 자동 실행 (DB 생성 → pg_dump 스키마 복제 → 템플릿 데이터 → 관리자 → 메타 등록)
→ 13~15초 내 완료, 바로 서브도메인 접속 가능
```
필수 필드 4개만: `company_code`, `company_name`, `subdomain`, `db_prefix`.
나머지(사업자번호·대표자·이메일 등) 전부 optional.
### 환경별 도메인 — 요약
| 환경 | 접속 | 추가 설정 |
|---|---|---|
| **운영** | 실 `*.invyone.com` (+ 메인 `solution.invyone.com`) | **세팅 완료 (2026-04-24)** — Porkbun 와일드카드 DNS + Traefik v2.11 + Let's Encrypt DNS-01 와일드카드 TLS |
| **로컬 (직접 `docker up`)** | `*.localhost` 자동 | 0 (RFC 6761, Chrome/Firefox 기본 지원) |
| **원격 공유 개발서버** | `nip.io` or `hosts` 편집 | 경우 따라 |
### 새 환경 붙일 때 체크리스트
- [ ] `.env``CORS_ALLOWED_ORIGINS` 에 해당 도메인 패턴 포함 (`http://*.invyone.com:[*]` 등)
- [ ] DB 마이그레이션 `RUN_079/080/081.md` 운영 DB 에서 1회 실행
- [ ] `docker compose build --no-cache backend-spring` (`postgresql16-client` 포함)
- [ ] 프로덕션은 `tenant.provisioning.require-super-admin=true`
### 관련 코드 맵
```
backend-spring/src/main/java/com/erp/
├── tenant/ # 라우팅 (Filter / Holder / CompanyResolver / RoutingDataSource / Factory / DataSourceConfig / TenantController)
└── provisioning/ # 회사 생성 (Service / Registry / DatabaseCreator / SchemaCopier / DataCopier / AdminAccountCreator / Controller / StatsService)
backend-spring/src/main/resources/mapper/
├── tenant.xml # resolveDbNameBySubdomain
└── provisioning.xml # exists / insertCompanyWithTenant / listCompaniesForUi / updateDbStatus
frontend/
├── lib/api/provisioning.ts
├── lib/tenant/subdomain.ts # 호스트 파싱 + 예약어 제외
├── components/TenantGuard.tsx # 미등록 서브도메인 차단 (layout wrap)
├── components/admin/provisioning/ # 메인 화면 (accordion / stats strip / status dot / sparkline)
│ └── wizard/ # 4-step 마법사 (force_password_change 옵션 포함)
├── app/tenant-not-found/page.tsx # v5 solid+glow 에러 페이지
└── app/(main)/admin/sysMng/subdomainList/ # 회사 프로비저닝 페이지 엔트리
db/migrations/RUN_079/080/081_MIGRATION.md
notes/gbpark/2026-04-24-traefik-wildcard/ # 운영 Traefik 설정 산출물 (2026-04-24)
```
@@ -138,6 +138,48 @@ public class AuthController {
} }
} }
/**
* POST /api/auth/change-password
*
* 본인 비밀번호 변경 (첫 로그인 강제 변경 / 평시 변경 공용).
* Bearer 토큰에서 user_id 추출 → current_password 검증 → 새 비번 저장 + force_password_change=false.
*/
@PostMapping("/change-password")
public ResponseEntity<ApiResponse<Map<String, Object>>> changePassword(
@RequestBody Map<String, Object> body,
HttpServletRequest request) {
String token = resolveToken(request);
if (!StringUtils.hasText(token) || !jwtTokenProvider.validateToken(token)) {
return ResponseEntity.status(401).body(ApiResponse.error("인증이 필요합니다."));
}
String currentPassword = (String) body.get("current_password");
String newPassword = (String) body.get("new_password");
if (!StringUtils.hasText(currentPassword) || !StringUtils.hasText(newPassword)) {
return ResponseEntity.badRequest().body(ApiResponse.error("현재/새 비밀번호를 모두 입력해주세요."));
}
// Defense in depth — 전역 TenantConsistencyGuardFilter 가 이미 잡지만,
// 비밀번호 변경은 크리티컬하므로 Service 레벨에서도 한 번 더 대조.
// 테넌트 서브도메인 요청인데 JWT.company_code 와 tenant.company_code 가 다르면 즉시 거부.
String tenantCompanyCode = (String) request.getAttribute("tenant_company_code");
String jwtCompanyCode = jwtTokenProvider.getCompanyCode(token);
if (tenantCompanyCode != null && !tenantCompanyCode.equals(jwtCompanyCode)) {
log.warn("[ChangePassword] cross-tenant rejected: jwt={}, tenant={}", jwtCompanyCode, tenantCompanyCode);
return ResponseEntity.status(403).body(ApiResponse.error("로그인한 회사와 접속 도메인이 일치하지 않습니다."));
}
try {
Map<String, Object> data = authService.changePassword(token, currentPassword, newPassword);
return ResponseEntity.ok(ApiResponse.success(data, "비밀번호가 변경되었습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
} catch (Exception e) {
log.error("비밀번호 변경 실패", e);
return ResponseEntity.status(500).body(ApiResponse.error("비밀번호 변경 중 오류가 발생했습니다."));
}
}
/** /**
* POST /api/auth/signup * POST /api/auth/signup
*/ */
@@ -0,0 +1,96 @@
package com.erp.migration;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
import java.util.List;
/**
* 앱 부팅 시 idempotent 스키마 마이그레이션 실행.
*
* 왜 필요:
* DB-per-tenant 구조 → 메타 DB 1개 + 활성 테넌트 DB N개 에 동일 ALTER 필요.
* runbook 문서 (`db/migrations/RUN_*.md`) 로만 남기면 배포 때 사람이 까먹으면 장애.
* 부팅 때 `IF NOT EXISTS` 로 안전하게 돌려두면 배포 순서/인적 실수와 무관.
*
* 원칙:
* - 각 ALTER 는 반드시 idempotent (`IF NOT EXISTS` / 재실행 안전)
* - 테넌트 DB 하나 실패해도 다른 DB 는 계속 진행 (ERROR 로그만)
* - 메타 DB 가 실패하면 앱 시작은 계속되지만 WARN 크게 남김
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class StartupSchemaMigrator {
private final TenantDbSettings tenantDbSettings;
private final SqlSession sqlSession;
@Value("${spring.datasource.url}")
private String metaJdbcUrl;
private static final List<String> MIGRATIONS = List.of(
// RUN_082: 첫 로그인 비밀번호 강제 변경 플래그
"ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE"
);
@EventListener(ApplicationReadyEvent.class)
public void run() {
log.info("[SchemaMigrator] start — {} migration(s)", MIGRATIONS.size());
String metaDb = parseMetaDbName(metaJdbcUrl);
applyTo(metaDb, "meta");
// 테넌트 목록 조회는 반드시 메타 라우팅으로
DbContextHolder.setMeta();
List<String> tenantDbs;
try {
tenantDbs = sqlSession.selectList("provisioning.listActiveDbNames");
} catch (Exception e) {
log.warn("[SchemaMigrator] tenant list query failed — skipping tenants: {}", e.getMessage());
tenantDbs = List.of();
} finally {
DbContextHolder.clear();
}
int ok = 0, fail = 0;
for (String db : tenantDbs) {
if (db == null || db.isBlank() || db.equalsIgnoreCase(metaDb)) continue;
if (applyTo(db, "tenant")) ok++; else fail++;
}
log.info("[SchemaMigrator] done — meta=done, tenants ok={}, fail={}", ok, fail);
}
private boolean applyTo(String dbName, String kind) {
String url = tenantDbSettings.buildJdbcUrl(dbName);
try (Connection c = DriverManager.getConnection(url, tenantDbSettings.username(), tenantDbSettings.password());
Statement s = c.createStatement()) {
for (String ddl : MIGRATIONS) {
s.execute(ddl);
}
log.info("[SchemaMigrator] {} db='{}' OK", kind, dbName);
return true;
} catch (Exception e) {
log.error("[SchemaMigrator] {} db='{}' FAILED: {}", kind, dbName, e.getMessage());
return false;
}
}
static String parseMetaDbName(String jdbcUrl) {
int slash = jdbcUrl.lastIndexOf('/');
if (slash < 0) return "invyone";
String tail = jdbcUrl.substring(slash + 1);
int q = tail.indexOf('?');
return q < 0 ? tail : tail.substring(0, q);
}
}
@@ -21,25 +21,29 @@ public class AdminAccountCreator {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
/** /**
* @param dst 신규 회사 DB 커넥션 (autoCommit 무관, 호출자가 트랜잭션 관리) * @param dst 신규 회사 DB 커넥션 (autoCommit 무관, 호출자가 트랜잭션 관리)
* @param companyCode COMPANY_MNG.company_code (FK 성격) * @param companyCode COMPANY_MNG.company_code (FK 성격)
* @param userId 예: qnc_admin * @param userId 예: qnc_admin
* @param rawPassword 평문 비밀번호 (BCrypt 해시 후 저장됨) * @param rawPassword 평문 비밀번호 (BCrypt 해시 후 저장됨)
* @param userName 표시 이름 * @param userName 표시 이름
* @param forcePasswordChange true 면 첫 로그인 시 비밀번호 변경을 강제 (RUN_082)
*/ */
public void createCompanyAdmin(Connection dst, String companyCode, String userId, public void createCompanyAdmin(Connection dst, String companyCode, String userId,
String rawPassword, String userName) throws SQLException { String rawPassword, String userName,
boolean forcePasswordChange) throws SQLException {
String hash = passwordEncoder.encode(rawPassword); String hash = passwordEncoder.encode(rawPassword);
String sql = "INSERT INTO user_info " + String sql = "INSERT INTO user_info " +
"(user_id, user_password, user_name, company_code, user_type, status, created_date) " + "(user_id, user_password, user_name, company_code, user_type, status, force_password_change, created_date) " +
"VALUES (?, ?, ?, ?, 'COMPANY_ADMIN', 'active', NOW())"; "VALUES (?, ?, ?, ?, 'COMPANY_ADMIN', 'active', ?, NOW())";
try (PreparedStatement ps = dst.prepareStatement(sql)) { try (PreparedStatement ps = dst.prepareStatement(sql)) {
ps.setString(1, userId); ps.setString(1, userId);
ps.setString(2, hash); ps.setString(2, hash);
ps.setString(3, userName); ps.setString(3, userName);
ps.setString(4, companyCode); ps.setString(4, companyCode);
ps.setBoolean(5, forcePasswordChange);
int affected = ps.executeUpdate(); int affected = ps.executeUpdate();
log.info("[Provisioning] CREATE ADMIN: {} (company={}, rows={})", userId, companyCode, affected); log.info("[Provisioning] CREATE ADMIN: {} (company={}, rows={}, force_pw_change={})",
userId, companyCode, affected, forcePasswordChange);
} }
} }
} }
@@ -0,0 +1,118 @@
package com.erp.provisioning;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 테넌트 DB 의 COMPANY_ADMIN 계정 관련 조회/재설정.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyAdminService {
private final TenantDbSettings settings;
private final PasswordEncoder passwordEncoder;
/**
* 해당 회사 tenant DB 의 COMPANY_ADMIN 계정 1건 조회.
* 비밀번호 해시는 반환하지 않음. 평문 비번도 없음.
*/
public Map<String, Object> getAdmin(String dbName) {
Map<String, Object> out = new LinkedHashMap<>();
try (Connection c = DriverManager.getConnection(
settings.buildJdbcUrl(dbName), settings.username(), settings.password());
PreparedStatement ps = c.prepareStatement(
"SELECT user_id, user_name, status, force_password_change, created_date " +
"FROM user_info WHERE user_type='COMPANY_ADMIN' " +
"ORDER BY created_date ASC LIMIT 1")) {
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
out.put("user_id", rs.getString("user_id"));
out.put("user_name", rs.getString("user_name"));
out.put("status", rs.getString("status"));
out.put("force_password_change", rs.getBoolean("force_password_change"));
out.put("created_date", rs.getTimestamp("created_date"));
out.put("found", true);
} else {
out.put("found", false);
}
}
} catch (SQLException e) {
log.error("[CompanyAdmin] getAdmin failed db={} err={}", dbName, e.getMessage());
out.put("found", false);
out.put("error", e.getMessage());
}
return out;
}
/**
* 관리자 비번 재설정. 새 랜덤 비번 생성 → BCrypt 저장 → force_password_change=true.
* 평문 비번은 1회만 반환 (호출자가 UI 에 1회 노출 후 파기).
*
* @return { admin_user_id, new_password } 또는 { error }
*/
public Map<String, Object> resetAdminPassword(String dbName) {
String newPlain = generateRandomPassword();
String hash = passwordEncoder.encode(newPlain);
Map<String, Object> out = new LinkedHashMap<>();
try (Connection c = DriverManager.getConnection(
settings.buildJdbcUrl(dbName), settings.username(), settings.password())) {
// 대상 user_id 먼저 조회
String userId = null;
try (PreparedStatement sel = c.prepareStatement(
"SELECT user_id FROM user_info WHERE user_type='COMPANY_ADMIN' ORDER BY created_date ASC LIMIT 1");
ResultSet rs = sel.executeQuery()) {
if (rs.next()) userId = rs.getString(1);
}
if (userId == null) {
out.put("error", "no_admin_found");
return out;
}
try (PreparedStatement up = c.prepareStatement(
"UPDATE user_info SET user_password=?, force_password_change=TRUE " +
"WHERE user_id=? AND user_type='COMPANY_ADMIN'")) {
up.setString(1, hash);
up.setString(2, userId);
int affected = up.executeUpdate();
if (affected == 0) {
out.put("error", "no_rows_updated");
return out;
}
log.info("[CompanyAdmin] password reset: db={} user={}", dbName, userId);
}
out.put("admin_user_id", userId);
out.put("new_password", newPlain);
out.put("force_password_change", true);
return out;
} catch (SQLException e) {
log.error("[CompanyAdmin] resetAdminPassword failed db={} err={}", dbName, e.getMessage());
out.put("error", e.getMessage());
return out;
}
}
private static String generateRandomPassword() {
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#$";
SecureRandom r = new SecureRandom();
StringBuilder sb = new StringBuilder(14);
for (int i = 0; i < 14; i++) sb.append(chars.charAt(r.nextInt(chars.length())));
return sb.toString();
}
}
@@ -0,0 +1,150 @@
package com.erp.provisioning;
import com.erp.tenant.DbContextHolder;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 회사 관리 화면(/admin/sysMng/subdomainList)의 라이프사이클 액션 감사 로그.
*
* 테이블: COMPANY_AUDIT_LOG (메타 DB). 테넌트 DB 내부 CRUD 로그용 {@link com.erp.service.AuditLogService}
* 와는 스코프가 다름. 이 서비스는 SUPER_ADMIN 전용.
*
* action 상수:
* COMPANY_CREATE — 프로비저닝 성공
* COMPANY_CREATE_FAILED — 프로비저닝 실패 (자동 롤백 완료 후)
* COMPANY_DEACTIVATE — DB_STATUS=suspended
* COMPANY_REACTIVATE — suspended→active 복귀
* COMPANY_DELETE — DROP DATABASE + row 삭제
* ADMIN_PASSWORD_RESET — 관리자 비번 재설정
* TEMPLATES_RECOPY — 템플릿 재복제
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyAuditLogService {
public static final String ACTION_CREATE = "COMPANY_CREATE";
public static final String ACTION_CREATE_FAILED = "COMPANY_CREATE_FAILED";
public static final String ACTION_DEACTIVATE = "COMPANY_DEACTIVATE";
public static final String ACTION_REACTIVATE = "COMPANY_REACTIVATE";
public static final String ACTION_DELETE = "COMPANY_DELETE";
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
private final SqlSession sqlSession;
private final ObjectMapper objectMapper;
/** 성공 기록. */
public void log(String companyCode, String actorUserId, String action, String target, Map<String, Object> details) {
doLog(companyCode, actorUserId, action, target, details, true, null);
}
/** 실패 기록. */
public void logFailure(String companyCode, String actorUserId, String action, String target,
Map<String, Object> details, String errorMessage) {
doLog(companyCode, actorUserId, action, target, details, false, errorMessage);
}
private void doLog(String companyCode, String actorUserId, String action, String target,
Map<String, Object> details, boolean success, String errorMessage) {
// 감사 로그는 항상 메타 DB 에 기록. tenant 컨텍스트 영향 받지 않도록 강제.
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("actor_user_id", actorUserId);
params.put("action", action);
params.put("target", target);
params.put("details_json", details == null ? null : toJson(details));
params.put("success", success);
params.put("error_message", errorMessage);
sqlSession.insert("provisioning.insertAuditLog", params);
} catch (Exception e) {
// 감사 로그 실패가 본 작업을 깨면 안 됨.
log.error("[CompanyAudit] log failed: company={} action={} err={}", companyCode, action, e.getMessage());
} finally {
if (prevCtx == null) {
DbContextHolder.clear();
} else {
DbContextHolder.set(prevCtx);
}
}
}
/** 특정 회사 감사 로그 (최신순, 페이지네이션). */
public Map<String, Object> listByCompany(String companyCode, int page, int limit) {
if (page < 1) page = 1;
if (limit < 1 || limit > 200) limit = 50;
int offset = (page - 1) * limit;
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
p.put("limit", limit);
p.put("offset", offset);
Number totalNum = sqlSession.selectOne("provisioning.countAuditLog", p);
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.selectAuditLog", p);
hydrateDetails(rows);
Map<String, Object> out = new LinkedHashMap<>();
out.put("data", rows);
out.put("total", totalNum == null ? 0 : totalNum.intValue());
out.put("page", page);
out.put("limit", limit);
return out;
}
/** 전체 감사 로그 (헤더 버튼용). */
public Map<String, Object> listAll(int page, int limit, String action) {
if (page < 1) page = 1;
if (limit < 1 || limit > 200) limit = 50;
int offset = (page - 1) * limit;
Map<String, Object> p = new HashMap<>();
p.put("action", action);
p.put("limit", limit);
p.put("offset", offset);
Number totalNum = sqlSession.selectOne("provisioning.countAuditLog", p);
List<Map<String, Object>> rows = sqlSession.selectList("provisioning.selectAuditLog", p);
hydrateDetails(rows);
Map<String, Object> out = new LinkedHashMap<>();
out.put("data", rows);
out.put("total", totalNum == null ? 0 : totalNum.intValue());
out.put("page", page);
out.put("limit", limit);
return out;
}
private String toJson(Map<String, Object> details) {
try {
return objectMapper.writeValueAsString(details);
} catch (Exception e) {
log.warn("[CompanyAudit] JSON serialize failed: {}", e.getMessage());
return null;
}
}
@SuppressWarnings("unchecked")
private void hydrateDetails(List<Map<String, Object>> rows) {
for (Map<String, Object> r : rows) {
Object raw = r.get("details");
if (raw == null) continue;
try {
Map<String, Object> parsed = objectMapper.readValue(raw.toString(), Map.class);
r.put("details", parsed);
} catch (Exception e) {
// 파싱 실패해도 원본 문자열 유지
}
}
}
}
@@ -0,0 +1,149 @@
package com.erp.provisioning;
import com.erp.tenant.CompanyResolver;
import com.erp.tenant.DbContextHolder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 회사 라이프사이클 액션 (비활성화 / 재활성화 / 영구 삭제).
*
* 비활성화: DB_STATUS='suspended' + DEACTIVATED_AT + DEACTIVATION_REASON 기록.
* 로그인 필터가 suspended 를 403 처리 (기존 구현). DB 는 남겨둠.
* 재활성화: DB_STATUS='active' + 위 2 필드 NULL 로 클리어.
* 영구 삭제: DROP DATABASE + COMPANY_MNG row DELETE + CompanyResolver 캐시 무효화.
* 절대 되돌릴 수 없음.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyLifecycleService {
private final SqlSession sqlSession;
private final DatabaseCreator databaseCreator;
private final CompanyResolver companyResolver;
public Map<String, Object> getCompany(String companyCode) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
Map<String, Object> row = sqlSession.selectOne("provisioning.selectCompanyByCode", p);
return row;
} finally {
restoreCtx(prevCtx);
}
}
/**
* 비활성화. 이미 suspended 면 no-op 리턴.
* @return { status, subdomain } 성공 시 | { error } 실패 시
*/
public Map<String, Object> deactivate(String companyCode, String reason) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> company = getCompany(companyCode);
if (company == null) return Map.of("error", "not_found");
String curStatus = (String) company.get("db_status");
if ("suspended".equals(curStatus)) {
return Map.of("error", "already_suspended");
}
if ("provisioning".equals(curStatus)) {
return Map.of("error", "cannot_suspend_while_provisioning");
}
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
p.put("reason", reason);
sqlSession.update("provisioning.deactivateCompany", p);
companyResolver.invalidate((String) company.get("subdomain"));
log.info("[CompanyLifecycle] deactivated company={} reason={}", companyCode, reason);
Map<String, Object> out = new LinkedHashMap<>();
out.put("status", "suspended");
out.put("subdomain", company.get("subdomain"));
return out;
} finally {
restoreCtx(prevCtx);
}
}
public Map<String, Object> reactivate(String companyCode) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> company = getCompany(companyCode);
if (company == null) return Map.of("error", "not_found");
String curStatus = (String) company.get("db_status");
if (!"suspended".equals(curStatus)) {
return Map.of("error", "not_suspended", "current_status", curStatus);
}
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
sqlSession.update("provisioning.reactivateCompany", p);
companyResolver.invalidate((String) company.get("subdomain"));
log.info("[CompanyLifecycle] reactivated company={}", companyCode);
Map<String, Object> out = new LinkedHashMap<>();
out.put("status", "active");
out.put("subdomain", company.get("subdomain"));
return out;
} finally {
restoreCtx(prevCtx);
}
}
/**
* 영구 삭제. DROP DATABASE → COMPANY_MNG row DELETE.
* DROP 이 실패해도 메타 row 는 남겨둠 (사용자가 수동 청소 후 재시도 하도록).
*/
public Map<String, Object> delete(String companyCode) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> company = getCompany(companyCode);
if (company == null) return Map.of("error", "not_found");
String dbName = (String) company.get("db_name");
String subdomain = (String) company.get("subdomain");
// 1. DROP DATABASE (실패해도 로그만, 예외 안 남) — dropDatabase 가 이미 그렇게 동작
databaseCreator.dropDatabase(dbName);
// 2. 메타 row 삭제
Map<String, Object> p = new HashMap<>();
p.put("company_code", companyCode);
int affected = sqlSession.delete("provisioning.deleteCompany", p);
companyResolver.invalidate(subdomain);
log.warn("[CompanyLifecycle] ⚠ PERMANENTLY DELETED company={} db={} (rows_removed={})",
companyCode, dbName, affected);
Map<String, Object> out = new LinkedHashMap<>();
out.put("company_code", companyCode);
out.put("db_name", dbName);
out.put("subdomain", subdomain);
out.put("meta_rows_removed", affected);
return out;
} finally {
restoreCtx(prevCtx);
}
}
private static void restoreCtx(String prev) {
if (prev == null) DbContextHolder.clear();
else DbContextHolder.set(prev);
}
}
@@ -0,0 +1,54 @@
package com.erp.provisioning;
import com.erp.tenant.TenantDbSettings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 테넌트 DB 의 user_info 를 조회해서 회사 구성원 리스트를 반환.
* 회사 관리 화면의 "구성원" 탭 전용.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyMembersService {
private final TenantDbSettings settings;
public List<Map<String, Object>> listMembers(String dbName) {
List<Map<String, Object>> out = new ArrayList<>();
try (Connection c = DriverManager.getConnection(
settings.buildJdbcUrl(dbName), settings.username(), settings.password());
PreparedStatement ps = c.prepareStatement(
"SELECT user_id, user_name, user_type, status, created_date " +
"FROM user_info " +
"ORDER BY " +
" CASE user_type WHEN 'COMPANY_ADMIN' THEN 0 WHEN 'ADMIN' THEN 1 ELSE 2 END, " +
" created_date DESC");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("user_id", rs.getString("user_id"));
row.put("user_name", rs.getString("user_name"));
row.put("user_type", rs.getString("user_type"));
row.put("status", rs.getString("status"));
row.put("created_date", rs.getTimestamp("created_date"));
out.add(row);
}
} catch (SQLException e) {
log.error("[CompanyMembers] list failed db={} err={}", dbName, e.getMessage());
}
return out;
}
}
@@ -0,0 +1,227 @@
package com.erp.provisioning;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 회사 관리 화면 라이프사이클 엔드포인트.
*
* 프로비저닝(생성) 은 {@link ProvisioningController} 가 담당. 이 컨트롤러는 그 이후의
* 관리 액션 — 비활성/재활성/삭제, 관리자 계정 관리, 구성원 조회, 템플릿 재복제, 감사 로그.
*
* 전부 SUPER_ADMIN 전용 ({@link SuperAdminGuard}).
*/
@RestController
@RequestMapping("/api/admin/provisioning")
@RequiredArgsConstructor
@Slf4j
public class CompanyMgmtController {
private final SuperAdminGuard guard;
private final CompanyLifecycleService lifecycleService;
private final CompanyAdminService adminService;
private final CompanyMembersService membersService;
private final CompanyTemplatesService templatesService;
private final CompanyAuditLogService auditLogService;
@Value("${spring.datasource.url}")
private String metaJdbcUrl;
// ─────────────── 관리자 계정 ───────────────
@GetMapping("/companies/{companyCode}/admin")
public ResponseEntity<Map<String, Object>> getAdmin(HttpServletRequest req, @PathVariable String companyCode) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dbName = (String) company.get("db_name");
Map<String, Object> admin = adminService.getAdmin(dbName);
admin.put("company_code", companyCode);
admin.put("db_name", dbName);
return ResponseEntity.ok(admin);
}
@PostMapping("/companies/{companyCode}/admin/reset-password")
public ResponseEntity<Map<String, Object>> resetAdminPassword(HttpServletRequest req,
@PathVariable String companyCode) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dbName = (String) company.get("db_name");
Map<String, Object> result = adminService.resetAdminPassword(dbName);
String actor = guard.actorUserId(req);
if (result.containsKey("error")) {
auditLogService.logFailure(companyCode, actor, CompanyAuditLogService.ACTION_PW_RESET,
dbName, Map.of("error_kind", result.get("error")), (String) result.get("error"));
return ResponseEntity.status(500).body(result);
}
auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_PW_RESET,
(String) result.get("admin_user_id"), Map.of("db_name", dbName));
return ResponseEntity.ok()
.header("Cache-Control", "no-store")
.header("Pragma", "no-cache")
.body(result);
}
// ─────────────── 구성원 ───────────────
@GetMapping("/companies/{companyCode}/members")
public ResponseEntity<Map<String, Object>> listMembers(HttpServletRequest req,
@PathVariable String companyCode) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dbName = (String) company.get("db_name");
List<Map<String, Object>> members = membersService.listMembers(dbName);
Map<String, Object> out = new LinkedHashMap<>();
out.put("company_code", companyCode);
out.put("db_name", dbName);
out.put("members", members);
out.put("total", members.size());
return ResponseEntity.ok(out);
}
// ─────────────── 템플릿 ───────────────
@GetMapping("/companies/{companyCode}/installed-groups")
public ResponseEntity<List<Map<String, Object>>> installedGroups(HttpServletRequest req,
@PathVariable String companyCode) {
guard.enforce(req);
return ResponseEntity.ok(templatesService.listInstalledGroups(companyCode));
}
@PostMapping("/companies/{companyCode}/re-copy")
public ResponseEntity<Map<String, Object>> recopyTemplates(HttpServletRequest req,
@PathVariable String companyCode,
@RequestBody Map<String, Object> body) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String dstDb = (String) company.get("db_name");
String srcDb = resolveMetaDbName();
@SuppressWarnings("unchecked")
List<String> groups = (List<String>) body.getOrDefault("selected_groups", List.of());
if (groups.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "selected_groups required"));
}
Map<String, Object> result = templatesService.recopy(srcDb, dstDb, groups);
String actor = guard.actorUserId(req);
List<?> errors = (List<?>) result.get("errors");
if (errors != null && !errors.isEmpty()) {
auditLogService.logFailure(companyCode, actor, CompanyAuditLogService.ACTION_RECOPY,
dstDb, Map.of("selected_groups", groups, "errors", errors),
"partial_failure: " + errors.size() + " table(s)");
} else {
auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_RECOPY, dstDb,
Map.of("selected_groups", groups, "total_inserted", result.get("total_inserted")));
}
return ResponseEntity.ok(result);
}
// ─────────────── 라이프사이클 ───────────────
@PatchMapping("/companies/{companyCode}/status")
public ResponseEntity<Map<String, Object>> changeStatus(HttpServletRequest req,
@PathVariable String companyCode,
@RequestBody Map<String, Object> body) {
guard.enforce(req);
String target = (String) body.get("status");
String reason = (String) body.get("reason");
if (target == null) {
return ResponseEntity.badRequest().body(Map.of("error", "status required"));
}
String actor = guard.actorUserId(req);
Map<String, Object> result;
String action;
if ("suspended".equals(target)) {
result = lifecycleService.deactivate(companyCode, reason);
action = CompanyAuditLogService.ACTION_DEACTIVATE;
} else if ("active".equals(target)) {
result = lifecycleService.reactivate(companyCode);
action = CompanyAuditLogService.ACTION_REACTIVATE;
} else {
return ResponseEntity.badRequest().body(Map.of("error", "unsupported status: " + target));
}
if (result.containsKey("error")) {
auditLogService.logFailure(companyCode, actor, action, null,
Map.of("target_status", target, "reason", reason == null ? "" : reason),
String.valueOf(result.get("error")));
return ResponseEntity.badRequest().body(result);
}
auditLogService.log(companyCode, actor, action, (String) result.get("subdomain"),
Map.of("target_status", target, "reason", reason == null ? "" : reason));
return ResponseEntity.ok(result);
}
/**
* 영구 삭제. 안전장치: 요청 본문에 {@code confirm_subdomain} 을 담아 보내야 함 (프론트 타이핑 확인).
* 이 값이 현재 서브도메인과 정확히 일치해야 실행.
*/
@DeleteMapping("/companies/{companyCode}")
public ResponseEntity<Map<String, Object>> deleteCompany(HttpServletRequest req,
@PathVariable String companyCode,
@RequestBody(required = false) Map<String, Object> body) {
guard.enforce(req);
Map<String, Object> company = lifecycleService.getCompany(companyCode);
if (company == null) return ResponseEntity.notFound().build();
String currentSub = (String) company.get("subdomain");
String confirmSub = body == null ? null : (String) body.get("confirm_subdomain");
if (confirmSub == null || !confirmSub.equals(currentSub)) {
return ResponseEntity.status(400).body(Map.of(
"error", "confirmation_mismatch",
"message", "confirm_subdomain 이 실제 서브도메인과 일치해야 함"));
}
String actor = guard.actorUserId(req);
Map<String, Object> result = lifecycleService.delete(companyCode);
auditLogService.log(companyCode, actor, CompanyAuditLogService.ACTION_DELETE,
(String) result.get("db_name"),
Map.of("subdomain", result.get("subdomain"),
"meta_rows_removed", result.get("meta_rows_removed")));
return ResponseEntity.ok(result);
}
// ─────────────── 감사 로그 ───────────────
@GetMapping("/companies/{companyCode}/audit-log")
public ResponseEntity<Map<String, Object>> companyAuditLog(HttpServletRequest req,
@PathVariable String companyCode,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int limit) {
guard.enforce(req);
return ResponseEntity.ok(auditLogService.listByCompany(companyCode, page, limit));
}
@GetMapping("/audit-log")
public ResponseEntity<Map<String, Object>> globalAuditLog(HttpServletRequest req,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String action) {
guard.enforce(req);
return ResponseEntity.ok(auditLogService.listAll(page, limit, action));
}
// ─────────────── helpers ───────────────
/** jdbc:postgresql://host:port/{db_name}?... 에서 db_name 추출. */
private String resolveMetaDbName() {
int slash = metaJdbcUrl.lastIndexOf('/');
if (slash < 0) return "invyone";
String tail = metaJdbcUrl.substring(slash + 1);
int q = tail.indexOf('?');
return q < 0 ? tail : tail.substring(0, q);
}
}
@@ -47,6 +47,8 @@ public class CompanyProvisioningService {
private final AdminAccountCreator adminCreator; private final AdminAccountCreator adminCreator;
private final CompanyResolver companyResolver; private final CompanyResolver companyResolver;
private final SqlSession sqlSession; private final SqlSession sqlSession;
private final CompanyTemplatesService templatesService;
private final CompanyAuditLogService auditLogService;
@Value("${spring.datasource.url}") @Value("${spring.datasource.url}")
private String metaJdbcUrl; private String metaJdbcUrl;
@@ -121,6 +123,9 @@ public class CompanyProvisioningService {
String dbPrefix = (String) req.get("db_prefix"); String dbPrefix = (String) req.get("db_prefix");
String initialPassword = (String) req.get("initial_password"); String initialPassword = (String) req.get("initial_password");
String companyName = (String) req.get("company_name"); String companyName = (String) req.get("company_name");
// force_password_change: 기본 true (첫 로그인 시 비밀번호 변경 강제). 프론트에서 명시적으로 false 주면 그대로 반영.
Object fpc = req.get("force_password_change");
boolean forcePasswordChange = fpc == null || Boolean.parseBoolean(fpc.toString());
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<String> selectedGroupIds = (List<String>) req.getOrDefault("selected_groups", List.of()); List<String> selectedGroupIds = (List<String>) req.getOrDefault("selected_groups", List.of());
@@ -170,7 +175,7 @@ public class CompanyProvisioningService {
String adminId = dbPrefix + "_admin"; String adminId = dbPrefix + "_admin";
String adminName = (companyName == null ? companyCode : companyName) + " 관리자"; String adminName = (companyName == null ? companyCode : companyName) + " 관리자";
dst.setAutoCommit(true); dst.setAutoCommit(true);
adminCreator.createCompanyAdmin(dst, companyCode, adminId, initialPassword, adminName); adminCreator.createCompanyAdmin(dst, companyCode, adminId, initialPassword, adminName, forcePasswordChange);
} }
// Step 6: Finalize // Step 6: Finalize
@@ -179,6 +184,10 @@ public class CompanyProvisioningService {
upd.put("company_code", companyCode); upd.put("company_code", companyCode);
upd.put("db_status", "active"); upd.put("db_status", "active");
sqlSession.update("provisioning.updateDbStatus", upd); sqlSession.update("provisioning.updateDbStatus", upd);
// installed_groups 메타 저장
templatesService.saveInstalledGroups(companyCode, selectedGroupIds);
companyResolver.invalidate(subdomain); companyResolver.invalidate(subdomain);
registry.update(jobId, s -> { registry.update(jobId, s -> {
@@ -187,6 +196,13 @@ public class CompanyProvisioningService {
}); });
log.info("[Provisioning] ✅ completed: company={} db={} subdomain={}", companyCode, dbName, subdomain); log.info("[Provisioning] ✅ completed: company={} db={} subdomain={}", companyCode, dbName, subdomain);
// 감사 로그 — 성공
Map<String, Object> auditDetails = new HashMap<>();
auditDetails.put("db_name", dbName);
auditDetails.put("subdomain", subdomain);
auditDetails.put("selected_groups", selectedGroupIds);
auditLogService.log(companyCode, null, CompanyAuditLogService.ACTION_CREATE, dbName, auditDetails);
} catch (Exception e) { } catch (Exception e) {
String failedStep = registry.get(jobId).getCurrentStep(); String failedStep = registry.get(jobId).getCurrentStep();
log.error("[Provisioning] ❌ FAILED at {} for {} ({})", failedStep, dbName, e.getMessage(), e); log.error("[Provisioning] ❌ FAILED at {} for {} ({})", failedStep, dbName, e.getMessage(), e);
@@ -206,6 +222,13 @@ public class CompanyProvisioningService {
} catch (Exception ex) { } catch (Exception ex) {
log.error("[Provisioning] failed marking db_status=failed: {}", ex.getMessage()); log.error("[Provisioning] failed marking db_status=failed: {}", ex.getMessage());
} }
// 감사 로그 — 실패
Map<String, Object> auditDetails = new HashMap<>();
auditDetails.put("db_name", dbName);
auditDetails.put("subdomain", subdomain);
auditDetails.put("failed_step", failedStep);
auditLogService.logFailure(companyCode, null, CompanyAuditLogService.ACTION_CREATE_FAILED,
dbName, auditDetails, e.getMessage());
} }
} }
@@ -0,0 +1,232 @@
package com.erp.provisioning;
import com.erp.tenant.DbContextHolder;
import com.erp.tenant.TenantDbSettings;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Service;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 회사에 설치된 TableGroup 조회 + 템플릿 재복제.
*
* 재복제 정책 (MVP): {@code INSERT ... ON CONFLICT DO NOTHING}.
* 메타 DB 의 공통 템플릿(company_code IN ('*','TEMPLATE') or NULL) row 들을
* tenant DB 에 추가 — **이미 있는 row 는 스킵**, 새로 추가된 것만 반영.
* 사용자 커스터마이징 데이터는 덮어쓰지 않음.
*
* installed_groups 는 {@code COMPANY_MNG.INSTALLED_GROUPS} (JSONB) 에 저장.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class CompanyTemplatesService {
private final SqlSession sqlSession;
private final TenantDbSettings settings;
private final ObjectMapper objectMapper;
/**
* 설치된 그룹 id 조회. 메타 DB 의 COMPANY_MNG.INSTALLED_GROUPS 에서 읽어옴.
* 빈 배열이면 legacy 회사 → 필수 그룹만 최소 반환.
*/
public List<Map<String, Object>> listInstalledGroups(String companyCode) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Map<String, Object> p = Map.of("company_code", companyCode);
Map<String, Object> row = sqlSession.selectOne("provisioning.selectCompanyByCode", p);
if (row == null) return List.of();
List<String> ids = parseJsonArray(row.get("installed_groups"));
// 빈 배열 이면 legacy — 필수 그룹은 반드시 깔렸다고 가정
if (ids.isEmpty()) {
ids = TableGroup.requiredGroups().stream().map(TableGroup::name).toList();
}
List<Map<String, Object>> out = new ArrayList<>();
Set<String> idSet = new HashSet<>(ids);
for (TableGroup g : TableGroup.values()) {
Map<String, Object> m = new LinkedHashMap<>(g.toMap());
m.put("installed", idSet.contains(g.name()));
out.add(m);
}
return out;
} finally {
restoreCtx(prevCtx);
}
}
/**
* provisioning 시 initiate() 에서 호출 — selected + required 를 JSONB 로 저장.
*/
public void saveInstalledGroups(String companyCode, List<String> selectedGroupIds) {
String prevCtx = DbContextHolder.get();
DbContextHolder.setMeta();
try {
Set<String> all = new HashSet<>(selectedGroupIds == null ? List.of() : selectedGroupIds);
TableGroup.requiredGroups().forEach(g -> all.add(g.name()));
// 유효한 그룹 id 만
List<String> validated = all.stream()
.filter(id -> TableGroup.parse(id) != null)
.sorted()
.toList();
String json = objectMapper.writeValueAsString(validated);
Map<String, Object> p = new LinkedHashMap<>();
p.put("company_code", companyCode);
p.put("installed_groups_json", json);
sqlSession.update("provisioning.updateInstalledGroups", p);
log.info("[CompanyTemplates] saved installed_groups for {}: {}", companyCode, validated);
} catch (Exception e) {
log.error("[CompanyTemplates] saveInstalledGroups failed {}: {}", companyCode, e.getMessage());
} finally {
restoreCtx(prevCtx);
}
}
/**
* 템플릿 재복제. 선택된 그룹의 테이블에서 공통 template row 를 메타→tenant 로 추가.
* ON CONFLICT DO NOTHING 으로 idempotent. 사용자 데이터는 건드리지 않음.
*
* @return { tables: [{name, inserted, skipped}], total_inserted, errors }
*/
public Map<String, Object> recopy(String srcDbName, String dstDbName, List<String> selectedGroupIds) {
List<String> tables = gatherTables(selectedGroupIds);
Map<String, Object> result = new LinkedHashMap<>();
List<Map<String, Object>> perTable = new ArrayList<>();
int totalInserted = 0;
List<String> errors = new ArrayList<>();
try (Connection src = DriverManager.getConnection(
settings.buildJdbcUrl(srcDbName), settings.username(), settings.password());
Connection dst = DriverManager.getConnection(
settings.buildJdbcUrl(dstDbName), settings.username(), settings.password())) {
src.setAutoCommit(true);
dst.setAutoCommit(false);
for (String table : tables) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("table", table);
try {
int inserted = copyTableIdempotent(src, dst, table);
row.put("inserted", inserted);
row.put("status", "ok");
totalInserted += inserted;
} catch (SQLException e) {
row.put("inserted", 0);
row.put("status", "error");
row.put("error", e.getMessage());
errors.add(table + ": " + e.getMessage());
log.warn("[CompanyTemplates] recopy table={} failed: {}", table, e.getMessage());
}
perTable.add(row);
}
dst.commit();
} catch (SQLException e) {
errors.add("connection: " + e.getMessage());
log.error("[CompanyTemplates] recopy top-level failure {}: {}", dstDbName, e.getMessage());
}
result.put("tables", perTable);
result.put("total_inserted", totalInserted);
result.put("errors", errors);
result.put("selected_groups", selectedGroupIds);
return result;
}
/** 복사 + ON CONFLICT DO NOTHING. inserted row 수 반환. */
private int copyTableIdempotent(Connection src, Connection dst, String table) throws SQLException {
boolean hasCompanyCode = columnExists(src, table, "company_code");
List<String> cols = listColumns(src, table);
if (cols.isEmpty()) return 0;
String where = hasCompanyCode ? " WHERE company_code IS NULL OR company_code IN ('*', 'TEMPLATE')" : "";
String quotedCols = cols.stream().map(c -> "\"" + c + "\"").collect(Collectors.joining(","));
String selectSql = "SELECT " + quotedCols + " FROM \"" + table + "\"" + where;
String placeholders = cols.stream().map(c -> "?").collect(Collectors.joining(","));
String insertSql = "INSERT INTO \"" + table + "\" (" + quotedCols + ") VALUES (" + placeholders + ") ON CONFLICT DO NOTHING";
int inserted = 0;
try (PreparedStatement sel = src.prepareStatement(selectSql);
ResultSet rs = sel.executeQuery();
PreparedStatement ins = dst.prepareStatement(insertSql)) {
ResultSetMetaData meta = rs.getMetaData();
int colCount = meta.getColumnCount();
while (rs.next()) {
for (int i = 1; i <= colCount; i++) ins.setObject(i, rs.getObject(i));
inserted += ins.executeUpdate();
}
}
return inserted;
}
private List<String> gatherTables(List<String> selectedGroupIds) {
if (selectedGroupIds == null || selectedGroupIds.isEmpty()) return List.of();
List<String> result = new ArrayList<>();
for (String id : selectedGroupIds) {
TableGroup g = TableGroup.parse(id);
if (g != null) result.addAll(g.getTables());
}
return result;
}
private List<String> listColumns(Connection conn, String table) throws SQLException {
List<String> cols = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(
"SELECT column_name FROM information_schema.columns " +
"WHERE table_schema='public' AND lower(table_name)=lower(?) " +
"ORDER BY ordinal_position")) {
ps.setString(1, table);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) cols.add(rs.getString(1));
}
}
return cols;
}
private boolean columnExists(Connection conn, String table, String col) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(
"SELECT 1 FROM information_schema.columns " +
"WHERE table_schema='public' AND lower(table_name)=lower(?) AND lower(column_name)=lower(?)")) {
ps.setString(1, table);
ps.setString(2, col);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
private List<String> parseJsonArray(Object raw) {
if (raw == null) return List.of();
try {
return objectMapper.readValue(raw.toString(), new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("[CompanyTemplates] parse installed_groups failed: {}", e.getMessage());
return List.of();
}
}
private static void restoreCtx(String prev) {
if (prev == null) DbContextHolder.clear();
else DbContextHolder.set(prev);
}
}
@@ -0,0 +1,40 @@
package com.erp.provisioning;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
/**
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
*
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
*
* JwtAuthenticationFilter 가 request.getAttribute("user_type"), "user_id" 를 세팅한다.
*/
@Component
@Slf4j
public class SuperAdminGuard {
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
public void enforce(HttpServletRequest request) {
String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
log.warn("[ProvisioningGuard] anonymous access allowed in dev mode");
return;
}
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
}
/** 감사 로그에 기록할 actor user_id. 개발모드에서 JWT 없으면 "dev-anonymous". */
public String actorUserId(HttpServletRequest request) {
String userId = (String) request.getAttribute("user_id");
return userId == null ? "dev-anonymous" : userId;
}
}
@@ -0,0 +1,95 @@
package com.erp.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
/**
* JWT 의 force_password_change 클레임이 true 면 비밀번호 변경 완료 전까지
* 일부 허용 경로 외 모든 API 요청을 403 으로 차단한다.
*
* 허용 경로 (이 경로들만 진행 가능):
* - POST /api/auth/change-password — 실제 변경 API
* - POST /api/auth/logout — 사용자가 포기하고 로그아웃
* - POST /api/auth/refresh — 토큰 만료 시 재발급 (플래그는 그대로 유지됨)
* - GET /api/auth/me — AuthGuard 가 현재 사용자 상태 조회용
* - GET /api/auth/status — 세션 상태 체크
* - OPTIONS ** — CORS preflight 는 필터 통과 필요
*
* JwtAuthenticationFilter 다음에 등록된다. (SecurityConfig)
*/
@Slf4j
@RequiredArgsConstructor
public class ForcePasswordChangeGuardFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private static final Set<String> ALLOWED_PATHS = Set.of(
"/api/auth/change-password",
"/api/auth/logout",
"/api/auth/refresh",
"/api/auth/me",
"/api/auth/status"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// CORS preflight 는 그대로 통과
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
filterChain.doFilter(request, response);
return;
}
String path = request.getRequestURI();
// /api/** 가 아니면 이 가드와 무관
if (path == null || !path.startsWith("/api/")) {
filterChain.doFilter(request, response);
return;
}
// 토큰이 없으면 비로그인 요청 — 여기선 막지 않는다. (기존 permitAll 동작 유지)
String token = resolveToken(request);
if (!StringUtils.hasText(token) || !jwtTokenProvider.validateToken(token)) {
filterChain.doFilter(request, response);
return;
}
if (!jwtTokenProvider.isForcePasswordChange(token)) {
filterChain.doFilter(request, response);
return;
}
if (ALLOWED_PATHS.contains(path)) {
filterChain.doFilter(request, response);
return;
}
// 강제 변경 대기 상태 → 허용되지 않은 경로는 403
log.warn("[ForcePwGuard] blocked: path={} (force_password_change=true)", path);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Cache-Control", "no-store");
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
"success", false,
"errorCode", "PASSWORD_CHANGE_REQUIRED",
"message", "비밀번호 변경이 필요합니다. /change-password 에서 먼저 변경해주세요."
));
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
@@ -31,6 +31,9 @@ public class JwtTokenProvider {
*/ */
public String generateToken(Map<String, Object> personBean) { public String generateToken(Map<String, Object> personBean) {
Date now = new Date(); Date now = new Date();
Object fpc = personBean.get("force_password_change");
boolean forcePasswordChange = fpc instanceof Boolean ? (Boolean) fpc
: fpc != null && Boolean.parseBoolean(fpc.toString());
return Jwts.builder() return Jwts.builder()
.claim("user_id", personBean.get("user_id")) .claim("user_id", personBean.get("user_id"))
.claim("user_name", personBean.get("user_name")) .claim("user_name", personBean.get("user_name"))
@@ -39,6 +42,7 @@ public class JwtTokenProvider {
.claim("company_name", personBean.get("company_name")) .claim("company_name", personBean.get("company_name"))
.claim("user_type", personBean.get("user_type")) .claim("user_type", personBean.get("user_type"))
.claim("user_type_name", personBean.get("user_type_name")) .claim("user_type_name", personBean.get("user_type_name"))
.claim("force_password_change", forcePasswordChange)
.issuedAt(now) .issuedAt(now)
.expiration(new Date(now.getTime() + expiration)) .expiration(new Date(now.getTime() + expiration))
.audience().add("PMS-Users").and() .audience().add("PMS-Users").and()
@@ -47,6 +51,16 @@ public class JwtTokenProvider {
.compact(); .compact();
} }
/** JWT 의 force_password_change claim. 없는 토큰(legacy)은 false 로 간주. */
public boolean isForcePasswordChange(String token) {
try {
Boolean v = getClaims(token).get("force_password_change", Boolean.class);
return Boolean.TRUE.equals(v);
} catch (Exception e) {
return false;
}
}
public boolean validateToken(String token) { public boolean validateToken(String token) {
try { try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
@@ -58,6 +58,12 @@ public class SecurityConfig {
) )
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class) UsernamePasswordAuthenticationFilter.class)
// JwtAuthenticationFilter 뒤 — JWT.company_code 와 서브도메인의 company_code 대조.
.addFilterAfter(new TenantConsistencyGuardFilter(jwtTokenProvider),
JwtAuthenticationFilter.class)
// TenantConsistencyGuardFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
.addFilterAfter(new ForcePasswordChangeGuardFilter(jwtTokenProvider),
TenantConsistencyGuardFilter.class)
// Phase 2 (2026-04-24): 서브도메인 → CompanyResolver → TenantRoutingDataSource 라우팅. // Phase 2 (2026-04-24): 서브도메인 → CompanyResolver → TenantRoutingDataSource 라우팅.
// JwtAuthenticationFilter 보다 앞에서 실행되어야 tenant 컨텍스트가 먼저 결정됨. // JwtAuthenticationFilter 보다 앞에서 실행되어야 tenant 컨텍스트가 먼저 결정됨.
.addFilterBefore( .addFilterBefore(
@@ -0,0 +1,130 @@
package com.erp.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
/**
* 크로스-테넌트 요청 차단.
*
* 검증:
* JWT 가 있는 요청 && Host 가 테넌트 서브도메인 → JWT.company_code == tenant.company_code 여야 함.
*
* 동작:
* 1. OPTIONS (CORS preflight) → 통과
* 2. /api/** 가 아닌 요청 → 통과
* 3. JWT 없는 요청 → 통과 (로그인/회원가입 등)
* 4. JWT 있음 + 서브도메인 있음 + resolve 실패 → 거부 (악의적 요청 의심)
* 5. JWT 있음 + tenant attribute 없음 (메인 도메인) → 통과 (SUPER_ADMIN 전역 API 등)
* 6. JWT.company_code == tenant.company_code → 통과
* 7. JWT.company_code == "*" → 제한된 SUPER_ADMIN pre-switch 경로만 통과
* 8. 나머지 → 거부
*
* 필터 순서: SubdomainResolverFilter → JwtAuthenticationFilter → **여기** → ForcePasswordChangeGuardFilter
*/
@Slf4j
@RequiredArgsConstructor
public class TenantConsistencyGuardFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
/**
* SUPER_ADMIN 가 company_code="*" 로 로그인한 직후, 실제 회사로 switch 하기 전에
* 테넌트 서브도메인에서 허용해야 하는 최소 경로들.
* 여기에 실제 업무 API 를 넣지 말 것 — "*" 는 어떤 회사 DB 도 대표하지 않는 상태.
*/
private static final Set<String> STAR_ALLOWED_PATHS = Set.of(
"/api/auth/switch-company",
"/api/auth/me",
"/api/auth/status",
"/api/auth/logout",
"/api/auth/refresh"
);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
chain.doFilter(request, response);
return;
}
String path = request.getRequestURI();
if (path == null || !path.startsWith("/api/")) {
chain.doFilter(request, response);
return;
}
String token = resolveToken(request);
if (!StringUtils.hasText(token) || !jwtTokenProvider.validateToken(token)) {
// 로그인/회원가입 등 JWT 없는 요청은 통과
chain.doFilter(request, response);
return;
}
String jwtCompanyCode = jwtTokenProvider.getCompanyCode(token);
String tenantCompanyCode = (String) request.getAttribute("tenant_company_code");
Boolean attempted = (Boolean) request.getAttribute("tenant_resolve_attempted");
// (4) SUPER_ADMIN pre-switch escape — "*" 토큰 + 허용 경로는 tenant resolve 결과와 무관하게 통과.
// 미등록/stale 서브도메인에 잘못 붙어도 switch-company / logout 으로 회복할 수 있어야 함.
if ("*".equals(jwtCompanyCode) && STAR_ALLOWED_PATHS.contains(path)) {
chain.doFilter(request, response);
return;
}
// (5) 서브도메인은 있는데 resolve 실패 + JWT 있음 → 의심스러움. 거부.
if (Boolean.TRUE.equals(attempted) && tenantCompanyCode == null) {
deny(response, path, "TENANT_NOT_RESOLVED",
"등록되지 않은 서브도메인입니다.");
return;
}
// (6) tenant attribute 자체가 없음 → 메인 도메인. 이 가드는 스킵.
if (tenantCompanyCode == null) {
chain.doFilter(request, response);
return;
}
// (7) 정상 일치
if (tenantCompanyCode.equals(jwtCompanyCode)) {
chain.doFilter(request, response);
return;
}
// (8) 거부
log.warn("[TenantGuard] cross-tenant blocked: path={}, jwt={}, tenant={}",
path, jwtCompanyCode, tenantCompanyCode);
deny(response, path, "CROSS_TENANT_REJECTED",
"로그인한 회사와 접속 도메인이 일치하지 않습니다.");
}
private void deny(HttpServletResponse response, String path, String errorCode, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.setHeader("Cache-Control", "no-store");
new ObjectMapper().writeValue(response.getOutputStream(), Map.of(
"success", false,
"errorCode", errorCode,
"message", message
));
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
@@ -125,6 +125,12 @@ public class AuthService extends BaseService {
companyName = companyRow != null ? getStr(companyRow, "company_name", "") : ""; companyName = companyRow != null ? getStr(companyRow, "company_name", "") : "";
} }
// 첫 로그인 비밀번호 강제 변경 플래그 (RUN_082)
Object rawFpc = userInfoRow.get("force_password_change");
boolean forcePasswordChange = rawFpc instanceof Boolean
? (Boolean) rawFpc
: rawFpc != null && Boolean.parseBoolean(rawFpc.toString());
// 5. JWT 토큰 생성 — Node.js 동일 페이로드 구조 // 5. JWT 토큰 생성 — Node.js 동일 페이로드 구조
Map<String, Object> personBean = new HashMap<>(); Map<String, Object> personBean = new HashMap<>();
personBean.put("user_id", userId); personBean.put("user_id", userId);
@@ -134,6 +140,7 @@ public class AuthService extends BaseService {
personBean.put("company_name", companyName); personBean.put("company_name", companyName);
personBean.put("user_type", userType); personBean.put("user_type", userType);
personBean.put("user_type_name", getStr(userInfoRow, "user_type_name", "일반사용자")); personBean.put("user_type_name", getStr(userInfoRow, "user_type_name", "일반사용자"));
personBean.put("force_password_change", forcePasswordChange);
String token = jwtTokenProvider.generateToken(personBean); String token = jwtTokenProvider.generateToken(personBean);
// 6. firstMenuPath 계산 (Node.js authController 동일 로직) // 6. firstMenuPath 계산 (Node.js authController 동일 로직)
@@ -199,19 +206,82 @@ public class AuthService extends BaseService {
data.put("token", token); data.put("token", token);
data.put("first_menu_path", firstMenuPath); data.put("first_menu_path", firstMenuPath);
data.put("pop_landing_path", popLandingPath); data.put("pop_landing_path", popLandingPath);
data.put("force_password_change", forcePasswordChange);
log.info("로그인 성공: {} ({})", userId, remoteAddr); log.info("로그인 성공: {} ({}) force_pw_change={}", userId, remoteAddr, forcePasswordChange);
return data; return data;
} }
/**
* 본인 비밀번호 변경 (로그인된 사용자 기준).
* - 현재 비밀번호 BCrypt/legacy 검증
* - 새 비밀번호 BCrypt 해시 저장 + force_password_change=false 로 클리어
* - 플래그가 꺼진 새 JWT 를 발급하여 반환 — 클라이언트는 즉시 교체해야
* `ForcePasswordChangeGuardFilter` 가 다른 API 호출을 차단하지 않는다.
*
* @return `{ "token": newJwt }` — 호출자가 응답에 실어 프론트로 전달
* @throws IllegalArgumentException 현재 비밀번호 불일치 / 정책 위반
*/
public Map<String, Object> changePassword(String oldToken, String currentPassword, String newPassword) {
if (oldToken == null || !jwtTokenProvider.validateToken(oldToken)) {
throw new IllegalArgumentException("인증 정보가 없습니다.");
}
if (currentPassword == null || newPassword == null || newPassword.length() < 8) {
throw new IllegalArgumentException("새 비밀번호는 8자 이상이어야 합니다.");
}
if (currentPassword.equals(newPassword)) {
throw new IllegalArgumentException("기존 비밀번호와 동일합니다. 다른 비밀번호를 사용하세요.");
}
Claims claims = jwtTokenProvider.getClaims(oldToken);
String userId = claims.get("user_id", String.class);
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("인증 정보가 없습니다.");
}
Map<String, Object> pwRow = sqlSession.selectOne("auth.selectUserPassword", Map.of("user_id", userId));
if (pwRow == null) {
throw new IllegalArgumentException("사용자를 찾을 수 없습니다.");
}
String storedPw = (String) pwRow.get("user_password");
if (!passwordHasher.matches(currentPassword, storedPw)) {
throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다.");
}
String newHash = passwordHasher.encode(newPassword);
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("user_password", newHash);
sqlSession.update("auth.updateUserPasswordAndClearForce", params);
log.info("비밀번호 변경 + 강제 플래그 해제: {}", userId);
// 기존 claim 을 그대로 유지하되 force_password_change=false 로 새 JWT 발급
Map<String, Object> personBean = new HashMap<>();
personBean.put("user_id", userId);
personBean.put("user_name", claims.get("user_name", String.class));
personBean.put("dept_name", claims.get("dept_name", String.class));
personBean.put("company_code", claims.get("company_code", String.class));
personBean.put("company_name", claims.get("company_name", String.class));
personBean.put("user_type", claims.get("user_type", String.class));
personBean.put("user_type_name", claims.get("user_type_name", String.class));
personBean.put("force_password_change", false);
String newToken = jwtTokenProvider.generateToken(personBean);
Map<String, Object> result = new HashMap<>();
result.put("token", newToken);
return result;
}
/** /**
* 토큰 갱신 * 토큰 갱신
* 기존 클레임을 그대로 유지하고 만료시간만 갱신 * 기존 클레임을 그대로 유지하고 만료시간만 갱신
*/ */
public Map<String, Object> refreshToken(String token) { public Map<String, Object> refreshToken(String token) {
Claims claims = jwtTokenProvider.getClaims(token); Claims claims = jwtTokenProvider.getClaims(token);
String userId = claims.get("user_id", String.class);
Map<String, Object> personBean = new HashMap<>(); Map<String, Object> personBean = new HashMap<>();
personBean.put("user_id", claims.get("user_id", String.class)); personBean.put("user_id", userId);
personBean.put("user_name", claims.get("user_name", String.class)); personBean.put("user_name", claims.get("user_name", String.class));
personBean.put("dept_name", claims.get("dept_name", String.class)); personBean.put("dept_name", claims.get("dept_name", String.class));
personBean.put("company_code", claims.get("company_code", String.class)); personBean.put("company_code", claims.get("company_code", String.class));
@@ -219,6 +289,24 @@ public class AuthService extends BaseService {
personBean.put("user_type", claims.get("user_type", String.class)); personBean.put("user_type", claims.get("user_type", String.class));
personBean.put("user_type_name", claims.get("user_type_name", String.class)); personBean.put("user_type_name", claims.get("user_type_name", String.class));
// force_password_change 는 **DB 를 source of truth 로** 재조회.
// - 관리자가 다른 세션에서 비번 리셋 → DB fpc=TRUE → 해당 유저가 리프레시할 때 반영.
// - 본인이 변경 직후 발급된 토큰은 changePassword() 가 별도 경로로 false 로 만듦.
// DB 조회 실패 시엔 claim 을 그대로 쓰되, TRUE 였으면 TRUE 유지 (절대 강제를 해제하지 않도록).
boolean forcePasswordChange;
try {
Map<String, Object> row = sqlSession.selectOne("auth.selectUserInfo", Map.of("user_id", userId));
Object rawFpc = row == null ? null : row.get("force_password_change");
forcePasswordChange = rawFpc instanceof Boolean
? (Boolean) rawFpc
: rawFpc != null && Boolean.parseBoolean(rawFpc.toString());
} catch (Exception e) {
log.warn("[refreshToken] fpc DB 재조회 실패, claim 값 유지: {}", e.getMessage());
Boolean claimFpc = claims.get("force_password_change", Boolean.class);
forcePasswordChange = Boolean.TRUE.equals(claimFpc);
}
personBean.put("force_password_change", forcePasswordChange);
String newToken = jwtTokenProvider.generateToken(personBean); String newToken = jwtTokenProvider.generateToken(personBean);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
@@ -259,6 +347,13 @@ public class AuthService extends BaseService {
photoStr = "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes); photoStr = "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bytes);
} }
// force_password_change 는 JWT claim 에서 읽는다. ForcePasswordChangeGuardFilter 가
// 같은 claim 으로 판단하므로, DB 를 소스로 쓰면 비번 변경 직후 구토큰을 든 다른 탭에서
// /auth/me → DB fpc=false → AuthGuard 통과 → 업무 API 403 PASSWORD_CHANGE_REQUIRED 로
// 갇힘. claim 을 같이 써야 필터와 일관된다.
Boolean fpcClaim = claims.get("force_password_change", Boolean.class);
boolean forcePasswordChange = Boolean.TRUE.equals(fpcClaim);
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("user_id", userId); result.put("user_id", userId);
result.put("user_name", getStr(dbUser, "user_name", "")); result.put("user_name", getStr(dbUser, "user_name", ""));
@@ -273,6 +368,7 @@ public class AuthService extends BaseService {
result.put("auth_name", authNames); result.put("auth_name", authNames);
result.put("is_admin", "ADMIN".equals(userType) || "SUPER_ADMIN".equals(userType) result.put("is_admin", "ADMIN".equals(userType) || "SUPER_ADMIN".equals(userType)
|| "COMPANY_ADMIN".equals(userType)); || "COMPANY_ADMIN".equals(userType));
result.put("force_password_change", forcePasswordChange);
return result; return result;
} }
@@ -291,9 +387,11 @@ public class AuthService extends BaseService {
String userType = claims.get("user_type", String.class); String userType = claims.get("user_type", String.class);
boolean isAdmin = "plm_admin".equals(userId) || "ADMIN".equals(userType) boolean isAdmin = "plm_admin".equals(userId) || "ADMIN".equals(userType)
|| "SUPER_ADMIN".equals(userType); || "SUPER_ADMIN".equals(userType);
Boolean fpc = claims.get("force_password_change", Boolean.class);
result.put("is_logged_in", true); result.put("is_logged_in", true);
result.put("is_admin", isAdmin); result.put("is_admin", isAdmin);
result.put("force_password_change", Boolean.TRUE.equals(fpc));
return result; return result;
} }
@@ -10,7 +10,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* 서브도메인 → tenant DB 이름 매핑. * 서브도메인 → (db_name, company_code) 매핑.
* 메타 DB (COMPANY_MNG) 에서 조회, ConcurrentHashMap 으로 캐시. * 메타 DB (COMPANY_MNG) 에서 조회, ConcurrentHashMap 으로 캐시.
* null 결과(= 매핑 없음)는 캐시하지 않음 — 신규 회사 프로비저닝 직후 바로 반영되게. * null 결과(= 매핑 없음)는 캐시하지 않음 — 신규 회사 프로비저닝 직후 바로 반영되게.
*/ */
@@ -21,26 +21,45 @@ public class CompanyResolver {
private final SqlSession sqlSession; private final SqlSession sqlSession;
private final Map<String, String> cache = new ConcurrentHashMap<>(); /** subdomain → Resolved (db_name, company_code). 둘 다 묶어 한 번의 쿼리로 캐시. */
public record Resolved(String dbName, String companyCode) {}
private final Map<String, Resolved> cache = new ConcurrentHashMap<>();
/** /**
* @return tenant DB 이름, 매핑 없으면 null. * @return 매핑 있으면 Resolved, 없으면 null.
*/ */
public String resolveDbName(String subdomain) { public Resolved resolve(String subdomain) {
if (subdomain == null || subdomain.isBlank()) return null; if (subdomain == null || subdomain.isBlank()) return null;
String cached = cache.get(subdomain); Resolved cached = cache.get(subdomain);
if (cached != null) return cached; if (cached != null) return cached;
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("subdomain", subdomain); params.put("subdomain", subdomain);
String dbName = sqlSession.selectOne("tenant.resolveDbNameBySubdomain", params); Map<String, Object> row = sqlSession.selectOne("tenant.resolveTenantBySubdomain", params);
if (row == null) return null;
if (dbName != null) { String dbName = (String) row.get("db_name");
cache.put(subdomain, dbName); String companyCode = (String) row.get("company_code");
log.debug("[CompanyResolver] resolved: {} -> {}", subdomain, dbName); if (dbName == null) return null;
}
return dbName; Resolved r = new Resolved(dbName, companyCode);
cache.put(subdomain, r);
log.debug("[CompanyResolver] resolved: {} -> db={}, company={}", subdomain, dbName, companyCode);
return r;
}
/** 하위 호환: db_name 만 필요할 때 */
public String resolveDbName(String subdomain) {
Resolved r = resolve(subdomain);
return r == null ? null : r.dbName();
}
/** company_code 만 필요할 때 */
public String resolveCompanyCode(String subdomain) {
Resolved r = resolve(subdomain);
return r == null ? null : r.companyCode();
} }
/** 특정 서브도메인 캐시 무효화 (회사 수정/삭제 시) */ /** 특정 서브도메인 캐시 무효화 (회사 수정/삭제 시) */
@@ -43,19 +43,24 @@ public class SubdomainResolverFilter extends OncePerRequestFilter {
if (subdomain == null) { if (subdomain == null) {
DbContextHolder.setMeta(); DbContextHolder.setMeta();
// 메인 도메인/IP/localhost 요청은 tenant attribute 없음 — 후속 필터가 스킵
} else { } else {
// resolve 쿼리 자체는 메타 DB 를 타야 하므로 먼저 META 로 세팅. // resolve 쿼리 자체는 메타 DB 를 타야 하므로 먼저 META 로 세팅.
DbContextHolder.setMeta(); DbContextHolder.setMeta();
String dbName = companyResolver.resolveDbName(subdomain); CompanyResolver.Resolved resolved = companyResolver.resolve(subdomain);
if (dbName == null) { if (resolved == null) {
log.debug("[Tenant] unknown subdomain '{}' → META fallback (host={})", subdomain, host); log.debug("[Tenant] unknown subdomain '{}' → META fallback (host={})", subdomain, host);
// META 유지 // META 유지. TenantConsistencyGuardFilter 가 JWT 붙은 요청이면 거부.
request.setAttribute("tenant_resolve_attempted", Boolean.TRUE);
} else { } else {
ensureTenantPool(dbName); ensureTenantPool(resolved.dbName());
DbContextHolder.set(dbName); DbContextHolder.set(resolved.dbName());
log.info("[Tenant] routed: subdomain={} → dbName={} (path={})", request.setAttribute("tenant_db_name", resolved.dbName());
subdomain, dbName, request.getRequestURI()); request.setAttribute("tenant_company_code", resolved.companyCode());
request.setAttribute("tenant_resolve_attempted", Boolean.TRUE);
log.info("[Tenant] routed: subdomain={} → db={}, company={} (path={})",
subdomain, resolved.dbName(), resolved.companyCode(), request.getRequestURI());
} }
} }
@@ -32,6 +32,7 @@
, COMPANY_CODE , COMPANY_CODE
, LOCALE , LOCALE
, PHOTO , PHOTO
, FORCE_PASSWORD_CHANGE
FROM USER_INFO FROM USER_INFO
WHERE UPPER(USER_ID) = UPPER(#{user_id}) WHERE UPPER(USER_ID) = UPPER(#{user_id})
</select> </select>
@@ -72,6 +73,14 @@
WHERE USER_ID = #{user_id} WHERE USER_ID = #{user_id}
</update> </update>
<!-- 사용자 본인 비밀번호 변경 + 강제 변경 플래그 해제 (RUN_082) -->
<update id="updateUserPasswordAndClearForce" parameterType="map">
UPDATE USER_INFO
SET USER_PASSWORD = #{user_password}
, FORCE_PASSWORD_CHANGE = FALSE
WHERE UPPER(USER_ID) = UPPER(#{user_id})
</update>
<!-- 락아웃 판단용: 최근 N분 내 동일 user_id 의 로그인 실패 건수 --> <!-- 락아웃 판단용: 최근 N분 내 동일 user_id 의 로그인 실패 건수 -->
<!-- LOGIN_RESULT 컬럼은 character varying 이고 'true'/'false' 문자열로 저장됨 (boolean 아님) --> <!-- LOGIN_RESULT 컬럼은 character varying 이고 'true'/'false' 문자열로 저장됨 (boolean 아님) -->
<select id="countRecentLoginFailures" parameterType="map" resultType="int"> <select id="countRecentLoginFailures" parameterType="map" resultType="int">
@@ -90,4 +90,116 @@
WHERE COMPANY_CODE = #{company_code} WHERE COMPANY_CODE = #{company_code}
</update> </update>
<!-- 부팅 시 스키마 마이그레이션 러너용: 활성 테넌트 DB 이름 목록 -->
<select id="listActiveDbNames" resultType="string">
SELECT DB_NAME
FROM COMPANY_MNG
WHERE DB_STATUS = 'active'
AND DB_NAME IS NOT NULL
</select>
<!-- 회사 상세 조회 (lifecycle 관리 액션 수행 전 검증용) -->
<select id="selectCompanyByCode" parameterType="map" resultType="map">
SELECT
COMPANY_CODE as company_code
, COMPANY_NAME as company_name
, SUBDOMAIN as subdomain
, DB_NAME as db_name
, DB_HOST as db_host
, DB_STATUS as db_status
, STATUS as status
, COALESCE(INSTALLED_GROUPS, '[]'::jsonb) as installed_groups
, DEACTIVATED_AT as deactivated_at
, DEACTIVATION_REASON as deactivation_reason
, CREATED_DATE as created
FROM COMPANY_MNG
WHERE COMPANY_CODE = #{company_code}
</select>
<!-- 비활성화: DB_STATUS='suspended' + 타임스탬프/사유 기록 -->
<update id="deactivateCompany" parameterType="map">
UPDATE COMPANY_MNG
SET DB_STATUS = 'suspended'
, DEACTIVATED_AT = NOW()
, DEACTIVATION_REASON = #{reason}
WHERE COMPANY_CODE = #{company_code}
</update>
<!-- 재활성화: DB_STATUS='active' + 타임스탬프/사유 클리어 -->
<update id="reactivateCompany" parameterType="map">
UPDATE COMPANY_MNG
SET DB_STATUS = 'active'
, DEACTIVATED_AT = NULL
, DEACTIVATION_REASON = NULL
WHERE COMPANY_CODE = #{company_code}
</update>
<!-- 영구 삭제 (메타 DB row). 실제 DROP DATABASE 는 서비스 레이어가 먼저 실행 -->
<delete id="deleteCompany" parameterType="map">
DELETE FROM COMPANY_MNG
WHERE COMPANY_CODE = #{company_code}
</delete>
<!-- installed_groups 저장 (provisioning 시 selected_groups 를 JSONB 로) -->
<update id="updateInstalledGroups" parameterType="map">
UPDATE COMPANY_MNG
SET INSTALLED_GROUPS = #{installed_groups_json}::jsonb
WHERE COMPANY_CODE = #{company_code}
</update>
<!-- ─────────────── 회사 관리 감사 로그 (COMPANY_AUDIT_LOG) ─────────────── -->
<insert id="insertAuditLog" parameterType="map">
INSERT INTO COMPANY_AUDIT_LOG (
COMPANY_CODE
, ACTOR_USER_ID
, ACTION
, TARGET
, DETAILS
, SUCCESS
, ERROR_MESSAGE
, CREATED_AT
) VALUES (
#{company_code}
, #{actor_user_id}
, #{action}
, #{target}
, <choose>
<when test="details_json != null">#{details_json}::jsonb</when>
<otherwise>NULL</otherwise>
</choose>
, COALESCE(#{success}, TRUE)
, #{error_message}
, NOW()
)
</insert>
<select id="selectAuditLog" parameterType="map" resultType="map">
SELECT
ID as id
, COMPANY_CODE as company_code
, ACTOR_USER_ID as actor_user_id
, ACTION as action
, TARGET as target
, DETAILS as details
, SUCCESS as success
, ERROR_MESSAGE as error_message
, CREATED_AT as created_at
FROM COMPANY_AUDIT_LOG
<where>
<if test="company_code != null">AND COMPANY_CODE = #{company_code}</if>
<if test="action != null">AND ACTION = #{action}</if>
</where>
ORDER BY CREATED_AT DESC, ID DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countAuditLog" parameterType="map" resultType="long">
SELECT COUNT(*) FROM COMPANY_AUDIT_LOG
<where>
<if test="company_code != null">AND COMPANY_CODE = #{company_code}</if>
<if test="action != null">AND ACTION = #{action}</if>
</where>
</select>
</mapper> </mapper>
@@ -18,4 +18,18 @@
LIMIT 1 LIMIT 1
</select> </select>
<!--
서브도메인으로 (db_name, company_code) 동시 조회.
TenantConsistencyGuardFilter 가 JWT.company_code 와 대조하는 용도.
-->
<select id="resolveTenantBySubdomain" parameterType="map" resultType="map">
SELECT DB_NAME AS db_name
, COMPANY_CODE AS company_code
FROM COMPANY_MNG
WHERE SUBDOMAIN = #{subdomain}
AND DB_NAME IS NOT NULL
AND DB_STATUS = 'active'
LIMIT 1
</select>
</mapper> </mapper>
+70
View File
@@ -0,0 +1,70 @@
# 082 마이그레이션 — 첫 로그인 비밀번호 강제 변경 컬럼
작성일: 2026-04-24
작성자: gbpark
관련: 회사 프로비저닝 `FORCE_PW_CHANGE` 토글 실제 동작 구현
## 목적
Step 3 마법사의 "첫 로그인 시 비밀번호 변경 강제" 토글이 실제로 동작하도록,
`USER_INFO` 테이블에 플래그 컬럼을 추가한다.
- 프로비저닝 시 관리자 계정 생성 시점에 플래그 true 로 INSERT
- 로그인 시 플래그 true 면 프론트가 `/change-password` 로 강제 이동
- 비밀번호 변경 성공 시 플래그 false 로 클리어
## 추가 컬럼
| 컬럼 | 타입 | 기본값 | 용도 |
|---|---|---|---|
| `FORCE_PASSWORD_CHANGE` | BOOLEAN | `FALSE` | true 면 로그인 성공 직후 비밀번호 변경 강제. 변경 완료 시 false 로 갱신 |
> `PASSWORD_CHANGED_AT` 는 일단 추가하지 않음 — 필요해지면 후속 마이그레이션.
## SQL
```sql
-- 082: 첫 로그인 비밀번호 강제 변경 플래그
ALTER TABLE USER_INFO
ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE;
```
## 실행
### 1) 메타 DB (= 프로비저닝 스키마 원본)
신규 회사 DB 는 `SchemaCopier` 가 메타 DB 를 `pg_dump --schema-only` 로 복제하므로,
**메타 DB 에만 컬럼 추가하면 이후 신규 회사는 자동 반영**된다.
```bash
psql -h 183.99.177.40 -U postgres -d invyone <<'SQL'
ALTER TABLE USER_INFO
ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE;
SQL
```
### 2) 기존 회사 DB 들
이미 프로비저닝된 회사 DB 에도 동일 ALTER 필요. DB 이름 목록은 메타 DB 의
`COMPANY_MNG.DB_NAME` 에서 확인:
```bash
psql -h 183.99.177.40 -U postgres -d invyone -c \
"SELECT DB_NAME FROM COMPANY_MNG WHERE DB_STATUS='active';"
```
각 DB 에 대해:
```bash
for DB in $(psql -h 183.99.177.40 -U postgres -d invyone -t -c \
"SELECT DB_NAME FROM COMPANY_MNG WHERE DB_STATUS='active'"); do
psql -h 183.99.177.40 -U postgres -d "$DB" -c \
"ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE;"
done
```
## 롤백
```sql
ALTER TABLE USER_INFO DROP COLUMN IF EXISTS FORCE_PASSWORD_CHANGE;
```
+81
View File
@@ -0,0 +1,81 @@
# 083 마이그레이션 — 회사 관리 감사 로그 테이블
작성일: 2026-04-24
작성자: gbpark
관련: 회사 관리 화면 (`/admin/sysMng/subdomainList`) 라이프사이클 액션 감사 로그
## 목적
회사 관리 화면에서 수행되는 SUPER_ADMIN 레벨 액션을 메타 DB 에 추적 가능하게 기록.
### 기록 대상 액션
| action | 기록 지점 |
|---|---|
| `COMPANY_CREATE` | 프로비저닝 성공 시 (FINALIZE) |
| `COMPANY_CREATE_FAILED` | 프로비저닝 실패 시 (자동 롤백 후) |
| `COMPANY_DEACTIVATE` | `DB_STATUS=suspended` 변경 시 |
| `COMPANY_REACTIVATE` | `suspended → active` 복귀 시 |
| `COMPANY_DELETE` | 영구 삭제 (DROP DATABASE + row DELETE) 시 |
| `ADMIN_PASSWORD_RESET` | 관리자 계정 비번 재설정 시 |
| `TEMPLATES_RECOPY` | 템플릿 재복제 시 |
## 추가 테이블
```sql
CREATE TABLE IF NOT EXISTS COMPANY_AUDIT_LOG (
ID BIGSERIAL PRIMARY KEY,
COMPANY_CODE VARCHAR(30) NOT NULL,
ACTOR_USER_ID VARCHAR(50), -- 수행자 (JWT 에서 뽑아낸 user_id). 개발모드는 NULL 가능
ACTION VARCHAR(40) NOT NULL, -- 위 표의 action 값
TARGET VARCHAR(100), -- 영향받은 대상 (db_name, admin user_id 등)
DETAILS JSONB, -- 추가 컨텍스트 (reason, before/after 값 등)
SUCCESS BOOLEAN DEFAULT TRUE,
ERROR_MESSAGE TEXT, -- 실패 시 에러 메시지
CREATED_AT TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS IDX_AUDIT_COMPANY_CODE
ON COMPANY_AUDIT_LOG (COMPANY_CODE, CREATED_AT DESC);
CREATE INDEX IF NOT EXISTS IDX_AUDIT_CREATED
ON COMPANY_AUDIT_LOG (CREATED_AT DESC);
CREATE INDEX IF NOT EXISTS IDX_AUDIT_ACTION
ON COMPANY_AUDIT_LOG (ACTION, CREATED_AT DESC);
```
## 실행 (메타 DB 만)
이 테이블은 **메타 DB (`invyone`) 에만** 존재해야 한다. 테넌트 DB 는 복제 대상 아님.
`SchemaCopier` 는 메타 DB 를 `pg_dump --schema-only` 로 복제하므로, 새 테넌트 DB 에 이 테이블이
딸려가는 걸 막으려면 이 테이블은 `COMPANY_MNG` 와 같이 "메타 전용" 취급.
### SchemaCopier 의 `--exclude-table` 처리 확인 필요
현재 구현은 public 스키마 전체를 복제. `COMPANY_AUDIT_LOG` 가 테넌트 DB 에도 생성되지만
**어차피 쓰지 않으므로 빈 채로 남는다.** 용량 문제 없음 → MVP 로는 허용. 추후 정리.
```bash
psql -h 183.99.177.40 -U postgres -d invyone <<'SQL'
CREATE TABLE IF NOT EXISTS COMPANY_AUDIT_LOG (
ID BIGSERIAL PRIMARY KEY,
COMPANY_CODE VARCHAR(30) NOT NULL,
ACTOR_USER_ID VARCHAR(50),
ACTION VARCHAR(40) NOT NULL,
TARGET VARCHAR(100),
DETAILS JSONB,
SUCCESS BOOLEAN DEFAULT TRUE,
ERROR_MESSAGE TEXT,
CREATED_AT TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS IDX_AUDIT_COMPANY_CODE ON COMPANY_AUDIT_LOG (COMPANY_CODE, CREATED_AT DESC);
CREATE INDEX IF NOT EXISTS IDX_AUDIT_CREATED ON COMPANY_AUDIT_LOG (CREATED_AT DESC);
CREATE INDEX IF NOT EXISTS IDX_AUDIT_ACTION ON COMPANY_AUDIT_LOG (ACTION, CREATED_AT DESC);
SQL
```
## 롤백
```sql
DROP TABLE IF EXISTS COMPANY_AUDIT_LOG;
```
+64
View File
@@ -0,0 +1,64 @@
# 084 마이그레이션 — COMPANY_MNG 라이프사이클 컬럼 추가
작성일: 2026-04-24
작성자: gbpark
관련: 회사 관리 화면 비활성화/템플릿 트래킹 기능
## 목적
회사 관리 화면의 "비활성화 + 템플릿 탭 + 라이프사이클 이벤트" 지원을 위해
`COMPANY_MNG` 에 메타 필드 3 개 추가.
## 추가 컬럼
| 컬럼 | 타입 | 기본값 | 용도 |
|---|---|---|---|
| `INSTALLED_GROUPS` | `JSONB` | `'[]'::jsonb` | 프로비저닝 시 설치된 `TableGroup` id 배열 (예: `["SCREEN","CONTROL","BATCH","DASHBOARD"]`). 템플릿 탭 렌더에 사용 |
| `DEACTIVATED_AT` | `TIMESTAMP` | `NULL` | `DB_STATUS='suspended'` 가 된 시각. 재활성화 시 NULL 로 리셋 |
| `DEACTIVATION_REASON` | `VARCHAR(200)` | `NULL` | SUPER_ADMIN 이 입력한 비활성화 사유 (감사 로그와 별개로 COMPANY_MNG 에도 즉시 조회용) |
## SQL
```sql
-- 084: 회사 라이프사이클 메타 컬럼
ALTER TABLE COMPANY_MNG
ADD COLUMN IF NOT EXISTS INSTALLED_GROUPS JSONB DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS DEACTIVATED_AT TIMESTAMP,
ADD COLUMN IF NOT EXISTS DEACTIVATION_REASON VARCHAR(200);
```
## 실행 (메타 DB 만)
```bash
psql -h 183.99.177.40 -U postgres -d invyone <<'SQL'
ALTER TABLE COMPANY_MNG
ADD COLUMN IF NOT EXISTS INSTALLED_GROUPS JSONB DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS DEACTIVATED_AT TIMESTAMP,
ADD COLUMN IF NOT EXISTS DEACTIVATION_REASON VARCHAR(200);
SQL
```
## 기존 회사 row 백필 (선택)
기존에 이미 프로비저닝된 회사 row 는 `INSTALLED_GROUPS` 가 빈 배열로 남음.
프로비저닝 로직이 지금은 선택 그룹 정보를 버리므로 소급 복원 불가 → 빈 배열 유지.
템플릿 탭은 "정보 없음" 으로 표시.
정확한 값이 필요하면 `TableGroup.values()` 의 required=true 3개(SCREEN, CONTROL, BATCH) 는
모든 회사에 필수로 들어갔으므로 최소 백필 가능:
```sql
UPDATE COMPANY_MNG
SET INSTALLED_GROUPS = '["SCREEN","CONTROL","BATCH"]'::jsonb
WHERE INSTALLED_GROUPS = '[]'::jsonb
AND DB_STATUS = 'active';
```
## 롤백
```sql
ALTER TABLE COMPANY_MNG
DROP COLUMN IF EXISTS INSTALLED_GROUPS,
DROP COLUMN IF EXISTS DEACTIVATED_AT,
DROP COLUMN IF EXISTS DEACTIVATION_REASON;
```
@@ -0,0 +1,260 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { Lock, Eye, EyeOff, ArrowRight, Shield } from "lucide-react";
import { apiCall } from "@/lib/api/client";
import "../login/login.css";
/**
* 첫 로그인 비밀번호 강제 변경 화면.
* - 로그인 응답의 force_password_change=true 일 때 useLogin 이 여기로 보냄
* - sessionStorage.pwChangeNext 에 원래 가려던 경로가 보관되어 있음
* - 성공 시 그 경로로 이동
*
* 디자인: login 의 cosmic 배경 + glass 카드 스코프 (`.inv-login`) 를 그대로 재사용.
*/
export default function ChangePasswordPage() {
const router = useRouter();
const { theme, resolvedTheme } = useTheme();
const isDark = (resolvedTheme ?? theme) === "dark";
const rootRef = useRef<HTMLDivElement>(null);
const cosmosRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const errRef = useRef<HTMLDivElement>(null);
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const root = rootRef.current;
if (!root) return;
root.classList.toggle("dark", isDark);
}, [isDark]);
// 로그인과 동일한 cosmic 배경 생성
useEffect(() => {
const co = cosmosRef.current;
if (!co) return;
const cs = ["rgba(var(--v5-primary-rgb),.8)", "rgba(var(--v5-cyan-rgb),.7)", "rgba(var(--v5-pink-rgb),.7)"];
for (let i = 0; i < 30; i++) {
const s = document.createElement("div");
s.className = "star" + (Math.random() > 0.83 ? " c" : "");
if (s.classList.contains("c")) s.style.setProperty("--sc", cs[(Math.random() * 3) | 0]);
s.style.left = Math.random() * 100 + "%";
s.style.top = Math.random() * 100 + "%";
s.style.setProperty("--d", 2 + Math.random() * 5 + "s");
s.style.setProperty("--dl", Math.random() * 5 + "s");
s.style.setProperty("--mo", 0.3 + Math.random() * 0.7 + "");
co.appendChild(s);
}
return () => {
co.querySelectorAll(".star").forEach((el) => el.remove());
};
}, []);
useEffect(() => {
if (!error || !cardRef.current || !errRef.current) return;
const card = cardRef.current;
const errEl = errRef.current;
card.classList.remove("denied");
void card.offsetWidth;
card.classList.add("denied");
errEl.classList.add("show");
const t1 = setTimeout(() => card.classList.remove("denied"), 600);
const t2 = setTimeout(() => errEl.classList.remove("show"), 3000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}, [error]);
const handleRipple = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
const btn = e.currentTarget;
const r = btn.getBoundingClientRect();
const d = document.createElement("div");
d.className = "rip";
const sz = Math.max(r.width, r.height) * 2;
d.style.width = d.style.height = sz + "px";
d.style.left = e.clientX - r.left - sz / 2 + "px";
d.style.top = e.clientY - r.top - sz / 2 + "px";
btn.appendChild(d);
setTimeout(() => d.remove(), 600);
}, []);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!currentPw || !newPw || !confirmPw) {
setError("모든 항목을 입력해주세요.");
return;
}
if (newPw.length < 8) {
setError("새 비밀번호는 8자 이상이어야 합니다.");
return;
}
if (newPw === currentPw) {
setError("기존 비밀번호와 동일합니다. 다른 비밀번호를 사용하세요.");
return;
}
if (newPw !== confirmPw) {
setError("새 비밀번호 확인이 일치하지 않습니다.");
return;
}
setIsLoading(true);
try {
const result = await apiCall<{ token?: string }>("POST", "/auth/change-password", {
current_password: currentPw,
new_password: newPw,
});
if (!result.success) {
setError(result.message || "비밀번호 변경에 실패했습니다.");
setIsLoading(false);
return;
}
// 새 JWT 로 교체 — 서버 가드 필터가 force_password_change=false 새 토큰에서만 다른 API 를 허용한다.
const newToken = result.data?.token;
if (newToken) {
localStorage.setItem("authToken", newToken);
document.cookie = `authToken=${newToken}; path=/; max-age=86400; SameSite=Lax`;
}
const next = sessionStorage.getItem("pwChangeNext") || "/main";
sessionStorage.removeItem("pwChangeNext");
router.push(next);
} catch (err: any) {
setError(err?.message || "네트워크 오류");
setIsLoading(false);
}
}
return (
<div ref={rootRef} className="inv-login">
<div ref={cosmosRef} className="cosmos">
<div className="neb neb-1" />
<div className="neb neb-2" />
<div className="neb neb-3" />
<div className="neb neb-4" />
<div className="shooting-star" style={{ top: "12%", left: "70%" }} />
<div className="shooting-star" style={{ top: "35%", left: "55%" }} />
</div>
<div ref={cardRef} className="login-card">
<div className="login-orbit">
<div className="orbit-ring" />
<div className="orbit-ring-2" />
<div className="orbit-dot" />
<div className="orbit-core" />
</div>
<div
className="logo"
style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 8 }}
>
<Shield width={22} height={22} />
<h1 style={{ margin: 0 }}> </h1>
</div>
<div className="login-sub"> . .</div>
<form onSubmit={onSubmit}>
<div className="fg">
<div className="fi-wrap pw-w">
<Lock className="fi-icon" width={16} height={16} />
<input
className="fi"
name="current_password"
type={showCurrent ? "text" : "password"}
placeholder="현재 비밀번호 (초기 비밀번호)"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
disabled={isLoading}
autoComplete="current-password"
/>
<button
type="button"
className="pw-b"
onClick={() => setShowCurrent((v) => !v)}
disabled={isLoading}
>
{showCurrent ? <EyeOff width={16} height={16} /> : <Eye width={16} height={16} />}
</button>
</div>
</div>
<div className="fg">
<div className="fi-wrap pw-w">
<Lock className="fi-icon" width={16} height={16} />
<input
className={`fi${error ? " error" : ""}`}
name="new_password"
type={showNew ? "text" : "password"}
placeholder="새 비밀번호 (8자 이상)"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
disabled={isLoading}
autoComplete="new-password"
/>
<button
type="button"
className="pw-b"
onClick={() => setShowNew((v) => !v)}
disabled={isLoading}
>
{showNew ? <EyeOff width={16} height={16} /> : <Eye width={16} height={16} />}
</button>
</div>
</div>
<div className="fg">
<div className="fi-wrap pw-w">
<Lock className="fi-icon" width={16} height={16} />
<input
className={`fi${error ? " error" : ""}`}
name="confirm_password"
type={showNew ? "text" : "password"}
placeholder="새 비밀번호 확인"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
disabled={isLoading}
autoComplete="new-password"
/>
</div>
</div>
<div className="login-divider">
<span>change</span>
</div>
<button
type="submit"
className="lbtn"
disabled={isLoading}
onMouseDown={handleRipple}
>
{isLoading ? (
<span className="spinner" />
) : (
<>
<span></span>
<ArrowRight width={16} height={16} />
</>
)}
</button>
</form>
<div ref={errRef} className="err-msg">
{error || "새 비밀번호를 입력해주세요"}
</div>
<div className="login-ft">&copy; 2026 Invy.one</div>
</div>
</div>
);
}
@@ -7,15 +7,17 @@ import { getCompaniesStats } from "@/lib/api/provisioning";
import CompanyStatsStrip from "@/components/admin/provisioning/CompanyStatsStrip"; import CompanyStatsStrip from "@/components/admin/provisioning/CompanyStatsStrip";
import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordionRow"; import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordionRow";
import Wizard from "@/components/admin/provisioning/wizard/Wizard"; import Wizard from "@/components/admin/provisioning/wizard/Wizard";
import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
import { toCsvString, downloadCsv } from "@/lib/csvExport";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
/** /**
* SUPER_ADMIN — 회사 프로비저닝 / 서브도메인 관리. * SUPER_ADMIN — 회사 관리 (테넌트 프로비저닝 + 서브도메인 라우팅).
* 경로: /admin/sysMng/subdomainList * 경로: /admin/sysMng/subdomainList
* *
* 기존 회사 관리(/admin/userMng/companyList) 는 일반 CRUD 그대로 유지. * 기존 /admin/userMng/companyList (회사 기본 CRUD) 와는 스코프가 다름.
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅" 전용. * 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" 전용.
*/ */
export default function SubdomainListPage() { export default function SubdomainListPage() {
const [openKey, setOpenKey] = useState<string | null>(null); const [openKey, setOpenKey] = useState<string | null>(null);
@@ -23,8 +25,29 @@ export default function SubdomainListPage() {
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all"); const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
const [planFilter, setPlanFilter] = useState("all"); const [planFilter, setPlanFilter] = useState("all");
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [auditOpen, setAuditOpen] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
function exportCsv() {
const cols = [
{ key: "company_code", label: "회사코드" },
{ key: "company_name", label: "회사명" },
{ key: "subdomain", label: "서브도메인" },
{ key: "db_name", label: "DB 이름" },
{ key: "db_status", label: "DB 상태" },
{ key: "plan", label: "플랜" },
{ key: "owner", label: "대표자" },
{ key: "email", label: "이메일" },
{ key: "users", label: "사용자 수" },
{ key: "db_size", label: "DB 사용량" },
{ key: "templates", label: "설치 템플릿" },
{ key: "created", label: "생성일", format: (v: any) => (v ? String(v).slice(0, 19) : "") },
];
const csv = toCsvString(filtered, cols as any);
const ts = new Date().toISOString().replace(/[-:T.]/g, "").slice(0, 14);
downloadCsv(`invyone-companies-${ts}.csv`, csv);
}
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({ const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
queryKey: ["companies-stats"], queryKey: ["companies-stats"],
queryFn: getCompaniesStats, queryFn: getCompaniesStats,
@@ -159,32 +182,42 @@ export default function SubdomainListPage() {
}} }}
> >
<div> <div>
<div style={{ fontSize: "0.68rem", color: "var(--v5-text-sec)", fontWeight: 500, marginBottom: 3 }}> <div style={{ fontSize: "0.78rem", color: "var(--v5-text-sec)", fontWeight: 500, marginBottom: 4 }}>
<b style={{ color: "var(--v5-text)" }}></b> · · · <b style={{ color: "var(--v5-text)" }}></b> · · ·
</div> </div>
<div style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", color: "var(--v5-text)" }}> <div style={{ fontSize: "1.375rem", fontWeight: 800, letterSpacing: "-0.02em", color: "var(--v5-text)" }}>
</div> </div>
<div <div
style={{ style={{
fontSize: "0.72rem", fontSize: "0.8125rem",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
marginTop: "0.25rem", marginTop: "0.3rem",
fontWeight: 500, fontWeight: 500,
}} }}
> >
Invy.one · Invy.one ·
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: "0.4rem" }}> <div style={{ display: "flex", gap: "0.45rem" }}>
<HeaderBtn onClick={() => refetch()} icon={<RefreshCw size={11} strokeWidth={1.75} />}> <HeaderBtn onClick={() => refetch()} icon={<RefreshCw size={13} strokeWidth={1.75} />}>
</HeaderBtn> </HeaderBtn>
<HeaderBtn icon={<FileText size={11} strokeWidth={1.75} />} soon> </HeaderBtn> <HeaderBtn
<HeaderBtn icon={<Download size={11} strokeWidth={1.75} />} soon>CSV </HeaderBtn> icon={<FileText size={13} strokeWidth={1.75} />}
onClick={() => setAuditOpen(true)}
>
</HeaderBtn>
<HeaderBtn
icon={<Download size={13} strokeWidth={1.75} />}
onClick={exportCsv}
>
CSV
</HeaderBtn>
<HeaderBtn <HeaderBtn
variant="primary" variant="primary"
icon={<Plus size={11} strokeWidth={1.75} />} icon={<Plus size={13} strokeWidth={1.75} />}
onClick={() => setWizardOpen(true)} onClick={() => setWizardOpen(true)}
> >
@@ -212,7 +245,7 @@ export default function SubdomainListPage() {
alignItems: "center", alignItems: "center",
}} }}
> >
<div style={{ position: "relative", flex: "0 0 260px" }}> <div style={{ position: "relative", flex: "0 0 280px" }}>
<input <input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
@@ -220,12 +253,12 @@ export default function SubdomainListPage() {
className="prov-input" className="prov-input"
style={{ style={{
width: "100%", width: "100%",
padding: "0.4rem 0.45rem 0.4rem 1.7rem", padding: "0.45rem 0.5rem 0.45rem 1.85rem",
background: "var(--v5-surface-hover)", background: "var(--v5-surface-hover)",
border: "1px solid var(--v5-border)", border: "1px solid var(--v5-border)",
borderRadius: 0, borderRadius: 0,
color: "var(--v5-text)", color: "var(--v5-text)",
fontSize: "0.72rem", fontSize: "0.8125rem",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
outline: "none", outline: "none",
transition: "all 0.18s ease", transition: "all 0.18s ease",
@@ -234,13 +267,13 @@ export default function SubdomainListPage() {
<span <span
style={{ style={{
position: "absolute", position: "absolute",
left: 8, left: 9,
top: "50%", top: "50%",
transform: "translateY(-50%)", transform: "translateY(-50%)",
color: "var(--v5-text-muted)", color: "var(--v5-text-muted)",
}} }}
> >
<Search size={11} strokeWidth={1.75} /> <Search size={13} strokeWidth={1.75} />
</span> </span>
</div> </div>
@@ -265,7 +298,7 @@ export default function SubdomainListPage() {
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<div style={{ fontSize: "0.66rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", fontWeight: 500 }}> <div style={{ fontSize: "0.75rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", fontWeight: 500 }}>
<b style={{ color: "rgb(var(--v5-green-rgb))" }}>{activeCount}</b> <b style={{ color: "rgb(var(--v5-green-rgb))" }}>{activeCount}</b>
<span style={{ margin: "0 6px", opacity: 0.4 }}>·</span> <span style={{ margin: "0 6px", opacity: 0.4 }}>·</span>
<b style={{ color: "var(--v5-primary)" }}>{provisCount}</b> <b style={{ color: "var(--v5-primary)" }}>{provisCount}</b>
@@ -281,10 +314,10 @@ export default function SubdomainListPage() {
marginLeft: "-1.1rem", marginLeft: "-1.1rem",
marginRight: "-1.1rem", marginRight: "-1.1rem",
display: "grid", display: "grid",
gridTemplateColumns: "14px minmax(280px, 420px) minmax(160px, 1fr) 90px 110px 110px 100px 80px 18px", gridTemplateColumns: "14px minmax(300px, 440px) minmax(170px, 1fr) 100px 120px 120px 110px 90px 18px",
gap: "0.85rem", gap: "0.9rem",
padding: "0.65rem 2rem", padding: "0.75rem 2rem",
fontSize: "0.7rem", fontSize: "0.75rem",
color: "var(--v5-text)", color: "var(--v5-text)",
letterSpacing: "0.02em", letterSpacing: "0.02em",
fontWeight: 700, fontWeight: 700,
@@ -322,7 +355,7 @@ export default function SubdomainListPage() {
padding: "2.2rem", padding: "2.2rem",
textAlign: "center", textAlign: "center",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
fontSize: "0.76rem", fontSize: "0.85rem",
fontWeight: 500, fontWeight: 500,
}} }}
> >
@@ -344,7 +377,7 @@ export default function SubdomainListPage() {
padding: "2.2rem", padding: "2.2rem",
textAlign: "center", textAlign: "center",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
fontSize: "0.76rem", fontSize: "0.85rem",
fontWeight: 500, fontWeight: 500,
}} }}
> >
@@ -356,11 +389,11 @@ export default function SubdomainListPage() {
{/* footer + pagination */} {/* footer + pagination */}
<div <div
style={{ style={{
marginTop: "0.65rem", marginTop: "0.7rem",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
fontSize: "0.66rem", fontSize: "0.75rem",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
flexShrink: 0, flexShrink: 0,
@@ -389,6 +422,7 @@ export default function SubdomainListPage() {
</div> </div>
{wizardOpen && <Wizard onClose={() => setWizardOpen(false)} />} {wizardOpen && <Wizard onClose={() => setWizardOpen(false)} />}
{auditOpen && <AuditLogDrawer onClose={() => setAuditOpen(false)} />}
</div> </div>
); );
} }
@@ -416,11 +450,11 @@ function HeaderBtn({
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: "0.4rem", gap: "0.45rem",
height: 30, height: 34,
padding: "0 0.75rem", padding: "0 0.85rem",
borderRadius: 6, borderRadius: 6,
fontSize: "0.72rem", fontSize: "0.8125rem",
fontWeight: 600, fontWeight: 600,
border: `1px solid ${isPrimary ? "var(--v5-primary)" : "var(--v5-border)"}`, border: `1px solid ${isPrimary ? "var(--v5-primary)" : "var(--v5-border)"}`,
background: isPrimary ? "var(--v5-primary)" : "var(--v5-surface-solid)", background: isPrimary ? "var(--v5-primary)" : "var(--v5-surface-solid)",
@@ -437,9 +471,9 @@ function HeaderBtn({
{soon && ( {soon && (
<span <span
style={{ style={{
fontSize: "0.58rem", fontSize: "0.65rem",
fontWeight: 700, fontWeight: 700,
padding: "1px 5px", padding: "2px 6px",
borderRadius: 3, borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.18)", background: "rgba(var(--v5-amber-rgb), 0.18)",
color: "var(--v5-amber)", color: "var(--v5-amber)",
@@ -479,13 +513,13 @@ function Pagination({
disabled={disabled} disabled={disabled}
className={`prov-pagebtn ${active ? "prov-pagebtn-active" : ""}`} className={`prov-pagebtn ${active ? "prov-pagebtn-active" : ""}`}
style={{ style={{
minWidth: 24, minWidth: 28,
height: 24, height: 28,
padding: "0 7px", padding: "0 8px",
border: `1px solid ${active ? "var(--v5-primary)" : "var(--v5-border)"}`, border: `1px solid ${active ? "var(--v5-primary)" : "var(--v5-border)"}`,
background: active ? "var(--v5-primary)" : "var(--v5-surface-solid)", background: active ? "var(--v5-primary)" : "var(--v5-surface-solid)",
color: active ? "#fff" : disabled ? "var(--v5-text-muted)" : "var(--v5-text)", color: active ? "#fff" : disabled ? "var(--v5-text-muted)" : "var(--v5-text)",
fontSize: "0.68rem", fontSize: "0.75rem",
fontWeight: 600, fontWeight: 600,
fontFamily: "inherit", fontFamily: "inherit",
borderRadius: 4, borderRadius: 4,
@@ -501,24 +535,24 @@ function Pagination({
); );
return ( return (
<div style={{ display: "inline-flex", alignItems: "center", gap: 3 }}> <div style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
{btn("prev", <ChevronLeft size={11} strokeWidth={1.75} />, page - 1, page <= 1)} {btn("prev", <ChevronLeft size={13} strokeWidth={1.75} />, page - 1, page <= 1)}
{start > 1 && btn("p-1", 1, 1, false, page === 1)} {start > 1 && btn("p-1", 1, 1, false, page === 1)}
{start > 2 && <span key="lead-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}></span>} {start > 2 && <span key="lead-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}></span>}
{pages.map((p) => btn(`p-${p}`, p, p, false, p === page))} {pages.map((p) => btn(`p-${p}`, p, p, false, p === page))}
{end < totalPages - 1 && <span key="tail-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}></span>} {end < totalPages - 1 && <span key="tail-dots" style={{ padding: "0 2px", color: "var(--v5-text-muted)" }}></span>}
{end < totalPages && btn(`p-${totalPages}`, totalPages, totalPages, false, page === totalPages)} {end < totalPages && btn(`p-${totalPages}`, totalPages, totalPages, false, page === totalPages)}
{btn("next", <ChevronRight size={11} strokeWidth={1.75} />, page + 1, page >= totalPages)} {btn("next", <ChevronRight size={13} strokeWidth={1.75} />, page + 1, page >= totalPages)}
</div> </div>
); );
} }
const selectStyle: React.CSSProperties = { const selectStyle: React.CSSProperties = {
padding: "0.4rem 0.5rem", padding: "0.45rem 0.55rem",
background: "var(--v5-surface-hover)", background: "var(--v5-surface-hover)",
border: "1px solid var(--v5-border)", border: "1px solid var(--v5-border)",
borderRadius: 0, borderRadius: 0,
fontSize: "0.72rem", fontSize: "0.8125rem",
color: "var(--v5-text)", color: "var(--v5-text)",
fontFamily: "inherit", fontFamily: "inherit",
fontWeight: 500, fontWeight: 500,
+7 -4
View File
@@ -2,14 +2,17 @@ import { AuthProvider } from "@/contexts/AuthContext";
import { MenuProvider } from "@/contexts/MenuContext"; import { MenuProvider } from "@/contexts/MenuContext";
import { AppLayout } from "@/components/layout/AppLayout"; import { AppLayout } from "@/components/layout/AppLayout";
import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener"; import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener";
import { RequireAuth } from "@/components/auth/AuthGuard";
export default function MainLayout({ children }: { children: React.ReactNode }) { export default function MainLayout({ children }: { children: React.ReactNode }) {
return ( return (
<AuthProvider> <AuthProvider>
<MenuProvider> <RequireAuth>
<AppLayout>{children}</AppLayout> <MenuProvider>
<ApprovalGlobalListener /> <AppLayout>{children}</AppLayout>
</MenuProvider> <ApprovalGlobalListener />
</MenuProvider>
</RequireAuth>
</AuthProvider> </AuthProvider>
); );
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import "@/app/globals.css"; import "@/app/globals.css";
import { RequireAuth } from "@/components/auth/AuthGuard";
export const metadata = { export const metadata = {
title: "POP - 생산실적관리", title: "POP - 생산실적관리",
@@ -6,5 +7,5 @@ export const metadata = {
}; };
export default function PopLayout({ children }: { children: React.ReactNode }) { export default function PopLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <RequireAuth>{children}</RequireAuth>;
} }
@@ -0,0 +1,229 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { X, FileText, CheckCircle2, AlertTriangle } from "lucide-react";
import { getCompanyAuditLog, getGlobalAuditLog } from "@/lib/api/provisioning";
/**
* 회사 관리 감사 로그 드로어. 오른쪽에서 슬라이드.
* companyCode 를 주면 특정 회사, 안 주면 전체.
*/
const ACTION_META: Record<string, { label: string; color: string }> = {
COMPANY_CREATE: { label: "회사 생성", color: "rgb(var(--v5-green-rgb))" },
COMPANY_CREATE_FAILED: { label: "생성 실패", color: "var(--v5-red)" },
COMPANY_DEACTIVATE: { label: "비활성화", color: "var(--v5-amber)" },
COMPANY_REACTIVATE: { label: "재활성화", color: "rgb(var(--v5-green-rgb))" },
COMPANY_DELETE: { label: "영구 삭제", color: "var(--v5-red)" },
ADMIN_PASSWORD_RESET: { label: "비번 재설정", color: "var(--v5-primary)" },
TEMPLATES_RECOPY: { label: "템플릿 재복제", color: "var(--v5-cyan)" },
};
export default function AuditLogDrawer({
companyCode,
title,
onClose,
}: {
companyCode?: string;
title?: string;
onClose: () => void;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { data, isLoading } = useQuery({
queryKey: companyCode ? ["company-audit-log", companyCode] : ["global-audit-log"],
queryFn: () => (companyCode ? getCompanyAuditLog(companyCode, 1, 100) : getGlobalAuditLog(1, 100)),
staleTime: 5_000,
});
const rows: Record<string, any>[] = data?.data || [];
if (!mounted) return null;
const drawer = (
<div
role="dialog"
aria-modal="true"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed",
inset: 0,
zIndex: 95,
background: "rgba(6, 5, 14, 0.45)",
display: "flex",
justifyContent: "flex-end",
animation: "drawerOverlayIn 0.16s ease-out",
}}
>
<style>{`
@keyframes drawerOverlayIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes drawerSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
`}</style>
<div
style={{
width: "min(620px, 100%)",
height: "100%",
background: "var(--v5-bg)",
borderLeft: "1px solid var(--v5-border)",
display: "flex",
flexDirection: "column",
animation: "drawerSlideIn 0.28s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
{/* header */}
<div style={{
padding: "0.95rem 1.2rem",
background: "var(--v5-surface-solid)",
borderBottom: "1px solid var(--v5-border)",
display: "flex",
alignItems: "center",
gap: "0.75rem",
}}>
<FileText size={18} strokeWidth={1.75} color="var(--v5-primary)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "1rem", fontWeight: 800, letterSpacing: "-0.01em" }}>
{title || (companyCode ? `감사 로그 · ${companyCode}` : "전체 감사 로그")}
</div>
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", marginTop: 2, fontFamily: "var(--v5-font-mono)" }}>
{rows.length} · {data?.total ?? 0}
</div>
</div>
<button
onClick={onClose}
style={{
width: 30, height: 30, borderRadius: 7,
border: "1px solid var(--v5-border)",
background: "transparent",
color: "var(--v5-text-sec)",
cursor: "pointer",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
<X size={14} />
</button>
</div>
{/* body */}
<div style={{ flex: 1, overflowY: "auto", padding: "0.8rem 1rem" }}>
{isLoading && (
<div style={{ padding: "2rem", textAlign: "center", color: "var(--v5-text-sec)", fontSize: "0.85rem" }}>
...
</div>
)}
{!isLoading && rows.length === 0 && (
<div style={{ padding: "2rem", textAlign: "center", color: "var(--v5-text-muted)", fontSize: "0.85rem" }}>
.
</div>
)}
{!isLoading && rows.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{rows.map((r) => {
const meta = ACTION_META[r.action] || { label: r.action, color: "var(--v5-text-sec)" };
const ok = r.success !== false && r.success !== "false";
return (
<div
key={r.id}
style={{
border: "1px solid var(--v5-border)",
borderLeft: `3px solid ${meta.color}`,
borderRadius: 7,
background: "var(--v5-surface-solid)",
padding: "0.55rem 0.8rem",
display: "grid",
gridTemplateColumns: "16px 1fr auto",
gap: 10,
alignItems: "flex-start",
}}
>
<div style={{ paddingTop: 3 }}>
{ok ? (
<CheckCircle2 size={13} color="rgb(var(--v5-green-rgb))" strokeWidth={2} />
) : (
<AlertTriangle size={13} color="var(--v5-red)" strokeWidth={1.75} />
)}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={{ fontSize: "0.82rem", fontWeight: 700, color: meta.color }}>{meta.label}</span>
{!companyCode && (
<span style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)" }}>
{r.company_code}
</span>
)}
{r.target && (
<span style={{
fontSize: "0.7rem",
padding: "1px 6px",
borderRadius: 3,
background: "var(--v5-bg-subtle)",
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
}}>
{r.target}
</span>
)}
</div>
{r.details && Object.keys(r.details).length > 0 && (
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", marginTop: 3, fontFamily: "var(--v5-font-mono)" }}>
{formatDetails(r.details)}
</div>
)}
{r.error_message && (
<div style={{ fontSize: "0.72rem", color: "var(--v5-red)", marginTop: 3 }}>
{r.error_message}
</div>
)}
</div>
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-muted)", fontFamily: "var(--v5-font-mono)", whiteSpace: "nowrap" }}>
<div>{formatTime(r.created_at)}</div>
<div style={{ textAlign: "right", marginTop: 2 }}>{r.actor_user_id || "—"}</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
return createPortal(drawer, document.body);
}
function formatDetails(details: any): string {
try {
const parts: string[] = [];
for (const [k, v] of Object.entries(details)) {
if (v === null || v === undefined || v === "") continue;
if (Array.isArray(v)) {
parts.push(`${k}=[${v.join(",")}]`);
} else if (typeof v === "object") {
parts.push(`${k}=${JSON.stringify(v)}`);
} else {
parts.push(`${k}=${v}`);
}
}
const joined = parts.join(" · ");
return joined.length > 200 ? joined.slice(0, 200) + "…" : joined;
} catch {
return String(details);
}
}
function formatTime(v: any): string {
if (v == null) return "—";
try {
let d: Date;
if (typeof v === "number") d = new Date(v);
else {
const s = String(v);
d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s);
}
if (isNaN(d.getTime())) return String(v);
return d.toISOString().replace("T", " ").slice(0, 19);
} catch {
return String(v);
}
}
@@ -3,7 +3,6 @@
import { useLayoutEffect, useRef, useState } from "react"; import { useLayoutEffect, useRef, useState } from "react";
import { import {
ChevronRight, ChevronRight,
ChevronDown,
MoreHorizontal, MoreHorizontal,
Info, Info,
Users, Users,
@@ -13,16 +12,26 @@ import {
KeyRound, KeyRound,
Copy, Copy,
PauseCircle, PauseCircle,
PlayCircle,
Trash2, Trash2,
UserPlus, FileText,
RefreshCw,
} from "lucide-react"; } from "lucide-react";
import StatusDot from "./StatusDot"; import StatusDot from "./StatusDot";
import MembersTab from "./tabs/MembersTab";
import TemplatesTab from "./tabs/TemplatesTab";
import AuditLogDrawer from "./AuditLogDrawer";
import AdminInfoModal from "./modals/AdminInfoModal";
import DeactivateModal from "./modals/DeactivateModal";
import DeleteCompanyModal from "./modals/DeleteCompanyModal";
import RecopyTemplatesModal from "./modals/RecopyTemplatesModal";
import { useQuery } from "@tanstack/react-query";
import { getInstalledGroups, getCompanyAuditLog } from "@/lib/api/provisioning";
const TABS = [ const TABS = [
{ key: "overview", label: "개요", Icon: Info, soon: false }, { key: "overview", label: "개요", Icon: Info, soon: false },
{ key: "members", label: "구성원", Icon: Users, soon: true }, { key: "members", label: "구성원", Icon: Users, soon: false },
{ key: "templates", label: "템플릿", Icon: Layers, soon: true }, { key: "templates", label: "템플릿", Icon: Layers, soon: false },
{ key: "audit", label: "감사 로그", Icon: FileText, soon: false },
{ key: "danger", label: "위험 영역", Icon: AlertTriangle, soon: false }, { key: "danger", label: "위험 영역", Icon: AlertTriangle, soon: false },
] as const; ] as const;
@@ -39,11 +48,17 @@ export default function CompanyAccordionRow({
open: boolean; open: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const [tab, setTab] = useState<"overview" | "members" | "templates" | "danger">("overview"); const [tab, setTab] = useState<"overview" | "members" | "templates" | "audit" | "danger">("overview");
const tabsWrapRef = useRef<HTMLDivElement>(null); const tabsWrapRef = useRef<HTMLDivElement>(null);
const tabBtnRefs = useRef<Record<string, HTMLButtonElement | null>>({}); const tabBtnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 }); const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 });
const [adminModal, setAdminModal] = useState<false | "view" | "reset-warn">(false);
const [recopyModal, setRecopyModal] = useState(false);
const [deactivateModal, setDeactivateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
const [auditDrawer, setAuditDrawer] = useState(false);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) return; if (!open) return;
const el = tabBtnRefs.current[tab]; const el = tabBtnRefs.current[tab];
@@ -95,15 +110,15 @@ export default function CompanyAccordionRow({
background: "transparent", background: "transparent",
border: 0, border: 0,
cursor: "pointer", cursor: "pointer",
padding: "0.6rem 2rem 0.6rem 2rem", padding: "0.7rem 2rem",
display: "grid", display: "grid",
gridTemplateColumns: "14px minmax(280px, 420px) minmax(160px, 1fr) 90px 110px 110px 100px 80px 18px", gridTemplateColumns: "14px minmax(300px, 440px) minmax(170px, 1fr) 100px 120px 120px 110px 90px 18px",
gap: "0.85rem", gap: "0.9rem",
alignItems: "center", alignItems: "center",
}} }}
> >
<ChevronRight <ChevronRight
size={12} size={14}
color={open ? "var(--v5-primary)" : "var(--v5-text-muted)"} color={open ? "var(--v5-primary)" : "var(--v5-text-muted)"}
strokeWidth={1.75} strokeWidth={1.75}
style={{ style={{
@@ -114,16 +129,16 @@ export default function CompanyAccordionRow({
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}> <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontSize: "0.82rem", fontWeight: 700, letterSpacing: "-0.01em", color: "var(--v5-text)" }}> <span style={{ fontSize: "0.9rem", fontWeight: 700, letterSpacing: "-0.01em", color: "var(--v5-text)" }}>
{name} {name}
</span> </span>
</div> </div>
<div <div
style={{ style={{
fontSize: "0.64rem", fontSize: "0.72rem",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
marginTop: 2, marginTop: 3,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 6, gap: 6,
@@ -147,10 +162,10 @@ export default function CompanyAccordionRow({
<> <>
<div <div
style={{ style={{
fontSize: "0.76rem", fontSize: "0.8125rem",
fontWeight: 600, fontWeight: 600,
color: "var(--v5-text)", color: "var(--v5-text)",
lineHeight: 1.15, lineHeight: 1.2,
whiteSpace: "nowrap", whiteSpace: "nowrap",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
@@ -161,7 +176,7 @@ export default function CompanyAccordionRow({
{r.email && ( {r.email && (
<div <div
style={{ style={{
fontSize: "0.6rem", fontSize: "0.7rem",
color: "var(--v5-text-muted)", color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
marginTop: 2, marginTop: 2,
@@ -176,7 +191,7 @@ export default function CompanyAccordionRow({
{!r.email && r.representative_phone && ( {!r.email && r.representative_phone && (
<div <div
style={{ style={{
fontSize: "0.6rem", fontSize: "0.7rem",
color: "var(--v5-text-muted)", color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
marginTop: 2, marginTop: 2,
@@ -187,7 +202,7 @@ export default function CompanyAccordionRow({
)} )}
</> </>
) : ( ) : (
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)" }}></div> <div style={{ fontSize: "0.8rem", color: "var(--v5-text-muted)" }}></div>
)} )}
</div> </div>
@@ -201,18 +216,18 @@ export default function CompanyAccordionRow({
<div style={labelSm}></div> <div style={labelSm}></div>
<div <div
style={{ style={{
fontSize: "0.72rem", fontSize: "0.8rem",
fontWeight: 600, fontWeight: 600,
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
color: "var(--v5-text)", color: "var(--v5-text)",
lineHeight: 1.1, lineHeight: 1.15,
}} }}
> >
{formatRelative(r.created) || "—"} {formatRelative(r.created) || "—"}
</div> </div>
<div <div
style={{ style={{
fontSize: "0.58rem", fontSize: "0.68rem",
color: "var(--v5-text-muted)", color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
marginTop: 2, marginTop: 2,
@@ -226,25 +241,25 @@ export default function CompanyAccordionRow({
<div style={labelSm}></div> <div style={labelSm}></div>
<div <div
style={{ style={{
fontSize: "0.85rem", fontSize: "0.95rem",
fontWeight: 700, fontWeight: 700,
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
fontVariantNumeric: "tabular-nums", fontVariantNumeric: "tabular-nums",
color: "var(--v5-text)", color: "var(--v5-text)",
lineHeight: 1.1, lineHeight: 1.15,
}} }}
> >
{users} {users}
<span style={{ fontSize: "0.62rem", color: "var(--v5-text-sec)", fontWeight: 500, marginLeft: 2 }}></span> <span style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", fontWeight: 500, marginLeft: 2 }}></span>
</div> </div>
<div style={{ fontSize: "0.6rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", marginTop: 1 }}> <div style={{ fontSize: "0.68rem", color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", marginTop: 2 }}>
30 {active30} 30 {active30}
</div> </div>
</div> </div>
<div> <div>
<div style={labelSm}>DB</div> <div style={labelSm}>DB</div>
<div style={{ fontSize: "0.66rem", fontFamily: "var(--v5-font-mono)", color: "var(--v5-text)", fontWeight: 600, marginBottom: 3 }}> <div style={{ fontSize: "0.75rem", fontFamily: "var(--v5-font-mono)", color: "var(--v5-text)", fontWeight: 600, marginBottom: 4 }}>
{r.db_size || "—"} {r.db_size || "—"}
</div> </div>
<div style={{ height: 2, background: "var(--v5-border)", borderRadius: 1, overflow: "hidden" }}> <div style={{ height: 2, background: "var(--v5-border)", borderRadius: 1, overflow: "hidden" }}>
@@ -260,7 +275,7 @@ export default function CompanyAccordionRow({
<StatusDot status={r.db_status || r.status} /> <StatusDot status={r.db_status || r.status} />
<MoreHorizontal size={12} color="var(--v5-text-muted)" strokeWidth={1.75} /> <MoreHorizontal size={14} color="var(--v5-text-muted)" strokeWidth={1.75} />
</button> </button>
<div <div
@@ -299,27 +314,27 @@ export default function CompanyAccordionRow({
setTab(k); setTab(k);
}} }}
style={{ style={{
padding: "0.45rem 0.7rem 0.5rem", padding: "0.55rem 0.8rem 0.6rem",
background: "transparent", background: "transparent",
border: 0, border: 0,
cursor: "pointer", cursor: "pointer",
fontSize: "0.72rem", fontSize: "0.8125rem",
fontWeight: 600, fontWeight: 600,
color: tab === k ? "var(--v5-primary)" : "var(--v5-text-sec)", color: tab === k ? "var(--v5-primary)" : "var(--v5-text-sec)",
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 5, gap: 6,
fontFamily: "inherit", fontFamily: "inherit",
transition: "color 0.2s ease", transition: "color 0.2s ease",
}} }}
> >
<IconC size={11} strokeWidth={1.75} /> {l} <IconC size={13} strokeWidth={1.75} /> {l}
{soon && ( {soon && (
<span <span
style={{ style={{
fontSize: "0.52rem", fontSize: "0.62rem",
fontWeight: 700, fontWeight: 700,
padding: "1px 5px", padding: "2px 6px",
borderRadius: 3, borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.18)", background: "rgba(var(--v5-amber-rgb), 0.18)",
color: "var(--v5-amber)", color: "var(--v5-amber)",
@@ -338,9 +353,9 @@ export default function CompanyAccordionRow({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
fontSize: "0.6rem", fontSize: "0.72rem",
color: "var(--v5-text-muted)", color: "var(--v5-text-muted)",
padding: "0.4rem 0", padding: "0.45rem 0",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
}} }}
> >
@@ -365,11 +380,11 @@ export default function CompanyAccordionRow({
</div> </div>
{tab === "overview" && ( {tab === "overview" && (
<div style={{ display: "grid", gridTemplateColumns: "1.3fr 1fr", gap: "1.2rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1.3fr 1fr", gap: "1.3rem" }}>
{/* 기본정보 */} {/* 기본정보 */}
<div> <div>
<div style={sectionTitle}> </div> <div style={sectionTitle}> </div>
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", rowGap: "0.5rem", fontSize: "0.72rem" }}> <div style={{ display: "grid", gridTemplateColumns: "130px 1fr", rowGap: "0.55rem", fontSize: "0.8125rem" }}>
{( {(
[ [
["회사 코드", r.company_code, true], ["회사 코드", r.company_code, true],
@@ -383,12 +398,12 @@ export default function CompanyAccordionRow({
] as const ] as const
).map(([l, v, mono], i) => ( ).map(([l, v, mono], i) => (
<div key={i} style={{ display: "contents" }}> <div key={i} style={{ display: "contents" }}>
<span style={{ color: "var(--v5-text-sec)", fontSize: "0.68rem", fontWeight: 500 }}>{l}</span> <span style={{ color: "var(--v5-text-sec)", fontSize: "0.75rem", fontWeight: 500 }}>{l}</span>
<span <span
style={{ style={{
color: "var(--v5-text)", color: "var(--v5-text)",
fontFamily: mono ? "var(--v5-font-mono)" : "inherit", fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
fontSize: mono ? "0.7rem" : "0.72rem", fontSize: mono ? "0.8rem" : "0.8125rem",
fontWeight: 500, fontWeight: 500,
}} }}
> >
@@ -422,9 +437,9 @@ export default function CompanyAccordionRow({
> >
<div <div
style={{ style={{
fontSize: "0.6rem", fontSize: "0.72rem",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
marginBottom: 3, marginBottom: 4,
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
letterSpacing: "0.04em", letterSpacing: "0.04em",
fontWeight: 600, fontWeight: 600,
@@ -434,7 +449,7 @@ export default function CompanyAccordionRow({
</div> </div>
<div <div
style={{ style={{
fontSize: "0.92rem", fontSize: "1rem",
fontWeight: 800, fontWeight: 800,
color: "var(--v5-text)", color: "var(--v5-text)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
@@ -458,121 +473,380 @@ export default function CompanyAccordionRow({
> >
</ABtn> </ABtn>
<ABtn icon={<KeyRound size={11} strokeWidth={1.75} />} soon> </ABtn> <ABtn
<ABtn icon={<Copy size={11} strokeWidth={1.75} />} soon>릿 </ABtn> icon={<KeyRound size={11} strokeWidth={1.75} />}
onClick={(e) => {
e.stopPropagation();
setAdminModal("view");
}}
>
</ABtn>
<ABtn
icon={<Copy size={11} strokeWidth={1.75} />}
onClick={(e) => {
e.stopPropagation();
setRecopyModal(true);
}}
>
릿
</ABtn>
</div> </div>
</div> </div>
</div> </div>
)} )}
{tab === "members" && ( {tab === "members" && (
<EmptyNote> <MembersTab companyCode={r.company_code} open={open && tab === "members"} />
<UserPlus size={14} strokeWidth={1.75} /> tenant DB (Phase 4).
{users}.
</EmptyNote>
)} )}
{tab === "templates" && ( {tab === "templates" && (
<EmptyNote> <TemplatesTab
<Layers size={14} strokeWidth={1.75} /> 릿 {r.templates || 0}. Phase 4 . companyCode={r.company_code}
</EmptyNote> companyName={name}
dbName={dbName}
open={open && tab === "templates"}
/>
)}
{tab === "audit" && (
<CompanyAuditMini companyCode={r.company_code} open={open && tab === "audit"} onOpenFull={() => setAuditDrawer(true)} />
)} )}
{tab === "danger" && ( {tab === "danger" && (
<div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}> <div style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
{[ {(() => {
{ const isSuspended = (r.db_status || r.status) === "suspended";
t: "회사 비활성화", const rows: Array<{ t: string; d: string; b: string; c: string; Icon: any; onClick: () => void }> = [
d: "사용자 로그인 차단 · 데이터 보존 · 언제든 재활성 가능", {
b: "비활성화", t: isSuspended ? "회사 재활성화" : "회사 비활성화",
c: "var(--v5-amber)", d: isSuspended
Icon: PauseCircle, ? "사용자 로그인 재개 · 기록은 감사 로그에 남음"
}, : "사용자 로그인 차단 · 데이터 보존 · 언제든 재활성 가능",
{ b: isSuspended ? "재활성화" : "비활성화",
t: "관리자 비밀번호 재설정", c: isSuspended ? "rgb(var(--v5-green-rgb))" : "var(--v5-amber)",
d: "무작위 비밀번호 재설정 · 1회 표시", Icon: isSuspended ? PlayCircle : PauseCircle,
b: "재설정", onClick: () => setDeactivateModal(true),
c: "var(--v5-primary)", },
Icon: KeyRound, {
}, t: "관리자 비밀번호 재설정",
{ d: "무작위 임시 비밀번호 발급 · 1회 표시 · 첫 로그인 시 변경 강제",
t: "회사 영구 삭제", b: "재설정",
d: "회사 + 테넌트 DB 영구 삭제 · 복구 불가", c: "var(--v5-primary)",
b: "삭제 예약", Icon: KeyRound,
c: "var(--v5-red)", onClick: () => setAdminModal("reset-warn"),
Icon: Trash2, },
}, {
].map((row, i) => { t: "회사 영구 삭제",
const IconC = row.Icon; d: "회사 + 테넌트 DB 영구 삭제 · 복구 불가",
return ( b: "삭제",
<div c: "var(--v5-red)",
key={i} Icon: Trash2,
style={{ onClick: () => setDeleteModal(true),
display: "grid", },
gridTemplateColumns: "20px 1fr 100px", ];
gap: 12, return rows.map((row, i) => {
alignItems: "center", const IconC = row.Icon;
padding: "0.55rem 0.7rem", return (
background: "var(--v5-surface-solid)", <div
border: "1px solid var(--v5-border)", key={i}
borderRadius: 6,
}}
>
<div style={{ color: row.c }}>
<IconC size={14} strokeWidth={1.75} />
</div>
<div>
<div style={{ fontSize: "0.72rem", fontWeight: 700, color: "var(--v5-text)" }}>{row.t}</div>
<div style={{ fontSize: "0.6rem", color: "var(--v5-text-muted)", marginTop: 2 }}>{row.d}</div>
</div>
<button
disabled
title="Phase 4 에서 구현"
style={{ style={{
height: 26, display: "grid",
padding: "0 0.6rem", gridTemplateColumns: "22px 1fr 120px",
borderRadius: 5, gap: 14,
border: `1px solid ${row.c}`, alignItems: "center",
background: "transparent", padding: "0.65rem 0.8rem",
color: row.c, background: "var(--v5-surface-solid)",
fontSize: "0.62rem", border: "1px solid var(--v5-border)",
fontWeight: 700, borderRadius: 6,
cursor: "not-allowed",
opacity: 0.5,
fontFamily: "inherit",
}} }}
> >
{row.b} <div style={{ color: row.c }}>
</button> <IconC size={16} strokeWidth={1.75} />
</div> </div>
); <div>
})} <div style={{ fontSize: "0.8125rem", fontWeight: 700, color: "var(--v5-text)" }}>{row.t}</div>
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)", marginTop: 3 }}>{row.d}</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
row.onClick();
}}
style={{
height: 30,
padding: "0 0.75rem",
borderRadius: 5,
border: `1px solid ${row.c}`,
background: "transparent",
color: row.c,
fontSize: "0.72rem",
fontWeight: 700,
cursor: "pointer",
fontFamily: "inherit",
transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = row.c;
(e.currentTarget as HTMLButtonElement).style.color = "#fff";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = row.c;
}}
>
{row.b}
</button>
</div>
);
});
})()}
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* ───── 모달 / 드로어 ───── */}
{adminModal && (
<AdminInfoModal
companyCode={r.company_code}
companyName={name}
initialStage={adminModal}
onClose={() => setAdminModal(false)}
/>
)}
{recopyModal && (
<RecopyTemplatesModalWrapper
companyCode={r.company_code}
companyName={name}
dbName={dbName}
onClose={() => setRecopyModal(false)}
/>
)}
{deactivateModal && (
<DeactivateModal
companyCode={r.company_code}
companyName={name}
currentStatus={r.db_status || r.status}
onClose={() => setDeactivateModal(false)}
/>
)}
{deleteModal && (
<DeleteCompanyModal
companyCode={r.company_code}
companyName={name}
subdomain={sub}
dbName={dbName}
onClose={() => setDeleteModal(false)}
/>
)}
{auditDrawer && (
<AuditLogDrawer
companyCode={r.company_code}
title={`감사 로그 · ${name}`}
onClose={() => setAuditDrawer(false)}
/>
)}
</div> </div>
); );
} }
/** 재복제 모달은 installed groups 가 필요하므로 wrapper 에서 fetch */
function RecopyTemplatesModalWrapper({
companyCode,
companyName,
dbName,
onClose,
}: {
companyCode: string;
companyName?: string;
dbName: string;
onClose: () => void;
}) {
const { data = [], isLoading } = useQuery({
queryKey: ["installed-groups", companyCode],
queryFn: () => getInstalledGroups(companyCode),
staleTime: 30_000,
});
if (isLoading) return null;
return (
<RecopyTemplatesModal
companyCode={companyCode}
companyName={companyName}
dbName={dbName}
installedGroups={data}
onClose={onClose}
/>
);
}
const ACTION_META: Record<string, { label: string; color: string }> = {
COMPANY_CREATE: { label: "회사 생성", color: "rgb(var(--v5-green-rgb))" },
COMPANY_CREATE_FAILED: { label: "생성 실패", color: "var(--v5-red)" },
COMPANY_DEACTIVATE: { label: "비활성화", color: "var(--v5-amber)" },
COMPANY_REACTIVATE: { label: "재활성화", color: "rgb(var(--v5-green-rgb))" },
COMPANY_DELETE: { label: "영구 삭제", color: "var(--v5-red)" },
ADMIN_PASSWORD_RESET: { label: "비번 재설정", color: "var(--v5-primary)" },
TEMPLATES_RECOPY: { label: "템플릿 재복제", color: "var(--v5-cyan)" },
};
/** 감사 탭 내 인라인 미니 리스트 (최근 10건) + 드로어 열기 링크 */
function CompanyAuditMini({
companyCode,
open,
onOpenFull,
}: {
companyCode: string;
open: boolean;
onOpenFull: () => void;
}) {
const { data, isLoading } = useQuery({
queryKey: ["company-audit-log", companyCode, "mini"],
queryFn: () => getCompanyAuditLog(companyCode, 1, 10),
enabled: open,
staleTime: 5_000,
});
const rows: Record<string, any>[] = data?.data || [];
return (
<div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div style={{ fontSize: "0.75rem", color: "var(--v5-text-sec)", fontWeight: 600 }}>
10 · {data?.total ?? 0}
</div>
<button
onClick={(e) => { e.stopPropagation(); onOpenFull(); }}
style={{
background: "transparent",
border: "1px solid var(--v5-border)",
color: "var(--v5-primary)",
fontSize: "0.72rem",
fontWeight: 600,
padding: "4px 10px",
borderRadius: 5,
cursor: "pointer",
fontFamily: "inherit",
}}
>
</button>
</div>
{isLoading && (
<div style={{ padding: "1rem", textAlign: "center", color: "var(--v5-text-sec)", fontSize: "0.8rem" }}> ...</div>
)}
{!isLoading && rows.length === 0 && (
<div style={{ padding: "1rem", textAlign: "center", color: "var(--v5-text-muted)", fontSize: "0.8rem" }}>
.
</div>
)}
{!isLoading && rows.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{/* 컬럼 헤더 */}
<div style={{
display: "grid",
gridTemplateColumns: "110px 130px minmax(0, 1fr) 150px",
gap: 12,
padding: "0.3rem 0.8rem",
fontSize: "0.65rem",
fontFamily: "var(--v5-font-mono)",
color: "var(--v5-text-muted)",
letterSpacing: "0.06em",
textTransform: "uppercase",
fontWeight: 700,
}}>
<span></span>
<span></span>
<span></span>
<span style={{ textAlign: "right" }}></span>
</div>
{rows.map((r) => {
const meta = ACTION_META[r.action] || { label: r.action, color: "var(--v5-text-sec)" };
const failed = r.success === false || r.success === "false";
return (
<div
key={r.id}
style={{
display: "grid",
gridTemplateColumns: "110px 130px minmax(0, 1fr) 150px",
gap: 12,
padding: "0.5rem 0.7rem",
fontSize: "0.78rem",
border: "1px solid var(--v5-border)",
borderLeft: `3px solid ${failed ? "var(--v5-red)" : meta.color}`,
borderRadius: 5,
background: "var(--v5-surface-solid)",
alignItems: "center",
}}
>
<span style={{ fontWeight: 700, color: failed ? "var(--v5-red)" : meta.color, whiteSpace: "nowrap" }}>
{meta.label}
</span>
<span style={{
color: r.actor_user_id ? "var(--v5-text)" : "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "0.72rem",
fontWeight: 600,
}}>
{r.actor_user_id || "—"}
</span>
<span style={{
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: "0.72rem",
}}>
{r.target || "—"}
</span>
<span style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-text-muted)", fontSize: "0.7rem", textAlign: "right" }}>
{formatDateTime(r.created_at)}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
function formatDateTime(v: any): string {
if (v == null) return "—";
try {
let d: Date;
if (typeof v === "number") d = new Date(v);
else {
const s = String(v);
d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s);
}
if (isNaN(d.getTime())) return String(v);
return d.toISOString().replace("T", " ").slice(0, 19);
} catch {
return String(v);
}
}
const labelSm: React.CSSProperties = { const labelSm: React.CSSProperties = {
fontSize: "0.58rem", fontSize: "0.65rem",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
letterSpacing: "0.06em", letterSpacing: "0.06em",
textTransform: "uppercase", textTransform: "uppercase",
fontWeight: 700, fontWeight: 700,
marginBottom: 2, marginBottom: 3,
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
}; };
const sectionTitle: React.CSSProperties = { const sectionTitle: React.CSSProperties = {
fontSize: "0.62rem", fontSize: "0.72rem",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
letterSpacing: "0.06em", letterSpacing: "0.06em",
textTransform: "uppercase", textTransform: "uppercase",
fontWeight: 700, fontWeight: 700,
marginBottom: "0.55rem", marginBottom: "0.7rem",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
}; };
@@ -608,11 +882,11 @@ function ABtn({
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: "0.35rem", gap: "0.4rem",
height: 26, height: 30,
padding: "0 0.6rem", padding: "0 0.7rem",
borderRadius: 6, borderRadius: 6,
fontSize: "0.64rem", fontSize: "0.75rem",
fontWeight: 600, fontWeight: 600,
border: "1px solid var(--v5-border)", border: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)", background: "var(--v5-surface-solid)",
@@ -628,9 +902,9 @@ function ABtn({
{soon && ( {soon && (
<span <span
style={{ style={{
fontSize: "0.54rem", fontSize: "0.62rem",
fontWeight: 700, fontWeight: 700,
padding: "1px 5px", padding: "2px 5px",
borderRadius: 3, borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.18)", background: "rgba(var(--v5-amber-rgb), 0.18)",
color: "var(--v5-amber)", color: "var(--v5-amber)",
@@ -645,28 +919,6 @@ function ABtn({
); );
} }
function EmptyNote({ children }: { children: React.ReactNode }) {
return (
<div
style={{
padding: "1rem",
textAlign: "center",
border: "1px dashed var(--v5-border)",
borderRadius: 6,
color: "var(--v5-text-muted)",
fontSize: "0.66rem",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
width: "100%",
}}
>
{children}
</div>
);
}
function formatDate(v: any): string { function formatDate(v: any): string {
if (!v) return "—"; if (!v) return "—";
try { try {
@@ -729,9 +981,9 @@ function PlanBadge({ plan }: { plan: string }) {
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
fontSize: "0.6rem", fontSize: "0.7rem",
fontWeight: 700, fontWeight: 700,
padding: "3px 8px", padding: "4px 9px",
borderRadius: 4, borderRadius: 4,
background: s.bg, background: s.bg,
color: s.color, color: s.color,
@@ -23,16 +23,16 @@ export default function CompanyStatsStrip({ rows }: { rows: Record<string, any>[
const pctDB = Math.round((dbGB / dbQuotaGB) * 100); const pctDB = Math.round((dbGB / dbQuotaGB) * 100);
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
padding: "0.75rem 0.85rem", padding: "0.85rem 0.95rem",
display: "grid", display: "grid",
gridTemplateRows: "16px 32px 6px 16px", gridTemplateRows: "18px 34px 6px 18px",
rowGap: 8, rowGap: 9,
background: "var(--v5-surface-solid)", background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)", border: "1px solid var(--v5-border)",
borderRadius: 10, borderRadius: 10,
}; };
const label: React.CSSProperties = { const label: React.CSSProperties = {
fontSize: "0.68rem", fontSize: "0.75rem",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
fontWeight: 700, fontWeight: 700,
display: "flex", display: "flex",
@@ -41,7 +41,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record<string, any>[
}; };
const bigRow: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 6 }; const bigRow: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 6 };
const bigNum: React.CSSProperties = { const bigNum: React.CSSProperties = {
fontSize: "1.75rem", fontSize: "1.85rem",
fontWeight: 800, fontWeight: 800,
color: "var(--v5-text)", color: "var(--v5-text)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
@@ -49,7 +49,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record<string, any>[
letterSpacing: "-0.03em", letterSpacing: "-0.03em",
lineHeight: 1, lineHeight: 1,
}; };
const unit: React.CSSProperties = { fontSize: "0.68rem", color: "var(--v5-text-sec)", fontWeight: 500 }; const unit: React.CSSProperties = { fontSize: "0.75rem", color: "var(--v5-text-sec)", fontWeight: 500 };
const bar: React.CSSProperties = { const bar: React.CSSProperties = {
height: 4, height: 4,
borderRadius: 2, borderRadius: 2,
@@ -58,7 +58,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record<string, any>[
alignSelf: "center", alignSelf: "center",
}; };
const sub: React.CSSProperties = { const sub: React.CSSProperties = {
fontSize: "0.64rem", fontSize: "0.72rem",
color: "var(--v5-text-sec)", color: "var(--v5-text-sec)",
fontWeight: 500, fontWeight: 500,
display: "flex", display: "flex",
@@ -104,19 +104,19 @@ export default function CompanyStatsStrip({ rows }: { rows: Record<string, any>[
<div style={label}></div> <div style={label}></div>
<div style={bigRow}> <div style={bigRow}>
<span style={bigNum}>{pctActive}</span> <span style={bigNum}>{pctActive}</span>
<span style={{ ...unit, fontSize: "0.85rem", fontWeight: 600 }}>%</span> <span style={{ ...unit, fontSize: "0.9rem", fontWeight: 600 }}>%</span>
<span <span
style={{ style={{
fontSize: "0.56rem", fontSize: "0.68rem",
color: "rgb(var(--v5-green-rgb))", color: "rgb(var(--v5-green-rgb))",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
marginLeft: "auto", marginLeft: "auto",
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 2, gap: 3,
}} }}
> >
<TrendingUp size={9} strokeWidth={1.75} /> <TrendingUp size={11} strokeWidth={1.75} />
30 30
</span> </span>
</div> </div>
@@ -136,7 +136,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record<string, any>[
<span style={unit}></span> <span style={unit}></span>
<span <span
style={{ style={{
fontSize: "0.56rem", fontSize: "0.68rem",
color: "var(--v5-text-muted)", color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
marginLeft: "auto", marginLeft: "auto",
@@ -161,7 +161,7 @@ export default function CompanyStatsStrip({ rows }: { rows: Record<string, any>[
<span style={unit}>GB</span> <span style={unit}>GB</span>
<span <span
style={{ style={{
fontSize: "0.56rem", fontSize: "0.68rem",
color: "var(--v5-text-muted)", color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
marginLeft: "auto", marginLeft: "auto",
@@ -18,7 +18,7 @@ export default function Sparkline({
return ( return (
<span <span
style={{ style={{
fontSize: "0.58rem", fontSize: "0.7rem",
color: "var(--v5-text-muted)", color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
}} }}
@@ -20,16 +20,16 @@ export default function StatusDot({ status }: { status?: string }) {
style={{ style={{
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 5, gap: 6,
fontSize: "0.7rem", fontSize: "0.78rem",
fontWeight: 600, fontWeight: 600,
color: "var(--v5-text)", color: "var(--v5-text)",
}} }}
> >
<span <span
style={{ style={{
width: 6, width: 7,
height: 6, height: 7,
borderRadius: "50%", borderRadius: "50%",
background: m.color, background: m.color,
boxShadow: `0 0 3px ${m.color}`, boxShadow: `0 0 3px ${m.color}`,
@@ -0,0 +1,370 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { KeyRound, UserCircle2, Copy, Check, AlertTriangle, Eye, EyeOff, ArrowLeft } from "lucide-react";
import { getCompanyAdmin, resetAdminPassword } from "@/lib/api/provisioning";
import ModalShell, { ModalBtn } from "./ModalShell";
type Stage = "view" | "reset-warn" | "reset-done";
/**
* 관리자 계정 조회 + 비번 재설정 통합 모달.
* 모달 중첩 금지 — 한 ModalShell 안에서 stage 전환으로 처리.
*
* view : 관리자 정보 조회 (기본)
* reset-warn : 재설정 경고 + 확인
* reset-done : 새 비번 1회 표시 + 복사
*/
export default function AdminInfoModal({
companyCode,
companyName,
initialStage = "view",
onClose,
}: {
companyCode: string;
companyName?: string;
initialStage?: "view" | "reset-warn";
onClose: () => void;
}) {
const [stage, setStage] = useState<Stage>(initialStage);
const [result, setResult] = useState<{ admin_user_id: string; new_password: string } | null>(null);
const [showPw, setShowPw] = useState(false);
const [pwCopied, setPwCopied] = useState(false);
const qc = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ["company-admin", companyCode],
queryFn: () => getCompanyAdmin(companyCode),
staleTime: 5_000,
enabled: stage === "view",
});
const mutation = useMutation({
mutationFn: () => resetAdminPassword(companyCode),
onSuccess: (d) => {
if (d.error) return;
setResult({ admin_user_id: d.admin_user_id!, new_password: d.new_password! });
setStage("reset-done");
qc.invalidateQueries({ queryKey: ["company-admin", companyCode] });
qc.invalidateQueries({ queryKey: ["company-audit-log", companyCode] });
},
});
const admin = data || {};
// ─── 헤더 ───
const titleNode = (() => {
if (stage === "reset-warn" || stage === "reset-done") {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<KeyRound size={18} strokeWidth={1.75} />
</span>
);
}
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<UserCircle2 size={18} strokeWidth={1.75} />
</span>
);
})();
// ─── 본문 ───
let body: React.ReactNode = null;
if (stage === "view") {
if (isLoading) {
body = <PadCenter> ...</PadCenter>;
} else if (!admin.found) {
body = <Warn> COMPANY_ADMIN .</Warn>;
} else {
body = (
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", rowGap: "0.6rem", fontSize: "0.8125rem" }}>
<Label> ID</Label>
<ValueMono>
{admin.user_id}
<CopyInline text={admin.user_id} />
</ValueMono>
<Label></Label>
<Value>{admin.user_name || "—"}</Value>
<Label></Label>
<Value>
<Badge color={admin.status === "active" ? "green" : "muted"}>{admin.status || "—"}</Badge>
</Value>
<Label> </Label>
<Value>
<Badge color={admin.force_password_change ? "amber" : "green"}>
{admin.force_password_change ? "필요 (미완료)" : "완료됨"}
</Badge>
</Value>
<Label></Label>
<ValueMono>{formatDate(admin.created_date)}</ValueMono>
</div>
);
}
}
if (stage === "reset-warn") {
body = (
<>
<div style={warnBoxStyle}>
<AlertTriangle size={18} color="var(--v5-amber)" strokeWidth={1.75} style={{ flexShrink: 0, marginTop: 2 }} />
<div style={{ fontSize: "0.82rem", color: "var(--v5-text)", lineHeight: 1.55 }}>
<b> </b>. 1 , .
<br />
.
</div>
</div>
{mutation.isError && (
<div style={{ fontSize: "0.8rem", color: "var(--v5-red)", marginTop: 10 }}>
: {(mutation.error as Error)?.message || "재설정 실패"}
</div>
)}
</>
);
}
if (stage === "reset-done" && result) {
body = (
<>
<div style={{
padding: "0.85rem 0.95rem",
background: "rgba(var(--v5-green-rgb), 0.08)",
border: "1px solid rgba(var(--v5-green-rgb), 0.35)",
borderRadius: 8,
marginBottom: "1rem",
fontSize: "0.82rem",
color: "var(--v5-text)",
}}>
. <b> </b>. .
</div>
<div style={{ display: "grid", gridTemplateColumns: "140px 1fr", rowGap: "0.7rem", fontSize: "0.82rem" }}>
<Label> ID</Label>
<ValueMono>{result.admin_user_id}</ValueMono>
<Label> </Label>
<div style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "0.55rem 0.7rem",
background: "var(--v5-bg-subtle)",
border: "1px solid var(--v5-border)",
borderRadius: 7,
fontFamily: "var(--v5-font-mono)",
fontSize: "0.95rem",
fontWeight: 600,
color: "var(--v5-primary)",
letterSpacing: "0.05em",
}}>
<span style={{ flex: 1, userSelect: "all" }}>
{showPw ? result.new_password : "•".repeat(result.new_password.length)}
</span>
<button onClick={() => setShowPw(!showPw)} title={showPw ? "숨기기" : "표시"} style={iconBtnStyle}>
{showPw ? <EyeOff size={13} /> : <Eye size={13} />}
</button>
<button
onClick={() => {
navigator.clipboard?.writeText(result.new_password);
setPwCopied(true);
}}
title="복사"
style={{ ...iconBtnStyle, color: pwCopied ? "rgb(var(--v5-green-rgb))" : "var(--v5-text-sec)" }}
>
{pwCopied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
<Label> </Label>
<Value> .</Value>
</div>
{!pwCopied && (
<div style={{
marginTop: 10,
padding: "0.5rem 0.7rem",
borderRadius: 6,
background: "rgba(var(--v5-amber-rgb), 0.1)",
color: "var(--v5-amber)",
fontSize: "0.74rem",
fontWeight: 600,
}}>
. .
</div>
)}
</>
);
}
// ─── 푸터 ───
let footer: React.ReactNode = null;
if (stage === "view") {
footer = (
<>
<ModalBtn variant="ghost" onClick={onClose}></ModalBtn>
<ModalBtn
variant="primary"
icon={<KeyRound size={13} strokeWidth={1.75} />}
onClick={() => setStage("reset-warn")}
disabled={!admin.found}
>
</ModalBtn>
</>
);
} else if (stage === "reset-warn") {
footer = (
<>
<ModalBtn
variant="ghost"
icon={<ArrowLeft size={13} strokeWidth={1.75} />}
onClick={() => (initialStage === "view" ? setStage("view") : onClose())}
disabled={mutation.isPending}
>
{initialStage === "view" ? "뒤로" : "취소"}
</ModalBtn>
<ModalBtn
variant="primary"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
icon={<KeyRound size={13} strokeWidth={1.75} />}
>
{mutation.isPending ? "재설정 중..." : "새 비밀번호 발급"}
</ModalBtn>
</>
);
} else if (stage === "reset-done") {
footer = (
<>
{initialStage === "view" && (
<ModalBtn
variant="ghost"
onClick={() => {
setStage("view");
setResult(null);
setShowPw(false);
setPwCopied(false);
refetch();
}}
>
</ModalBtn>
)}
<ModalBtn variant="primary" onClick={onClose}></ModalBtn>
</>
);
}
return (
<ModalShell
title={titleNode}
subtitle={companyName || companyCode}
onClose={onClose}
width={560}
footer={footer}
>
{body}
</ModalShell>
);
}
// ─── UI helpers ───
function Label({ children }: { children: React.ReactNode }) {
return <span style={{ color: "var(--v5-text-sec)", fontSize: "0.75rem", fontWeight: 500, paddingTop: 3 }}>{children}</span>;
}
function Value({ children }: { children: React.ReactNode }) {
return <span style={{ color: "var(--v5-text)", fontWeight: 500, fontSize: "0.8125rem" }}>{children}</span>;
}
function ValueMono({ children }: { children: React.ReactNode }) {
return (
<span style={{ color: "var(--v5-text)", fontFamily: "var(--v5-font-mono)", fontSize: "0.82rem", display: "inline-flex", alignItems: "center", gap: 8 }}>
{children}
</span>
);
}
function PadCenter({ children }: { children: React.ReactNode }) {
return <div style={{ padding: "2rem", textAlign: "center", color: "var(--v5-text-sec)", fontSize: "0.85rem" }}>{children}</div>;
}
function Warn({ children }: { children: React.ReactNode }) {
return <div style={{ padding: "1rem 1.1rem", color: "var(--v5-amber)", fontSize: "0.85rem" }}>{children}</div>;
}
function Badge({ color, children }: { color: "green" | "amber" | "muted"; children: React.ReactNode }) {
const palette = {
green: { bg: "rgba(var(--v5-green-rgb),0.12)", fg: "rgb(var(--v5-green-rgb))" },
amber: { bg: "rgba(var(--v5-amber-rgb),0.15)", fg: "var(--v5-amber)" },
muted: { bg: "var(--v5-bg-subtle)", fg: "var(--v5-text-sec)" },
}[color];
return (
<span style={{
fontSize: "0.72rem",
padding: "3px 8px",
borderRadius: 4,
background: palette.bg,
color: palette.fg,
fontWeight: 600,
}}>{children}</span>
);
}
function CopyInline({ text }: { text: any }) {
const [copied, setCopied] = useState(false);
if (!text) return null;
return (
<button
onClick={() => {
navigator.clipboard?.writeText(String(text));
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
title="복사"
style={{
background: "transparent",
border: "1px solid var(--v5-border)",
color: copied ? "rgb(var(--v5-green-rgb))" : "var(--v5-text-muted)",
borderRadius: 4,
padding: "2px 5px",
cursor: "pointer",
fontSize: 10,
}}
>
{copied ? <Check size={11} /> : <Copy size={11} />}
</button>
);
}
function formatDate(v: any): string {
if (v == null) return "—";
try {
let d: Date;
if (typeof v === "number") d = new Date(v);
else {
const s = String(v);
d = /^\d{10,}$/.test(s) ? new Date(Number(s)) : new Date(s);
}
if (isNaN(d.getTime())) return String(v);
return d.toISOString().replace("T", " ").slice(0, 19);
} catch {
return String(v);
}
}
const iconBtnStyle: React.CSSProperties = {
background: "transparent",
border: "1px solid var(--v5-border)",
color: "var(--v5-text-sec)",
borderRadius: 4,
padding: "3px 6px",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
};
const warnBoxStyle: React.CSSProperties = {
display: "flex",
gap: 10,
padding: "0.85rem 0.95rem",
background: "rgba(var(--v5-amber-rgb), 0.1)",
border: "1px solid rgba(var(--v5-amber-rgb), 0.35)",
borderRadius: 8,
marginBottom: "0.2rem",
alignItems: "flex-start",
};
@@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { PauseCircle, PlayCircle, AlertTriangle } from "lucide-react";
import { patchCompanyStatus } from "@/lib/api/provisioning";
import ModalShell, { ModalBtn } from "./ModalShell";
/**
* 비활성화 / 재활성화 공용 모달.
* mode="deactivate": 사유 입력 필수 (감사 로그 용)
* mode="reactivate": 간단 확인
*/
export default function DeactivateModal({
companyCode,
companyName,
currentStatus,
onClose,
}: {
companyCode: string;
companyName?: string;
currentStatus: string;
onClose: () => void;
}) {
const mode: "deactivate" | "reactivate" = currentStatus === "suspended" ? "reactivate" : "deactivate";
const [reason, setReason] = useState("");
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: () =>
patchCompanyStatus(
companyCode,
mode === "deactivate" ? "suspended" : "active",
mode === "deactivate" ? reason : undefined,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["companies-stats"] });
qc.invalidateQueries({ queryKey: ["company-audit-log", companyCode] });
onClose();
},
});
const isDeactivate = mode === "deactivate";
return (
<ModalShell
title={
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
{isDeactivate ? <PauseCircle size={18} strokeWidth={1.75} /> : <PlayCircle size={18} strokeWidth={1.75} />}
{isDeactivate ? "회사 비활성화" : "회사 재활성화"}
</span>
}
subtitle={companyName || companyCode}
onClose={onClose}
width={520}
footer={
<>
<ModalBtn variant="ghost" onClick={onClose}></ModalBtn>
<ModalBtn
variant={isDeactivate ? "danger" : "primary"}
onClick={() => mutation.mutate()}
disabled={mutation.isPending || (isDeactivate && reason.trim().length < 3)}
>
{mutation.isPending
? "처리 중..."
: isDeactivate
? "비활성화 실행"
: "재활성화 실행"}
</ModalBtn>
</>
}
>
{isDeactivate ? (
<>
<div style={{
display: "flex",
gap: 10,
padding: "0.85rem 0.95rem",
background: "rgba(var(--v5-amber-rgb), 0.1)",
border: "1px solid rgba(var(--v5-amber-rgb), 0.35)",
borderRadius: 8,
marginBottom: "1rem",
alignItems: "flex-start",
}}>
<AlertTriangle size={18} color="var(--v5-amber)" strokeWidth={1.75} style={{ flexShrink: 0, marginTop: 2 }} />
<div style={{ fontSize: "0.82rem", color: "var(--v5-text)", lineHeight: 1.5 }}>
<b> </b>. DB , .
</div>
</div>
<label style={{ display: "block", fontSize: "0.75rem", fontWeight: 600, color: "var(--v5-text-sec)", marginBottom: 6, letterSpacing: "0.05em", textTransform: "uppercase", fontFamily: "var(--v5-font-mono)" }}>
<span style={{ color: "var(--v5-red)" }}>*</span>
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="예: 결제 미납, 계약 종료, 보안 이슈 등"
rows={3}
style={{
width: "100%",
padding: "0.55rem 0.7rem",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 7,
fontSize: "0.82rem",
color: "var(--v5-text)",
fontFamily: "inherit",
resize: "vertical",
outline: "none",
}}
/>
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)", marginTop: 5 }}>
. 3 .
</div>
</>
) : (
<div style={{ padding: "0.5rem 0", fontSize: "0.88rem", color: "var(--v5-text)", lineHeight: 1.6 }}>
<b> </b>. , .
</div>
)}
{mutation.isError && (
<div style={{ fontSize: "0.8rem", color: "var(--v5-red)", marginTop: 10 }}>
: {(mutation.error as Error)?.message}
</div>
)}
{mutation.data?.error && (
<div style={{ fontSize: "0.8rem", color: "var(--v5-red)", marginTop: 10 }}>
{String(mutation.data.error)}
</div>
)}
</ModalShell>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Trash2, AlertOctagon } from "lucide-react";
import { deleteCompany } from "@/lib/api/provisioning";
import ModalShell, { ModalBtn } from "./ModalShell";
/**
* 영구 삭제 — 서브도메인 타이핑 확인 + 2-step.
* 1단계: 경고
* 2단계: 서브도메인 타이핑 + 최종 삭제 버튼
*/
export default function DeleteCompanyModal({
companyCode,
companyName,
subdomain,
dbName,
onClose,
}: {
companyCode: string;
companyName?: string;
subdomain: string;
dbName: string;
onClose: () => void;
}) {
const [stage, setStage] = useState<1 | 2>(1);
const [typed, setTyped] = useState("");
const qc = useQueryClient();
const typedOk = typed.trim() === subdomain;
const mutation = useMutation({
mutationFn: () => deleteCompany(companyCode, typed.trim()),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["companies-stats"] });
onClose();
},
});
return (
<ModalShell
title={<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><AlertOctagon size={18} strokeWidth={1.75} color="var(--v5-red)" /> </span>}
subtitle={companyName || companyCode}
onClose={onClose}
width={560}
footer={
stage === 1 ? (
<>
<ModalBtn variant="ghost" onClick={onClose}></ModalBtn>
<ModalBtn variant="danger" onClick={() => setStage(2)} icon={<Trash2 size={13} strokeWidth={1.75} />}>
,
</ModalBtn>
</>
) : (
<>
<ModalBtn variant="ghost" onClick={onClose} disabled={mutation.isPending}></ModalBtn>
<ModalBtn
variant="danger"
onClick={() => mutation.mutate()}
disabled={!typedOk || mutation.isPending}
icon={<Trash2 size={13} strokeWidth={1.75} />}
>
{mutation.isPending ? "삭제 중..." : "영구 삭제"}
</ModalBtn>
</>
)
}
>
{stage === 1 && (
<div style={{ fontSize: "0.85rem", color: "var(--v5-text)", lineHeight: 1.7 }}>
<div style={{
padding: "0.9rem 1rem",
background: "rgba(var(--v5-red-rgb), 0.08)",
border: "1px solid rgba(var(--v5-red-rgb), 0.4)",
borderRadius: 8,
marginBottom: "1rem",
fontWeight: 600,
color: "var(--v5-red)",
}}>
<b> .</b>
</div>
<p> <b> </b>:</p>
<ul style={{ paddingLeft: 22, margin: "0.5rem 0 0.8rem" }}>
<li> DB <b style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-red)" }}>{dbName}</b> (<code>DROP DATABASE</code>)</li>
<li> · · · </li>
<li> DB COMPANY_MNG row</li>
<li> <b style={{ fontFamily: "var(--v5-font-mono)" }}>{subdomain}.invyone.com</b> </li>
</ul>
<p style={{ color: "var(--v5-text-sec)", fontSize: "0.8rem" }}>
. .
</p>
</div>
)}
{stage === 2 && (
<div>
<p style={{ fontSize: "0.85rem", color: "var(--v5-text)", lineHeight: 1.6, marginBottom: "0.8rem" }}>
<b style={{ color: "var(--v5-red)" }}></b> .
</p>
<div style={{
padding: "0.55rem 0.7rem",
background: "var(--v5-bg-subtle)",
border: "1px solid var(--v5-border)",
borderRadius: 7,
fontFamily: "var(--v5-font-mono)",
fontSize: "0.85rem",
color: "var(--v5-text-sec)",
marginBottom: "0.6rem",
userSelect: "all",
}}>
: <b style={{ color: "var(--v5-red)" }}>{subdomain}</b>
</div>
<input
value={typed}
onChange={(e) => setTyped(e.target.value)}
placeholder={subdomain}
autoFocus
style={{
width: "100%",
padding: "0.6rem 0.75rem",
background: "var(--v5-surface-solid)",
border: `1px solid ${typed.length === 0 ? "var(--v5-border)" : typedOk ? "rgb(var(--v5-green-rgb))" : "var(--v5-red)"}`,
borderRadius: 7,
fontSize: "0.9rem",
fontFamily: "var(--v5-font-mono)",
color: "var(--v5-text)",
outline: "none",
transition: "all 0.18s ease",
}}
/>
{mutation.isError && (
<div style={{ fontSize: "0.8rem", color: "var(--v5-red)", marginTop: 10 }}>
: {(mutation.error as Error)?.message}
</div>
)}
</div>
)}
</ModalShell>
);
}
@@ -0,0 +1,186 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
/**
* 회사 관리 모달 공용 셸 — v5 토큰/글로우 따름. 반투명/blur 금지.
*
* ⚠ createPortal 로 document.body 에 렌더링.
* 이유: accordion 의 부모에 transform 이 걸려 있으면 position:fixed 가 viewport 가 아니라
* 그 transform 부모 기준으로 포지셔닝됨 (CSS containing-block 규칙). Portal 로 탈출.
*/
export default function ModalShell({
title,
subtitle,
onClose,
width = 520,
children,
footer,
}: {
title: React.ReactNode;
subtitle?: React.ReactNode;
onClose: () => void;
width?: number;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
// 헤더는 항상 테마 primary 색의 아주 연한 그라데이션 (variant 구분 없음).
const headerBg =
"linear-gradient(90deg, rgba(var(--v5-primary-rgb), 0.05), transparent 70%)";
const shell = (
<div
role="dialog"
aria-modal="true"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 90,
background: "rgba(6, 5, 14, 0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
animation: "modalOverlayIn 0.18s ease-out",
}}
>
<style>{`
@keyframes modalOverlayIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes modalShellIn {
from { opacity: 0; transform: translateY(10px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
<div
style={{
width: `min(${width}px, 100%)`,
maxHeight: "calc(100vh - 48px)",
display: "flex",
flexDirection: "column",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 14,
overflow: "hidden",
boxShadow: "0 12px 32px rgba(0, 0, 0, 0.28), 0 0 18px rgba(var(--v5-primary-rgb), 0.06)",
animation: "modalShellIn 0.28s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<div
style={{
padding: "0.95rem 1.2rem",
background: headerBg,
borderBottom: "1px solid var(--v5-border)",
display: "flex",
alignItems: "center",
gap: "0.75rem",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "1.05rem", fontWeight: 800, letterSpacing: "-0.01em", color: "var(--v5-text)" }}>
{title}
</div>
{subtitle && (
<div style={{ fontSize: "0.75rem", color: "var(--v5-text-sec)", marginTop: 3, fontWeight: 500 }}>
{subtitle}
</div>
)}
</div>
<button
onClick={onClose}
aria-label="닫기"
style={{
width: 30,
height: 30,
borderRadius: 7,
border: "1px solid var(--v5-border)",
background: "transparent",
color: "var(--v5-text-sec)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={14} />
</button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "1.1rem 1.2rem" }}>{children}</div>
{footer && (
<div
style={{
padding: "0.8rem 1.2rem",
borderTop: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
display: "flex",
alignItems: "center",
gap: "0.55rem",
justifyContent: "flex-end",
}}
>
{footer}
</div>
)}
</div>
</div>
);
return createPortal(shell, document.body);
}
export function ModalBtn({
children,
onClick,
variant = "secondary",
disabled,
icon,
}: {
children: React.ReactNode;
onClick?: () => void;
variant?: "primary" | "secondary" | "ghost" | "danger";
disabled?: boolean;
icon?: React.ReactNode;
}) {
const map: Record<string, React.CSSProperties> = {
primary: { background: "var(--v5-primary)", color: "#fff", borderColor: "var(--v5-primary)" },
danger: { background: "var(--v5-red)", color: "#fff", borderColor: "var(--v5-red)" },
secondary: {
background: "var(--v5-surface-solid)",
color: "var(--v5-text)",
borderColor: "var(--v5-border)",
},
ghost: { background: "transparent", color: "var(--v5-text-sec)", borderColor: "transparent" },
};
return (
<button
onClick={disabled ? undefined : onClick}
disabled={disabled}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
height: 34,
padding: "0 0.9rem",
borderRadius: 8,
fontSize: "0.8125rem",
fontWeight: 600,
border: "1px solid transparent",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
fontFamily: "inherit",
whiteSpace: "nowrap",
transition: "all 0.15s ease",
...map[variant],
}}
>
{icon}
{children}
</button>
);
}
@@ -0,0 +1,197 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy, AlertTriangle, Check } from "lucide-react";
import { recopyTemplates } from "@/lib/api/provisioning";
import ModalShell, { ModalBtn } from "./ModalShell";
/**
* 템플릿 재복제 — 설치된 그룹 중 선택해서 메타 DB 에서 공통 템플릿 row 재적용.
* INSERT ... ON CONFLICT DO NOTHING 이라 기존 데이터는 그대로, 새 template row 만 추가됨.
*/
export default function RecopyTemplatesModal({
companyCode,
companyName,
dbName,
installedGroups,
onClose,
onSuccess,
}: {
companyCode: string;
companyName?: string;
dbName: string;
installedGroups: Record<string, any>[];
onClose: () => void;
onSuccess?: () => void;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: () => recopyTemplates(companyCode, Array.from(selected)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["companies-stats"] });
qc.invalidateQueries({ queryKey: ["company-audit-log", companyCode] });
},
});
const toggle = (id: string) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
setSelected(next);
};
const result = mutation.data;
return (
<ModalShell
title={<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><Copy size={18} strokeWidth={1.75} /> 릿 </span>}
subtitle={(companyName || companyCode) + " · " + dbName}
onClose={mutation.isPending ? () => {} : onClose}
width={620}
footer={
result ? (
<ModalBtn variant="primary" onClick={() => { onSuccess?.(); onClose(); }}></ModalBtn>
) : (
<>
<ModalBtn variant="ghost" onClick={onClose} disabled={mutation.isPending}></ModalBtn>
<ModalBtn
variant="primary"
onClick={() => mutation.mutate()}
disabled={selected.size === 0 || mutation.isPending}
icon={<Copy size={13} strokeWidth={1.75} />}
>
{mutation.isPending ? "복사 중..." : `재복제 (${selected.size}개 그룹)`}
</ModalBtn>
</>
)
}
>
{!result && (
<>
<div style={{
display: "flex",
gap: 10,
padding: "0.75rem 0.85rem",
background: "var(--v5-bg-subtle)",
border: "1px solid var(--v5-border)",
borderRadius: 7,
marginBottom: "1rem",
alignItems: "flex-start",
}}>
<AlertTriangle size={16} color="var(--v5-text-sec)" strokeWidth={1.75} style={{ flexShrink: 0, marginTop: 1 }} />
<div style={{ fontSize: "0.78rem", color: "var(--v5-text-sec)", lineHeight: 1.5 }}>
DB 릿 row (<code>company_code IN ('*','TEMPLATE')</code>) DB
. <b> </b> INSERT ON CONFLICT DO NOTHING .
</div>
</div>
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", letterSpacing: "0.05em", textTransform: "uppercase", fontWeight: 700, fontFamily: "var(--v5-font-mono)", marginBottom: 7 }}>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginBottom: "0.7rem" }}>
{installedGroups.map((g) => {
const checked = selected.has(g.id);
return (
<label
key={g.id}
style={{
display: "grid",
gridTemplateColumns: "18px 1fr 90px",
gap: 10,
alignItems: "center",
padding: "0.55rem 0.7rem",
background: checked ? "rgba(var(--v5-primary-rgb), 0.06)" : "var(--v5-surface-solid)",
border: `1px solid ${checked ? "var(--v5-primary)" : "var(--v5-border)"}`,
borderRadius: 7,
cursor: "pointer",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(g.id)}
style={{ accentColor: "var(--v5-primary)" }}
/>
<div>
<div style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--v5-text)" }}>{g.label}</div>
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 1, fontFamily: "var(--v5-font-mono)" }}>
{g.id} · {(g.tables || []).length}
</div>
</div>
<span style={{ textAlign: "right", fontSize: "0.7rem", color: g.installed ? "rgb(var(--v5-green-rgb))" : "var(--v5-text-muted)", fontWeight: 600 }}>
{g.installed ? "설치됨" : "미설치"}
</span>
</label>
);
})}
</div>
{mutation.isError && (
<div style={{ fontSize: "0.8rem", color: "var(--v5-red)" }}>
: {(mutation.error as Error)?.message}
</div>
)}
</>
)}
{result && (
<>
<div style={{
padding: "0.85rem 0.95rem",
background: result.errors?.length
? "rgba(var(--v5-amber-rgb), 0.1)"
: "rgba(var(--v5-green-rgb), 0.08)",
border: `1px solid ${result.errors?.length ? "rgba(var(--v5-amber-rgb), 0.35)" : "rgba(var(--v5-green-rgb), 0.35)"}`,
borderRadius: 8,
marginBottom: "1rem",
fontSize: "0.85rem",
color: "var(--v5-text)",
}}>
{result.errors?.length ? (
<><b style={{ color: "var(--v5-amber)" }}> </b> {result.total_inserted} row , {result.errors.length} </>
) : (
<><b style={{ color: "rgb(var(--v5-green-rgb))" }}> </b> {result.total_inserted} row ( )</>
)}
</div>
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-sec)", letterSpacing: "0.05em", textTransform: "uppercase", fontWeight: 700, fontFamily: "var(--v5-font-mono)", marginBottom: 7 }}>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4, maxHeight: 260, overflowY: "auto" }}>
{(result.tables || []).map((t: any) => (
<div
key={t.table}
style={{
display: "grid",
gridTemplateColumns: "16px 1fr 80px",
gap: 10,
alignItems: "center",
padding: "0.4rem 0.6rem",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 5,
fontSize: "0.78rem",
}}
>
{t.status === "ok" ? (
<Check size={11} color="rgb(var(--v5-green-rgb))" strokeWidth={2} />
) : (
<AlertTriangle size={11} color="var(--v5-red)" strokeWidth={1.75} />
)}
<span style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t.table}
</span>
<span style={{ textAlign: "right", fontFamily: "var(--v5-font-mono)", color: t.status === "ok" ? "var(--v5-primary)" : "var(--v5-red)", fontWeight: 600 }}>
+{t.inserted}
</span>
</div>
))}
</div>
</>
)}
</ModalShell>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { UserCircle2, Shield, User } from "lucide-react";
import { getCompanyMembers } from "@/lib/api/provisioning";
const TYPE_STYLE: Record<string, { color: string; bg: string; label: string }> = {
COMPANY_ADMIN: { color: "var(--v5-primary)", bg: "rgba(var(--v5-primary-rgb), 0.1)", label: "관리자" },
ADMIN: { color: "var(--v5-cyan)", bg: "rgba(var(--v5-cyan-rgb), 0.1)", label: "부관리자" },
USER: { color: "var(--v5-text-sec)", bg: "var(--v5-bg-subtle)", label: "일반" },
};
export default function MembersTab({ companyCode, open }: { companyCode: string; open: boolean }) {
const { data, isLoading } = useQuery({
queryKey: ["company-members", companyCode],
queryFn: () => getCompanyMembers(companyCode),
enabled: open,
staleTime: 10_000,
});
const members: Record<string, any>[] = data?.members || [];
if (isLoading) {
return <EmptyBox> ...</EmptyBox>;
}
if (members.length === 0) {
return <EmptyBox> .</EmptyBox>;
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{
display: "grid",
gridTemplateColumns: "minmax(160px, 1fr) minmax(140px, 1fr) 100px 90px 140px",
gap: "0.8rem",
padding: "0.4rem 0.7rem",
fontSize: "0.7rem",
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
letterSpacing: "0.05em",
textTransform: "uppercase",
fontWeight: 700,
borderBottom: "1px solid var(--v5-border)",
}}>
<span>ID</span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{members.map((m) => {
const typeStyle = TYPE_STYLE[m.user_type] || TYPE_STYLE.USER;
const Icon = m.user_type === "COMPANY_ADMIN" ? Shield : m.user_type === "ADMIN" ? UserCircle2 : User;
return (
<div
key={m.user_id}
style={{
display: "grid",
gridTemplateColumns: "minmax(160px, 1fr) minmax(140px, 1fr) 100px 90px 140px",
gap: "0.8rem",
padding: "0.5rem 0.7rem",
fontSize: "0.78rem",
alignItems: "center",
borderRadius: 6,
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
}}
>
<span style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-text)", fontWeight: 500, display: "inline-flex", alignItems: "center", gap: 6 }}>
<Icon size={12} strokeWidth={1.75} color={typeStyle.color} />
{m.user_id}
</span>
<span style={{ color: "var(--v5-text)" }}>{m.user_name || "—"}</span>
<span>
<span style={{
fontSize: "0.68rem",
padding: "2px 7px",
borderRadius: 4,
background: typeStyle.bg,
color: typeStyle.color,
fontWeight: 700,
fontFamily: "var(--v5-font-mono)",
}}>
{typeStyle.label}
</span>
</span>
<span>
<span style={{
fontSize: "0.68rem",
padding: "2px 7px",
borderRadius: 4,
background: m.status === "active" ? "rgba(var(--v5-green-rgb),0.1)" : "var(--v5-bg-subtle)",
color: m.status === "active" ? "rgb(var(--v5-green-rgb))" : "var(--v5-text-muted)",
fontWeight: 600,
}}>
{m.status || "—"}
</span>
</span>
<span style={{ color: "var(--v5-text-sec)", fontFamily: "var(--v5-font-mono)", fontSize: "0.7rem" }}>
{formatDate(m.created_date)}
</span>
</div>
);
})}
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-muted)", textAlign: "right", marginTop: 4 }}>
<b style={{ color: "var(--v5-text)" }}>{members.length}</b>
</div>
</div>
);
}
function EmptyBox({ children }: { children: React.ReactNode }) {
return (
<div
style={{
padding: "1.2rem",
textAlign: "center",
border: "1px dashed var(--v5-border)",
borderRadius: 6,
color: "var(--v5-text-muted)",
fontSize: "0.8125rem",
}}
>
{children}
</div>
);
}
function formatDate(v: any): string {
if (!v) return "—";
try {
const d = new Date(String(v));
if (isNaN(d.getTime())) return String(v);
return d.toISOString().slice(0, 10);
} catch {
return String(v);
}
}
@@ -0,0 +1,219 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Layers, Copy, Check, ChevronDown, ChevronRight } from "lucide-react";
import { getInstalledGroups } from "@/lib/api/provisioning";
import RecopyTemplatesModal from "../modals/RecopyTemplatesModal";
export default function TemplatesTab({
companyCode,
companyName,
dbName,
open,
}: {
companyCode: string;
companyName?: string;
dbName: string;
open: boolean;
}) {
const { data = [], isLoading, refetch } = useQuery({
queryKey: ["installed-groups", companyCode],
queryFn: () => getInstalledGroups(companyCode),
enabled: open,
staleTime: 30_000,
});
const [recopyOpen, setRecopyOpen] = useState(false);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
if (isLoading) return <EmptyBox> ...</EmptyBox>;
const installed = data.filter((g) => g.installed);
const notInstalled = data.filter((g) => !g.installed);
return (
<>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontSize: "0.75rem",
color: "var(--v5-text-sec)",
fontWeight: 600,
}}>
<span>
릿 <b style={{ color: "var(--v5-text)" }}>{installed.length}</b> · {data.length}
</span>
<button
onClick={() => setRecopyOpen(true)}
className="prov-hbtn prov-hbtn-secondary"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
height: 30,
padding: "0 0.75rem",
borderRadius: 6,
fontSize: "0.75rem",
fontWeight: 600,
border: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
color: "var(--v5-text)",
cursor: "pointer",
fontFamily: "inherit",
}}
>
<Copy size={12} strokeWidth={1.75} /> 릿
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{data.map((g) => {
const expanded = expandedKeys.has(g.id);
const tables: string[] = g.tables || [];
return (
<div
key={g.id}
style={{
border: "1px solid var(--v5-border)",
borderRadius: 7,
background: g.installed ? "var(--v5-surface-solid)" : "var(--v5-bg-subtle)",
overflow: "hidden",
opacity: g.installed ? 1 : 0.65,
}}
>
<button
onClick={() => {
const next = new Set(expandedKeys);
if (expanded) next.delete(g.id); else next.add(g.id);
setExpandedKeys(next);
}}
style={{
width: "100%",
display: "grid",
gridTemplateColumns: "16px 1fr 100px 80px",
gap: 10,
alignItems: "center",
padding: "0.55rem 0.75rem",
background: "transparent",
border: 0,
cursor: "pointer",
textAlign: "left",
fontFamily: "inherit",
}}
>
{expanded ? (
<ChevronDown size={14} color="var(--v5-text-muted)" strokeWidth={1.75} />
) : (
<ChevronRight size={14} color="var(--v5-text-muted)" strokeWidth={1.75} />
)}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Layers size={13} color="var(--v5-primary)" strokeWidth={1.75} />
<span style={{ fontSize: "0.82rem", fontWeight: 700, color: "var(--v5-text)" }}>{g.label}</span>
{g.required && (
<span style={{
fontSize: "0.62rem",
padding: "2px 6px",
borderRadius: 3,
background: "var(--v5-bg-subtle)",
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
fontWeight: 700,
letterSpacing: "0.04em",
}}>
</span>
)}
</div>
<span style={{
fontSize: "0.7rem",
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
}}>
{tables.length}
</span>
<span style={{ justifySelf: "flex-end" }}>
{g.installed ? (
<span style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.7rem",
color: "rgb(var(--v5-green-rgb))",
fontWeight: 700,
}}>
<Check size={11} strokeWidth={2} />
</span>
) : (
<span style={{ fontSize: "0.7rem", color: "var(--v5-text-muted)", fontWeight: 600 }}>
</span>
)}
</span>
</button>
{expanded && (
<div style={{
padding: "0.5rem 0.8rem 0.7rem 2.2rem",
borderTop: "1px solid var(--v5-border)",
background: "var(--v5-bg-subtle)",
display: "flex",
flexWrap: "wrap",
gap: 5,
}}>
{tables.map((t: string) => (
<span
key={t}
style={{
fontSize: "0.7rem",
padding: "2px 7px",
borderRadius: 3,
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
color: "var(--v5-text-sec)",
fontFamily: "var(--v5-font-mono)",
}}
>
{t}
</span>
))}
</div>
)}
</div>
);
})}
</div>
</div>
{recopyOpen && (
<RecopyTemplatesModal
companyCode={companyCode}
companyName={companyName}
dbName={dbName}
installedGroups={data}
onClose={() => setRecopyOpen(false)}
onSuccess={() => {
setRecopyOpen(false);
refetch();
}}
/>
)}
</>
);
}
function EmptyBox({ children }: { children: React.ReactNode }) {
return (
<div
style={{
padding: "1.2rem",
textAlign: "center",
border: "1px dashed var(--v5-border)",
borderRadius: 6,
color: "var(--v5-text-muted)",
fontSize: "0.8125rem",
}}
>
{children}
</div>
);
}
@@ -78,6 +78,7 @@ export default function Step4Run({
address: state.address, address: state.address,
selected_groups: state.selected_groups || [], selected_groups: state.selected_groups || [],
initial_password: state.initial_password, initial_password: state.initial_password,
force_password_change: state.force_password_change !== false,
}; };
const resp = await createCompany(payload); const resp = await createCompany(payload);
setJobId(resp.provisioning_id); setJobId(resp.provisioning_id);
@@ -21,6 +21,7 @@ import Step1Basic from "./Step1Basic";
import Step2Template from "./Step2Template"; import Step2Template from "./Step2Template";
import Step3Admin from "./Step3Admin"; import Step3Admin from "./Step3Admin";
import Step4Run from "./Step4Run"; import Step4Run from "./Step4Run";
import AuditLogDrawer from "../AuditLogDrawer";
type RunDone = { type RunDone = {
success: boolean; success: boolean;
@@ -46,6 +47,7 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
4: false, 4: false,
}); });
const [runDone, setRunDone] = useState<RunDone>(null); const [runDone, setRunDone] = useState<RunDone>(null);
const [auditOpen, setAuditOpen] = useState(false);
function setState(patch: Record<string, any>) { function setState(patch: Record<string, any>) {
setStateRaw((s) => ({ ...s, ...patch })); setStateRaw((s) => ({ ...s, ...patch }));
@@ -230,20 +232,20 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div <div
style={{ style={{
fontSize: "0.72rem", fontSize: "0.75rem",
fontWeight: 700, fontWeight: 700,
letterSpacing: "0.02em", letterSpacing: "0.02em",
color: "var(--v5-cyan)", color: "var(--v5-cyan)",
}} }}
> >
</div> </div>
<div <div
style={{ style={{
fontSize: "1.15rem", fontSize: "1.25rem",
fontWeight: 800, fontWeight: 800,
letterSpacing: "-0.02em", letterSpacing: "-0.02em",
marginTop: 2, marginTop: 3,
color: "var(--v5-text)", color: "var(--v5-text)",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
@@ -376,7 +378,12 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
> >
· ·
</div> </div>
<Btn icon={<FileText size={13} strokeWidth={1.75} />} disabled soon> </Btn> <Btn
icon={<FileText size={13} strokeWidth={1.75} />}
onClick={() => setAuditOpen(true)}
>
</Btn>
<Btn <Btn
variant="cyan" variant="cyan"
icon={<ArrowUpRight size={13} strokeWidth={1.75} />} icon={<ArrowUpRight size={13} strokeWidth={1.75} />}
@@ -397,20 +404,17 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
<div <div
style={{ style={{
flex: 1, flex: 1,
fontSize: "0.76rem", fontSize: "0.78rem",
color: "var(--v5-red)", color: "var(--v5-red)",
fontWeight: 600, fontWeight: 600,
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 6, gap: 7,
}} }}
> >
<AlertTriangle size={14} strokeWidth={1.75} /> <AlertTriangle size={14} strokeWidth={1.75} />
· DB · DB (DROP DATABASE)
</div> </div>
<Btn variant="danger" icon={<Trash2 size={13} strokeWidth={1.75} />} disabled soon>
(DB )
</Btn>
<Btn variant="cyan" icon={<RefreshCw size={13} strokeWidth={1.75} />} onClick={tryClose}> <Btn variant="cyan" icon={<RefreshCw size={13} strokeWidth={1.75} />} onClick={tryClose}>
</Btn> </Btn>
@@ -443,6 +447,13 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
)} )}
</div> </div>
</div> </div>
{auditOpen && runDone?.companyCode && (
<AuditLogDrawer
companyCode={runDone.companyCode}
title={`감사 로그 · ${runDone.companyCode}`}
onClose={() => setAuditOpen(false)}
/>
)}
</div> </div>
); );
} }
@@ -30,12 +30,12 @@ export function Field({
<div style={{ gridColumn: full ? "1 / -1" : "auto", display: "flex", flexDirection: "column", gap: 5 }}> <div style={{ gridColumn: full ? "1 / -1" : "auto", display: "flex", flexDirection: "column", gap: 5 }}>
<div <div
style={{ style={{
fontSize: "0.7rem", fontSize: "0.75rem",
color: error ? "var(--v5-red)" : "var(--v5-text-muted)", color: error ? "var(--v5-red)" : "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)", fontFamily: "var(--v5-font-mono)",
letterSpacing: "0.08em", letterSpacing: "0.08em",
textTransform: "uppercase", textTransform: "uppercase",
marginBottom: 2, marginBottom: 3,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 6, gap: 6,
+15 -10
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, ReactNode } from "react"; import { useEffect, ReactNode } from "react";
import { useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { AuthLogger } from "@/lib/authLogger"; import { AuthLogger } from "@/lib/authLogger";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
@@ -27,19 +27,17 @@ export function AuthGuard({
redirectTo = "/login", redirectTo = "/login",
fallback, fallback,
}: AuthGuardProps) { }: AuthGuardProps) {
const { isLoggedIn, isAdmin, loading } = useAuth(); const { isLoggedIn, isAdmin, loading, forcePasswordChange } = useAuth();
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
useEffect(() => { useEffect(() => {
if (loading) return; if (loading) return;
// 토큰이 있는데 아직 인증 확인 중이면 대기 // loading=false 인데 토큰만 localStorage 에 남아있고 isLoggedIn=false 라면
if (typeof window !== "undefined") { // useAuth 가 refreshUserData 에서 이미 removeToken 을 실행했어야 함.
const token = localStorage.getItem("authToken"); // 혹시라도 외부가 stale 토큰을 set 해 놓은 경우엔 여기서 stuck 되지 않도록
if (token && !isLoggedIn && !loading) { // 그냥 리다이렉트로 진행시킨다. (이전 early-return 은 영구 stuck 을 유발했음)
return;
}
}
if (requireAuth && !isLoggedIn) { if (requireAuth && !isLoggedIn) {
AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`); AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`);
@@ -47,12 +45,19 @@ export function AuthGuard({
return; return;
} }
// 로그인은 됐지만 비밀번호 강제 변경 대기 — /change-password 외 경로는 모두 막음
if (isLoggedIn && forcePasswordChange && pathname !== "/change-password") {
AuthLogger.log("AUTH_GUARD_BLOCK", `force_password_change=true → /change-password 강제 이동`);
router.push("/change-password");
return;
}
if (requireAdmin && !isAdmin) { if (requireAdmin && !isAdmin) {
AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`); AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`);
router.push(redirectTo); router.push(redirectTo);
return; return;
} }
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, redirectTo, router]); }, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, forcePasswordChange, pathname, redirectTo, router]);
if (loading) { if (loading) {
return ( return (
+4 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useRef, useState } from "react"; import { Fragment, useCallback, useRef, useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
type UIMenu = { type UIMenu = {
@@ -65,8 +65,9 @@ export function TopNavBar({ menus, isMenuActive, onSelect }: TopNavBarProps) {
const isOpen = openId === sec.id; const isOpen = openId === sec.id;
const isActive = isMenuActive(sec); const isActive = isMenuActive(sec);
return ( return (
<Fragment key={sec.id}>
{i > 0 && <span className="v5-hdr-sep" aria-hidden="true" />}
<div <div
key={sec.id}
className={`v5-tn-section ${isActive ? "on" : ""} ${isOpen ? "open" : ""}`} className={`v5-tn-section ${isActive ? "on" : ""} ${isOpen ? "open" : ""}`}
onMouseEnter={() => openNow(sec.id)} onMouseEnter={() => openNow(sec.id)}
onMouseLeave={scheduleClose} onMouseLeave={scheduleClose}
@@ -107,6 +108,7 @@ export function TopNavBar({ menus, isMenuActive, onSelect }: TopNavBarProps) {
</div> </div>
)} )}
</div> </div>
</Fragment>
); );
})} })}
</nav> </nav>
+2
View File
@@ -25,6 +25,7 @@ interface UserInfo {
sabun?: string; sabun?: string;
photo?: string | null; photo?: string | null;
company_code?: string; company_code?: string;
force_password_change?: boolean;
} }
interface AuthStatus { interface AuthStatus {
@@ -323,6 +324,7 @@ export const useAuth = () => {
isLoggedIn: authStatus.isLoggedIn, isLoggedIn: authStatus.isLoggedIn,
isAdmin: authStatus.isAdmin, isAdmin: authStatus.isAdmin,
forcePasswordChange: user?.force_password_change === true,
userId: user?.user_id, userId: user?.user_id,
userName: user?.user_name, userName: user?.user_name,
companyCode: user?.company_code, companyCode: user?.company_code,
+67 -2
View File
@@ -152,7 +152,24 @@ const startAutoRefresh = (): void => {
tokenRefreshTimer = setInterval( tokenRefreshTimer = setInterval(
async () => { async () => {
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) { if (!token) {
// idle 페이지에서 토큰이 사라진 걸 interval 이 감지.
// API 호출이 없으면 401 인터셉터가 발동하지 않으므로 여기서 직접 리다이렉트.
// redirectToLogin 내부에서 /login 경로는 스킵.
authLog("AUTO_REFRESH_CHECK", "interval 중 토큰 없음 감지 → 로그인 리다이렉트");
redirectToLogin();
return;
}
if (TokenManager.isTokenExpired(token)) {
// 이미 만료 — 한 번 갱신 시도하고 실패하면 로그인으로
const newToken = await refreshToken();
if (!newToken) {
authLog("REDIRECT_TO_LOGIN", "interval 중 만료 토큰 갱신 실패 → 로그인");
redirectToLogin();
}
return;
}
if (TokenManager.isTokenExpiringSoon(token)) {
await refreshToken(); await refreshToken();
} }
}, },
@@ -174,7 +191,12 @@ const setupVisibilityRefresh = (): void => {
if (!document.hidden) { if (!document.hidden) {
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (!token) { if (!token) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음"); // 탭 복귀 시 토큰이 아예 없다 = 다른 탭 로그아웃 / 저장소 정리 / WebView 리셋 등.
// API 호출이 일어나지 않는 idle 페이지라면 401 인터셉터가 안 발동하므로
// 여기서 능동적으로 로그인 페이지로 보낸다. (redirectToLogin 내부에서
// /login 경로면 no-op 이라 안전.)
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음 → 로그인 리다이렉트");
redirectToLogin();
return; return;
} }
@@ -194,6 +216,21 @@ const setupVisibilityRefresh = (): void => {
}); });
}; };
// 다른 탭에서 authToken 을 지우면 storage 이벤트가 발화한다 (같은 탭에서는 발화 X).
// 멀티탭 환경에서 한 탭의 로그아웃이 즉시 다른 탭으로 전파되도록 한다.
const setupStorageListener = (): void => {
if (typeof window === "undefined") return;
window.addEventListener("storage", (e) => {
if (e.key !== "authToken") return;
// newValue 가 null/빈 문자열이면 제거된 것. 새 값이 들어온 경우(다른 탭 로그인/갱신)는 그대로 따라감.
if (!e.newValue) {
authLog("STORAGE_SYNC", "다른 탭에서 authToken 제거 감지 → 로그인 리다이렉트");
redirectToLogin();
}
});
};
// 사용자 활동 감지 기반 갱신 // 사용자 활동 감지 기반 갱신
const setupActivityBasedRefresh = (): void => { const setupActivityBasedRefresh = (): void => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -247,6 +284,7 @@ if (typeof window !== "undefined") {
startAutoRefresh(); startAutoRefresh();
setupVisibilityRefresh(); setupVisibilityRefresh();
setupActivityBasedRefresh(); setupActivityBasedRefresh();
setupStorageListener();
} }
// ============================================ // ============================================
@@ -350,6 +388,33 @@ apiClient.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
// 403 — AuthGuard 외 경로에서도 반드시 막히도록 전역 리다이렉트 (backend filter 와 쌍)
if (status === 403 && typeof window !== "undefined") {
const errorData = error.response?.data as { errorCode?: string };
const ec = errorData?.errorCode;
if (ec === "PASSWORD_CHANGE_REQUIRED" && window.location.pathname !== "/change-password") {
authLog("REDIRECT_TO_CHANGE_PW", `403 PASSWORD_CHANGE_REQUIRED (${url})`);
window.location.href = "/change-password";
return Promise.reject(error);
}
if (ec === "CROSS_TENANT_REJECTED") {
authLog("REDIRECT_TO_LOGIN", `403 CROSS_TENANT_REJECTED (${url})`);
TokenManager.removeToken();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
return Promise.reject(error);
}
if (ec === "TENANT_NOT_RESOLVED") {
authLog("REDIRECT_TO_LOGIN", `403 TENANT_NOT_RESOLVED (${url})`);
TokenManager.removeToken();
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
return Promise.reject(error);
}
}
// 401 에러 처리 (핵심 개선) // 401 에러 처리 (핵심 개선)
if (status === 401 && typeof window !== "undefined") { if (status === 401 && typeof window !== "undefined") {
const errorData = error.response?.data as { error?: { code?: string; details?: string } }; const errorData = error.response?.data as { error?: { code?: string; details?: string } };
+79
View File
@@ -49,6 +49,7 @@ export interface CreateCompanyRequest {
address?: string; address?: string;
selected_groups?: string[]; selected_groups?: string[];
initial_password?: string; initial_password?: string;
force_password_change?: boolean;
} }
export interface CreateCompanyResponse { export interface CreateCompanyResponse {
@@ -70,3 +71,81 @@ export async function getProvisioningStatus(jobId: string): Promise<Record<strin
const { data } = await apiClient.get(`/admin/provisioning/status/${jobId}`); const { data } = await apiClient.get(`/admin/provisioning/status/${jobId}`);
return data || {}; return data || {};
} }
// ───────────────── 회사 관리 (lifecycle / admin / members / templates / audit) ─────────────────
export async function getCompanyAdmin(companyCode: string): Promise<Record<string, any>> {
const { data } = await apiClient.get(`/admin/provisioning/companies/${companyCode}/admin`);
return data || {};
}
export interface ResetAdminPasswordResponse {
admin_user_id?: string;
new_password?: string;
force_password_change?: boolean;
error?: string;
}
export async function resetAdminPassword(companyCode: string): Promise<ResetAdminPasswordResponse> {
const { data } = await apiClient.post(`/admin/provisioning/companies/${companyCode}/admin/reset-password`);
return data || {};
}
export async function getCompanyMembers(companyCode: string): Promise<Record<string, any>> {
const { data } = await apiClient.get(`/admin/provisioning/companies/${companyCode}/members`);
return data || {};
}
export async function getInstalledGroups(companyCode: string): Promise<Record<string, any>[]> {
const { data } = await apiClient.get(`/admin/provisioning/companies/${companyCode}/installed-groups`);
return Array.isArray(data) ? data : [];
}
export async function recopyTemplates(companyCode: string, selectedGroups: string[]): Promise<Record<string, any>> {
const { data } = await apiClient.post(`/admin/provisioning/companies/${companyCode}/re-copy`, {
selected_groups: selectedGroups,
});
return data || {};
}
export async function patchCompanyStatus(
companyCode: string,
status: "active" | "suspended",
reason?: string,
): Promise<Record<string, any>> {
const { data } = await apiClient.patch(`/admin/provisioning/companies/${companyCode}/status`, {
status,
reason,
});
return data || {};
}
/** 영구 삭제 — 서브도메인 타이핑 확인 필수 */
export async function deleteCompany(companyCode: string, confirmSubdomain: string): Promise<Record<string, any>> {
const { data } = await apiClient.delete(`/admin/provisioning/companies/${companyCode}`, {
data: { confirm_subdomain: confirmSubdomain },
});
return data || {};
}
export async function getCompanyAuditLog(
companyCode: string,
page = 1,
limit = 50,
): Promise<Record<string, any>> {
const { data } = await apiClient.get(`/admin/provisioning/companies/${companyCode}/audit-log`, {
params: { page, limit },
});
return data || {};
}
export async function getGlobalAuditLog(
page = 1,
limit = 50,
action?: string,
): Promise<Record<string, any>> {
const { data } = await apiClient.get(`/admin/provisioning/audit-log`, {
params: { page, limit, action },
});
return data || {};
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 간이 CSV 변환 유틸 — client-side only.
* BOM 포함하여 엑셀에서 한글 깨짐 방지.
*/
export function toCsvString(
rows: Record<string, any>[],
columns: { key: string; label: string; format?: (v: any, row: Record<string, any>) => string }[],
): string {
const header = columns.map((c) => escapeCsv(c.label)).join(",");
const body = rows
.map((row) =>
columns
.map((c) => {
const raw = c.format ? c.format(row[c.key], row) : row[c.key];
return escapeCsv(raw);
})
.join(","),
)
.join("\n");
return "" + header + "\n" + body;
}
function escapeCsv(v: any): string {
if (v === null || v === undefined) return "";
const s = String(v);
if (/[",\n\r]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
export function downloadCsv(filename: string, content: string) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+4 -13
View File
@@ -483,13 +483,13 @@
} }
.v5-tn-item svg{opacity:.55;transition:opacity .2s var(--v5-ease-move),transform .2s var(--v5-ease-move);} .v5-tn-item svg{opacity:.55;transition:opacity .2s var(--v5-ease-move),transform .2s var(--v5-ease-move);}
.v5-tn-section:hover .v5-tn-item, .v5-tn-section:hover .v5-tn-item,
.v5-tn-section.open .v5-tn-item{color:var(--v5-text);background:var(--v5-surface-hover);} .v5-tn-section.open .v5-tn-item{color:var(--v5-primary);}
.v5-tn-section:hover .v5-tn-item svg, .v5-tn-section:hover .v5-tn-item svg,
.v5-tn-section.open .v5-tn-item svg{opacity:1;} .v5-tn-section.open .v5-tn-item svg{opacity:1;}
/* open 상태에선 chevron 을 살짝 내려서(2px) "펼쳤다" 감만 주기 — 180° 회전은 flyout 방향과 역방향이라 혼동 유발 */ /* open 상태에선 chevron 을 살짝 내려서(2px) "펼쳤다" 감만 주기 — 180° 회전은 flyout 방향과 역방향이라 혼동 유발 */
.v5-tn-section.open .v5-tn-item svg{transform:translateY(1px);} .v5-tn-section.open .v5-tn-item svg{transform:translateY(1px);}
.v5-tn-section.on .v5-tn-item{color:var(--v5-primary);} .v5-tn-section.on .v5-tn-item{color:var(--v5-primary);}
.v5-admin-mode .v5-tn-section.on .v5-tn-item{color:var(--v5-cyan);} .v5-admin-mode .v5-tn-section.on .v5-tn-item{color:var(--v5-primary);}
/* Flyout (1단) — 섹션과 border 가 맞닿게(top:100%) + 내부 top 패딩으로 시각 여백만 유지. /* Flyout (1단) — 섹션과 border 가 맞닿게(top:100%) + 내부 top 패딩으로 시각 여백만 유지.
→ 2px 공백 구간에서 mouseleave 가 타서 flyout 이 순간 사라지던 문제 제거. */ → 2px 공백 구간에서 mouseleave 가 타서 flyout 이 순간 사라지던 문제 제거. */
@@ -499,7 +499,6 @@
border:1px solid var(--v5-border); border:1px solid var(--v5-border);
border-radius:var(--v5-radius-md); border-radius:var(--v5-radius-md);
padding:.6rem .3rem .3rem .3rem;z-index:40; padding:.6rem .3rem .3rem .3rem;z-index:40;
box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.12);
animation:v5-tn-flyout-in .2s var(--v5-ease-enter) backwards; animation:v5-tn-flyout-in .2s var(--v5-ease-enter) backwards;
} }
/* 섹션의 마지막 1px 과 flyout 의 첫 1px 를 겹쳐 "끊김" 제로. 시각적으론 boundary 가 안 보임(밝은 border 2중). */ /* 섹션의 마지막 1px 과 flyout 의 첫 1px 를 겹쳐 "끊김" 제로. 시각적으론 boundary 가 안 보임(밝은 border 2중). */
@@ -507,7 +506,6 @@
content:'';position:absolute;left:0;right:0;top:-6px;height:6px; content:'';position:absolute;left:0;right:0;top:-6px;height:6px;
background:transparent;pointer-events:auto; background:transparent;pointer-events:auto;
} }
.dark .v5-tn-flyout{box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.5);}
@keyframes v5-tn-flyout-in{ @keyframes v5-tn-flyout-in{
from{opacity:0;transform:translateY(-6px);} from{opacity:0;transform:translateY(-6px);}
to {opacity:1;transform:translateY(0);} to {opacity:1;transform:translateY(0);}
@@ -532,7 +530,7 @@
} }
.v5-tn-row:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);} .v5-tn-row:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
.v5-tn-row.on{background:rgba(var(--v5-primary-rgb),.1);color:var(--v5-primary);font-weight:600;} .v5-tn-row.on{background:rgba(var(--v5-primary-rgb),.1);color:var(--v5-primary);font-weight:600;}
.v5-admin-mode .v5-tn-row.on{background:rgba(var(--v5-cyan-rgb),.1);color:var(--v5-cyan);} .v5-admin-mode .v5-tn-row.on{background:rgba(var(--v5-primary-rgb),.1);color:var(--v5-primary);}
.v5-tn-row .v5-tn-row-label{flex:1;min-width:0;} .v5-tn-row .v5-tn-row-label{flex:1;min-width:0;}
.v5-tn-row .v5-tn-ic{display:inline-flex;width:14px;height:14px;color:currentColor;opacity:.7;} .v5-tn-row .v5-tn-ic{display:inline-flex;width:14px;height:14px;color:currentColor;opacity:.7;}
.v5-tn-row svg:last-of-type{opacity:.5;flex-shrink:0;} .v5-tn-row svg:last-of-type{opacity:.5;flex-shrink:0;}
@@ -555,10 +553,8 @@
border:1px solid var(--v5-border); border:1px solid var(--v5-border);
border-radius:var(--v5-radius-md-2); border-radius:var(--v5-radius-md-2);
padding:.3rem;z-index:45; padding:.3rem;z-index:45;
box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.12);
animation:v5-tn-flyout-in .2s var(--v5-ease-enter) backwards; animation:v5-tn-flyout-in .2s var(--v5-ease-enter) backwards;
} }
.dark .v5-tn-sub{box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.5);}
/* ================================================================= /* =================================================================
Tweaks floating panel (디자인시스템 Tweaks UX). Tweaks floating panel (디자인시스템 Tweaks UX).
@@ -570,21 +566,16 @@
background:var(--v5-surface-solid); background:var(--v5-surface-solid);
border:1px solid var(--v5-border); border:1px solid var(--v5-border);
border-radius:var(--v5-radius-lg-2); border-radius:var(--v5-radius-lg-2);
box-shadow:var(--v5-glow-md), 0 8px 24px rgba(0,0,0,.08);
padding:var(--v5-sp-4); padding:var(--v5-sp-4);
font-family:var(--v5-font-sans); font-family:var(--v5-font-sans);
opacity:0;transform:translateY(10px) scale(.97);pointer-events:none; opacity:0;transform:translateY(10px) scale(.97);pointer-events:none;
transition: transition:
opacity .25s var(--v5-ease-enter), opacity .25s var(--v5-ease-enter),
transform .3s var(--v5-ease-enter), transform .3s var(--v5-ease-enter);
box-shadow .3s var(--v5-ease-move);
} }
.v5-tweaks-panel.on{ .v5-tweaks-panel.on{
opacity:1;transform:translateY(0) scale(1);pointer-events:auto; opacity:1;transform:translateY(0) scale(1);pointer-events:auto;
} }
.dark .v5-tweaks-panel{
box-shadow:var(--v5-glow-md), 0 12px 32px rgba(0,0,0,.5);
}
.v5-tweaks-head{ .v5-tweaks-head{
display:flex;align-items:center;justify-content:space-between; display:flex;align-items:center;justify-content:space-between;
+63 -30
View File
@@ -256,10 +256,9 @@ html:not(.dark) .v5-hdr{
box-shadow:0 0 6px rgba(var(--v5-pink-rgb),.8); box-shadow:0 0 6px rgba(var(--v5-pink-rgb),.8);
animation:v5-pdot 2s infinite; animation:v5-pdot 2s infinite;
} }
/* Admin mode tint: when .v5-admin-mode, the mode-toggle glows cyan. */
.v5-admin-mode .v5-hdr-icon.v5-mode-toggle{ .v5-admin-mode .v5-hdr-icon.v5-mode-toggle{
color:var(--v5-cyan); color:var(--v5-primary);
background:rgba(var(--v5-cyan-rgb),.10); background:rgba(var(--v5-primary-rgb),.10);
} }
/* 대시보드 생성 버튼 (헤더, Light/Dark 토글 왼쪽) */ /* 대시보드 생성 버튼 (헤더, Light/Dark 토글 왼쪽) */
@@ -302,9 +301,9 @@ html:not(.dark) .v5-hdr{
.v5-admin-btn .ic-home{display:none;} .v5-admin-btn .ic-home{display:none;}
.v5-admin-mode .v5-admin-btn .ic-gear{display:none;} .v5-admin-mode .v5-admin-btn .ic-gear{display:none;}
.v5-admin-mode .v5-admin-btn .ic-home{display:block;} .v5-admin-mode .v5-admin-btn .ic-home{display:block;}
.v5-admin-mode .v5-admin-btn{color:var(--v5-cyan);background:rgba(var(--v5-cyan-rgb),.10);} .v5-admin-mode .v5-admin-btn{color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.10);}
.v5-admin-mode .v5-admin-btn:hover{color:var(--v5-cyan);background:rgba(var(--v5-cyan-rgb),.16);} .v5-admin-mode .v5-admin-btn:hover{color:var(--v5-primary);background:rgba(var(--v5-primary-rgb),.16);}
.v5-admin-mode .v5-admin-btn .v5-admin-label{color:var(--v5-cyan);} .v5-admin-mode .v5-admin-btn .v5-admin-label{color:var(--v5-primary);}
/* Avatar */ /* Avatar */
.v5-avatar-w{position:relative;} .v5-avatar-w{position:relative;}
@@ -376,10 +375,10 @@ html:not(.dark) .v5-hdr{
/* Admin badge — display:none 대신 opacity/transform 으로 hidden 해서 zoom-in/out 애니메이션 가능 */ /* Admin badge — display:none 대신 opacity/transform 으로 hidden 해서 zoom-in/out 애니메이션 가능 */
.v5-admin-badge{display:flex;align-items:center;gap:.4rem;padding:.2rem .6rem;border-radius:999px; .v5-admin-badge{display:flex;align-items:center;gap:.4rem;padding:.2rem .6rem;border-radius:999px;
background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-cyan-rgb),.08)); background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.14),rgba(var(--v5-primary-rgb),.06));
border:1px solid rgba(var(--v5-primary-rgb),.2);font-size:.58rem;font-weight:700;color:var(--v5-primary); border:1px solid rgba(var(--v5-primary-rgb),.2);font-size:.58rem;font-weight:700;color:var(--v5-primary);
opacity:0;transform:scale(0) rotate(-30deg);pointer-events:none;} opacity:0;transform:scale(0) rotate(-30deg);pointer-events:none;}
.dark .v5-admin-badge{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-cyan-rgb),.08)); .dark .v5-admin-badge{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.14),rgba(var(--v5-primary-rgb),.06));
border-color:rgba(var(--v5-primary-rgb),.2);color:var(--v5-primary-light);} border-color:rgba(var(--v5-primary-rgb),.2);color:var(--v5-primary-light);}
.v5-admin-mode .v5-admin-badge{opacity:1;transform:scale(1) rotate(0);pointer-events:auto;} .v5-admin-mode .v5-admin-badge{opacity:1;transform:scale(1) rotate(0);pointer-events:auto;}
/* badge zoom — 모드 진입 시 bouncy in, 이탈 시 quick out */ /* badge zoom — 모드 진입 시 bouncy in, 이탈 시 quick out */
@@ -392,14 +391,16 @@ html:not(.dark) .v5-hdr{
@keyframes v5-badge-zoom-out{ @keyframes v5-badge-zoom-out{
0%{opacity:1;transform:scale(1) rotate(0)} 0%{opacity:1;transform:scale(1) rotate(0)}
100%{opacity:0;transform:scale(0) rotate(30deg)}} 100%{opacity:0;transform:scale(0) rotate(30deg)}}
.v5-admin-badge .badge-dot{width:6px;height:6px;border-radius:50%;background:var(--v5-cyan); .v5-admin-badge .badge-dot{width:6px;height:6px;border-radius:50%;background:var(--v5-primary);
box-shadow:0 0 8px var(--v5-cyan-glow);animation:v5-bdPulse 2s infinite;} box-shadow:0 0 8px var(--v5-primary-glow);animation:v5-bdPulse 2s infinite;}
@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-cyan-glow)}50%{box-shadow:0 0 12px var(--v5-cyan-glow)}} @keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-primary-glow)}50%{box-shadow:0 0 12px var(--v5-primary-glow)}}
/* ===== SOLID TABS ===== */ /* ===== SOLID TABS ===== */
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto; .v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
background:var(--v5-surface-solid); background:var(--v5-surface-solid);
border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;} border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;
scrollbar-width:none;-ms-overflow-style:none;}
.v5-tabs::-webkit-scrollbar{display:none;}
.v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500; .v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;} color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;}
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);} .v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
@@ -514,9 +515,8 @@ html:not(.dark) .v5-side{
/* Content area stretches smoothly with sidebar */ /* Content area stretches smoothly with sidebar */
.v5-body .v5-content{transition:all .5s cubic-bezier(.4,0,.2,1);} .v5-body .v5-content{transition:all .5s cubic-bezier(.4,0,.2,1);}
.v5-side.collapsed{width:56px;padding:.85rem .4rem;overflow:visible;z-index:30; .v5-side.collapsed{width:56px;padding:.85rem .4rem;overflow:visible;z-index:30;}
border-right-color:var(--v5-primary);box-shadow:var(--v5-glow-sm);} .v5-side.collapsed .v5-side-toggle{color:var(--v5-primary);}
.v5-side.collapsed .v5-side-toggle{box-shadow:var(--v5-glow-sm);border-color:var(--v5-primary);color:var(--v5-primary);}
/* Collapsed menu items — center icon */ /* Collapsed menu items — center icon */
.v5-side.collapsed .v5-si{justify-content:center;padding:.55rem;border-radius:10px;gap:0;position:relative; .v5-side.collapsed .v5-si{justify-content:center;padding:.55rem;border-radius:10px;gap:0;position:relative;
@@ -529,6 +529,18 @@ html:not(.dark) .v5-side{
.v5-side.collapsed .v5-si:nth-child(6){animation-delay:.28s;} .v5-side.collapsed .v5-si:nth-child(6){animation-delay:.28s;}
.v5-side.collapsed .v5-si:nth-child(7){animation-delay:.32s;} .v5-side.collapsed .v5-si:nth-child(7){animation-delay:.32s;}
.v5-side.collapsed .v5-si:nth-child(8){animation-delay:.36s;} .v5-side.collapsed .v5-si:nth-child(8){animation-delay:.36s;}
.v5-side.collapsed .v5-si:nth-child(9){animation-delay:.40s;}
.v5-side.collapsed .v5-si:nth-child(10){animation-delay:.44s;}
.v5-side.collapsed .v5-si:nth-child(11){animation-delay:.48s;}
.v5-side.collapsed .v5-si:nth-child(12){animation-delay:.52s;}
.v5-side.collapsed .v5-si:nth-child(13){animation-delay:.56s;}
.v5-side.collapsed .v5-si:nth-child(14){animation-delay:.60s;}
.v5-side.collapsed .v5-si:nth-child(15){animation-delay:.64s;}
.v5-side.collapsed .v5-si:nth-child(16){animation-delay:.68s;}
.v5-side.collapsed .v5-si:nth-child(17){animation-delay:.72s;}
.v5-side.collapsed .v5-si:nth-child(18){animation-delay:.76s;}
.v5-side.collapsed .v5-si:nth-child(19){animation-delay:.80s;}
.v5-side.collapsed .v5-si:nth-child(20){animation-delay:.84s;}
@keyframes v5-iconPop{from{opacity:0;transform:scale(.5)}to{opacity:1;transform:scale(1)}} @keyframes v5-iconPop{from{opacity:0;transform:scale(.5)}to{opacity:1;transform:scale(1)}}
/* Hide text when collapsed */ /* Hide text when collapsed */
@@ -564,6 +576,18 @@ html:not(.dark) .v5-side{
.v5-side:not(.collapsed) .v5-si:nth-child(6){animation-delay:.2s;} .v5-side:not(.collapsed) .v5-si:nth-child(6){animation-delay:.2s;}
.v5-side:not(.collapsed) .v5-si:nth-child(7){animation-delay:.23s;} .v5-side:not(.collapsed) .v5-si:nth-child(7){animation-delay:.23s;}
.v5-side:not(.collapsed) .v5-si:nth-child(8){animation-delay:.26s;} .v5-side:not(.collapsed) .v5-si:nth-child(8){animation-delay:.26s;}
.v5-side:not(.collapsed) .v5-si:nth-child(9){animation-delay:.29s;}
.v5-side:not(.collapsed) .v5-si:nth-child(10){animation-delay:.32s;}
.v5-side:not(.collapsed) .v5-si:nth-child(11){animation-delay:.35s;}
.v5-side:not(.collapsed) .v5-si:nth-child(12){animation-delay:.38s;}
.v5-side:not(.collapsed) .v5-si:nth-child(13){animation-delay:.41s;}
.v5-side:not(.collapsed) .v5-si:nth-child(14){animation-delay:.44s;}
.v5-side:not(.collapsed) .v5-si:nth-child(15){animation-delay:.47s;}
.v5-side:not(.collapsed) .v5-si:nth-child(16){animation-delay:.50s;}
.v5-side:not(.collapsed) .v5-si:nth-child(17){animation-delay:.53s;}
.v5-side:not(.collapsed) .v5-si:nth-child(18){animation-delay:.56s;}
.v5-side:not(.collapsed) .v5-si:nth-child(19){animation-delay:.59s;}
.v5-side:not(.collapsed) .v5-si:nth-child(20){animation-delay:.62s;}
@keyframes v5-menuSlideIn{from{opacity:0;transform:translateX(-12px)}to{opacity:1;transform:none}} @keyframes v5-menuSlideIn{from{opacity:0;transform:translateX(-12px)}to{opacity:1;transform:none}}
.v5-side:not(.collapsed) .v5-side-sec{opacity:1; .v5-side:not(.collapsed) .v5-side-sec{opacity:1;
transition:opacity .35s .1s,height .35s .05s,padding .35s .05s;} transition:opacity .35s .1s,height .35s .05s,padding .35s .05s;}
@@ -650,6 +674,19 @@ html:not(.dark) .v5-side{
.v5-side-flyout .fly-item:nth-child(5){animation-delay:.12s;} .v5-side-flyout .fly-item:nth-child(5){animation-delay:.12s;}
.v5-side-flyout .fly-item:nth-child(6){animation-delay:.15s;} .v5-side-flyout .fly-item:nth-child(6){animation-delay:.15s;}
.v5-side-flyout .fly-item:nth-child(7){animation-delay:.18s;} .v5-side-flyout .fly-item:nth-child(7){animation-delay:.18s;}
.v5-side-flyout .fly-item:nth-child(8){animation-delay:.21s;}
.v5-side-flyout .fly-item:nth-child(9){animation-delay:.24s;}
.v5-side-flyout .fly-item:nth-child(10){animation-delay:.27s;}
.v5-side-flyout .fly-item:nth-child(11){animation-delay:.30s;}
.v5-side-flyout .fly-item:nth-child(12){animation-delay:.33s;}
.v5-side-flyout .fly-item:nth-child(13){animation-delay:.36s;}
.v5-side-flyout .fly-item:nth-child(14){animation-delay:.39s;}
.v5-side-flyout .fly-item:nth-child(15){animation-delay:.42s;}
.v5-side-flyout .fly-item:nth-child(16){animation-delay:.45s;}
.v5-side-flyout .fly-item:nth-child(17){animation-delay:.48s;}
.v5-side-flyout .fly-item:nth-child(18){animation-delay:.51s;}
.v5-side-flyout .fly-item:nth-child(19){animation-delay:.54s;}
.v5-side-flyout .fly-item:nth-child(20){animation-delay:.57s;}
@keyframes v5-flyItemIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}} @keyframes v5-flyItemIn{from{opacity:0;transform:translateX(-8px)}to{opacity:1;transform:none}}
.v5-side-flyout .fly-title{font-size:.58rem;font-weight:700;color:var(--v5-text-muted); .v5-side-flyout .fly-title{font-size:.58rem;font-weight:700;color:var(--v5-text-muted);
text-transform:uppercase;letter-spacing:.08em;padding:.3rem .6rem .45rem;} text-transform:uppercase;letter-spacing:.08em;padding:.3rem .6rem .45rem;}
@@ -663,12 +700,12 @@ html:not(.dark) .v5-side{
.v5-side-flyout .fly-item.on .ic{opacity:1;} .v5-side-flyout .fly-item.on .ic{opacity:1;}
/* Admin sidebar accent */ /* Admin sidebar accent */
.v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-cyan-rgb),.12),rgba(var(--v5-cyan-rgb),.05)); .v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-primary-rgb),.05));
color:var(--v5-cyan);border-color:rgba(var(--v5-cyan-rgb),.2);} color:var(--v5-primary);border-color:rgba(var(--v5-primary-rgb),.2);}
.v5-admin-side .v5-si.on .ic{opacity:1;} .v5-admin-side .v5-si.on .ic{opacity:1;}
.v5-admin-side .v5-si::before{background:var(--v5-cyan);} .v5-admin-side .v5-si::before{background:var(--v5-primary);}
.dark .v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-cyan-rgb),.12),rgba(var(--v5-cyan-rgb),.05)); .dark .v5-admin-side .v5-si.on{background:linear-gradient(135deg,rgba(var(--v5-primary-rgb),.12),rgba(var(--v5-primary-rgb),.05));
border-color:rgba(var(--v5-cyan-rgb),.15);} border-color:rgba(var(--v5-primary-rgb),.15);}
/* ===== MODE TRANSITION ===== */ /* ===== MODE TRANSITION ===== */
.v5-mode-fade{position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:0; .v5-mode-fade{position:fixed;inset:0;z-index:9998;pointer-events:none;opacity:0;
@@ -699,7 +736,7 @@ html:not(.dark) .v5-side{
.v5-hdr-glow{position:absolute;bottom:-1px;left:0;right:0;height:1px; .v5-hdr-glow{position:absolute;bottom:-1px;left:0;right:0;height:1px;
background:linear-gradient(90deg,transparent,var(--v5-primary),transparent); background:linear-gradient(90deg,transparent,var(--v5-primary),transparent);
opacity:0;pointer-events:none;} opacity:0;pointer-events:none;}
.v5-admin-mode .v5-hdr-glow{background:linear-gradient(90deg,transparent,var(--v5-cyan),transparent);} .v5-admin-mode .v5-hdr-glow{background:linear-gradient(90deg,transparent,var(--v5-primary),transparent);}
.v5-hdr-glow.mode-flash{animation:v5-mode-hdr-flash 1.4s cubic-bezier(.16,1,.3,1) forwards;} .v5-hdr-glow.mode-flash{animation:v5-mode-hdr-flash 1.4s cubic-bezier(.16,1,.3,1) forwards;}
@keyframes v5-mode-hdr-flash{ @keyframes v5-mode-hdr-flash{
0%{opacity:0;height:1px;filter:blur(0)} 0%{opacity:0;height:1px;filter:blur(0)}
@@ -717,15 +754,13 @@ html:not(.dark) .v5-side{
from{opacity:0;transform:translateY(8px) scale(.9);filter:blur(4px)} from{opacity:0;transform:translateY(8px) scale(.9);filter:blur(4px)}
to{opacity:1;transform:translateY(0) scale(1);filter:blur(0)}} to{opacity:1;transform:translateY(0) scale(1);filter:blur(0)}}
/* ===== MODE TRANSITION — toggle button burst (디자인시스템 mode-burst 포팅) ===== /* ===== MODE TRANSITION — toggle button burst ===== */
JS 가 .v5-mode-burst 컨테이너를 fixed 위치(클릭점)에 append.
기본 = primary(보라, → 사용자 모드), .admin = cyan(시안, → 관리자 모드). */
.v5-mode-burst{ .v5-mode-burst{
position:fixed;width:0;height:0; position:fixed;width:0;height:0;
pointer-events:none;z-index:9998; pointer-events:none;z-index:9998;
--burst-rgb:var(--v5-primary-rgb); --burst-rgb:var(--v5-primary-rgb);
} }
.v5-mode-burst.admin{--burst-rgb:var(--v5-cyan-rgb);} .v5-mode-burst.admin{--burst-rgb:var(--v5-primary-rgb);}
/* Center expanding ring */ /* Center expanding ring */
.v5-mode-burst .burst-ring{ .v5-mode-burst .burst-ring{
@@ -768,11 +803,9 @@ html:not(.dark) .v5-side{
content:'';position:absolute;inset:0; content:'';position:absolute;inset:0;
background:linear-gradient(90deg, background:linear-gradient(90deg,
transparent 0%, transparent 0%,
rgba(var(--v5-cyan-rgb),0) 15%, rgba(var(--v5-primary-rgb),0) 15%,
rgba(var(--v5-cyan-rgb),.9) 40%, rgba(var(--v5-primary-rgb),.95) 50%,
rgba(var(--v5-primary-rgb),1) 50%, rgba(var(--v5-primary-rgb),0) 85%,
rgba(var(--v5-pink-rgb),.9) 60%,
rgba(var(--v5-cyan-rgb),0) 85%,
transparent 100%); transparent 100%);
filter:blur(.5px); filter:blur(.5px);
transform:translateX(-100%); transform:translateX(-100%);
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
# scripts/start — invyone 도커 기동 스크립트
플랫폼별 1-클릭 실행 스크립트. `docker/dev/docker-compose.invyone.yml` 을 기준으로 프론트엔드 + 백엔드-스프링 컨테이너를 자동 기동합니다.
## 파일 구성
| 파일 | 플랫폼 | 실행 방법 |
|---|---|---|
| `invyone-start-docker-all.bat` | Windows | 더블클릭 또는 cmd 에서 실행 |
| `invyone-start-docker-all.sh` | Linux | `./invyone-start-docker-all.sh` |
| `invyone-start-docker-all.command` | macOS | Finder 더블클릭 (Terminal 자동 오픈) |
## 하는 일
1. `docker` CLI 설치 여부 + daemon 실행 여부 체크
2. `docker compose -f docker/dev/docker-compose.invyone.yml up -d` 실행
3. 컨테이너 상태 + 접속 URL 안내 출력
## 접속 URL (기동 후)
| 대상 | URL |
|---|---|
| Frontend | `http://localhost:9772` |
| Backend API | `http://localhost:8083/api` |
| 테넌트 (서브도메인) | `http://<subdomain>.localhost:9772` — 예: `http://test01.localhost:9772` |
서브도메인 멀티테넌시는 `*.localhost` RFC 6761 자동 매핑을 이용합니다 (Chrome / Firefox / Edge 기본 지원, hosts 편집 · DNS 설정 불필요).
## 리눅스/맥 첫 실행 전 — 실행 권한 1회
```bash
chmod +x scripts/start/invyone-start-docker-all.sh
chmod +x scripts/start/invyone-start-docker-all.command
```
Windows 에서 작성된 파일을 git 으로 받은 경우 executable bit 가 빠질 수 있습니다. 리포에 박아두려면:
```bash
git update-index --chmod=+x scripts/start/invyone-start-docker-all.sh
git update-index --chmod=+x scripts/start/invyone-start-docker-all.command
```
## 관련 명령 (참고용)
| 용도 | 명령 |
|---|---|
| 로그 실시간 | `docker compose -f docker/dev/docker-compose.invyone.yml logs -f` |
| 재시작 (볼륨 유지) | `docker compose -f docker/dev/docker-compose.invyone.yml restart` |
| 컨테이너 내리기 | `docker compose -f docker/dev/docker-compose.invyone.yml down` |
| 상태 확인 | `docker ps` |
## 트러블슈팅
### `docker daemon 이 실행 중이지 않습니다`
Docker Desktop 실행 후 재시도.
### 포트 충돌 (9772 / 8083)
같은 머신에서 다른 프로젝트가 해당 포트를 점유했는지 확인 (`docker ps` · 태스크 매니저 · `lsof -i :9772`). 필요하면 `docker-compose.invyone.yml` 의 호스트 포트 매핑을 바꿔서 회피.
### 테넌트 서브도메인 접속 시 META DB 로 떨어지는 경우
현재 백엔드 `SubdomainResolverFilter` 와 프론트 `subdomain.ts` / `client.ts`**`*.invyone.com` 만** 서브도메인으로 인식합니다. dev 환경에서 `*.localhost` 를 허용하는 패치는 별도 작업으로 예정 (`TENANT_ALLOWED_SUFFIXES` / `NEXT_PUBLIC_TENANT_HOST_SUFFIXES` 환경변수 기반).
### 프론트 `routes-manifest.json` ENOENT
`frontend/next.config.mjs``output: "standalone"` + `experimental.webpackMemoryOptimizations: true` 가 dev 모드에서 청크/매니페스트를 깨는 이슈. 이미 `isDev` 분기로 수정됨 (2026-04-07). 재발 시 해당 설정이 prod build 전용인지 확인.
## 전제 조건
- Docker Desktop (Windows/Mac) 또는 docker-ce + docker-compose-plugin (Linux)
- 프로젝트 루트에 `docker/dev/docker-compose.invyone.yml` 존재
- 본인 로컬 머신에 `.env` 등 필요 설정이 이미 놓여있어야 함 (syncthing 환경이면 자동 동기화)
@@ -0,0 +1,55 @@
@echo off
REM invyone 개발용 도커 컨테이너 기동 (Windows)
REM 사용법: 더블클릭 또는 cmd 에서 실행
chcp 65001 >nul
pushd "%~dp0..\.."
set COMPOSE_FILE=docker\dev\docker-compose.invyone.yml
where docker >nul 2>&1
if errorlevel 1 (
echo [invyone] docker 가 설치되어있지 않습니다. Docker Desktop 설치 후 다시 실행해주세요.
popd
pause
exit /b 1
)
docker info >nul 2>&1
if errorlevel 1 (
echo [invyone] docker daemon 이 실행 중이지 않습니다. Docker Desktop 을 실행해주세요.
popd
pause
exit /b 1
)
if not exist "%COMPOSE_FILE%" (
echo [invyone] compose 파일을 찾을 수 없음: %COMPOSE_FILE%
popd
pause
exit /b 1
)
echo [invyone] 도커 컨테이너 기동 중...
docker compose -f %COMPOSE_FILE% up -d
if errorlevel 1 (
echo [invyone] 기동 실패. 로그를 확인해주세요.
popd
pause
exit /b 1
)
echo.
echo [invyone] 컨테이너 상태:
docker compose -f %COMPOSE_FILE% ps
echo.
echo [invyone] 접속 URL:
echo Frontend: http://localhost:9772
echo Backend: http://localhost:8083/api
echo 테넌트: http://^<subdomain^>.localhost:9772 (예: http://test01.localhost:9772)
echo.
echo [invyone] 로그 보기: docker compose -f docker/dev/docker-compose.invyone.yml logs -f
echo [invyone] 컨테이너 내리기: docker compose -f docker/dev/docker-compose.invyone.yml down
popd
pause
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# invyone 개발용 도커 컨테이너 기동 (macOS)
# 사용법: Finder 에서 더블클릭 또는 터미널에서 ./invyone-start-docker-all.command
# 실행권한 필요 시: chmod +x invyone-start-docker-all.command
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMPOSE_FILE="$PROJECT_ROOT/docker/dev/docker-compose.invyone.yml"
cd "$PROJECT_ROOT"
if ! command -v docker >/dev/null 2>&1; then
echo "[invyone] docker 가 설치되어있지 않습니다. Docker Desktop 설치 후 다시 실행해주세요."
read -n 1 -s -r -p "아무 키나 눌러 종료"
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "[invyone] docker daemon 이 실행 중이지 않습니다. Docker Desktop 을 실행해주세요."
read -n 1 -s -r -p "아무 키나 눌러 종료"
exit 1
fi
if [ ! -f "$COMPOSE_FILE" ]; then
echo "[invyone] compose 파일을 찾을 수 없음: $COMPOSE_FILE"
read -n 1 -s -r -p "아무 키나 눌러 종료"
exit 1
fi
echo "[invyone] 도커 컨테이너 기동 중..."
docker compose -f "$COMPOSE_FILE" up -d
echo ""
echo "[invyone] 컨테이너 상태:"
docker compose -f "$COMPOSE_FILE" ps
echo ""
echo "[invyone] 접속 URL:"
echo " Frontend: http://localhost:9772"
echo " Backend: http://localhost:8083/api"
echo " 테넌트: http://<subdomain>.localhost:9772 (예: http://test01.localhost:9772)"
echo ""
echo "[invyone] 로그 보기: docker compose -f docker/dev/docker-compose.invyone.yml logs -f"
echo "[invyone] 컨테이너 내리기: docker compose -f docker/dev/docker-compose.invyone.yml down"
echo ""
read -n 1 -s -r -p "아무 키나 눌러 종료"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# invyone 개발용 도커 컨테이너 기동 (리눅스)
# 사용법: ./invyone-start-docker-all.sh
# 실행권한 필요 시: chmod +x invyone-start-docker-all.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
COMPOSE_FILE="$PROJECT_ROOT/docker/dev/docker-compose.invyone.yml"
if ! command -v docker >/dev/null 2>&1; then
echo "[invyone] docker 가 설치되어있지 않습니다. Docker 설치 후 다시 실행해주세요."
exit 1
fi
if ! docker info >/dev/null 2>&1; then
echo "[invyone] docker daemon 이 실행 중이지 않습니다. Docker Desktop 또는 dockerd 를 실행해주세요."
exit 1
fi
if [ ! -f "$COMPOSE_FILE" ]; then
echo "[invyone] compose 파일을 찾을 수 없음: $COMPOSE_FILE"
exit 1
fi
echo "[invyone] 도커 컨테이너 기동 중..."
docker compose -f "$COMPOSE_FILE" up -d
echo ""
echo "[invyone] 컨테이너 상태:"
docker compose -f "$COMPOSE_FILE" ps
echo ""
echo "[invyone] 접속 URL:"
echo " Frontend: http://localhost:9772"
echo " Backend: http://localhost:8083/api"
echo " 테넌트: http://<subdomain>.localhost:9772 (예: http://test01.localhost:9772)"
echo ""
echo "[invyone] 로그 보기: docker compose -f docker/dev/docker-compose.invyone.yml logs -f"
echo "[invyone] 컨테이너 내리기: docker compose -f docker/dev/docker-compose.invyone.yml down"