회사 관리 기능 확장 + 테넌트/비번 보안 하드닝
- 첫 로그인 비번 강제 변경 (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:
@@ -173,7 +173,6 @@ uploads/
|
|||||||
*.hwpx
|
*.hwpx
|
||||||
|
|
||||||
# ===== 기타 =====
|
# ===== 기타 =====
|
||||||
claude.md
|
|
||||||
|
|
||||||
# Agent Pipeline 로컬 파일
|
# Agent Pipeline 로컬 파일
|
||||||
_local/
|
_local/
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
```
|
||||||
@@ -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;
|
||||||
|
```
|
||||||
@@ -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">© 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
|||||||
@@ -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 || {};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 "아무 키나 눌러 종료"
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user