security(멀티테넌시): 관리 plane vs 테넌트 plane 격리 + 부서관리 후속

이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를
4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다.

# 보안 (plane 격리)

PR #A — controller/CompanyManagementController 인증 누락 패치
  /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제
  + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용.

PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그
  CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러
  (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두
  테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종
  (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가.
  SuperAdminGuard.isTenantHost 가시성 public static 으로 승격.

PR #B — 프론트 솔루션 전용 admin 페이지 가드
  admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별:
  subdomainList / companyList / audit-log. 각 페이지에 isManagementHost
  useEffect 가드 + redirect 추가. 사이드바도 같이 숨김.

PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터
  V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹.
  admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus
  가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS
  하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지.
  StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용.

# 부서관리 후속 (이전 PR #18/#19 follow-up)

DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분.
이번 격리 작업과 무관하지만 같이 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 10:59:15 +09:00
parent 4f13d2e440
commit 824a3100ce
22 changed files with 1316 additions and 136 deletions
@@ -1,7 +1,9 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -49,11 +51,15 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
@RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko");
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
String host = request.getHeader("Host");
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
}
@@ -1,7 +1,9 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.CompanyManagementService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -16,6 +18,7 @@ import java.util.Map;
@Slf4j
public class CompanyManagementController {
private final SuperAdminGuard guard;
private final CompanyManagementService companyManagementService;
/**
@@ -24,9 +27,12 @@ public class CompanyManagementController {
*/
@DeleteMapping("/{companyCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
HttpServletRequest request,
@PathVariable String companyCode,
@RequestBody(required = false) Map<String, Object> body) {
guard.enforce(request);
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
if (body != null) {
@@ -52,7 +58,11 @@ public class CompanyManagementController {
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
*/
@GetMapping("/disk-usage/all")
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
HttpServletRequest request) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
return ResponseEntity.ok(ApiResponse.success(data));
@@ -68,7 +78,11 @@ public class CompanyManagementController {
*/
@GetMapping("/{companyCode}/disk-usage")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
HttpServletRequest request,
@PathVariable String companyCode) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
return ResponseEntity.ok(ApiResponse.success(data));
@@ -142,6 +142,135 @@ public class DepartmentController {
}
}
/**
* 일괄 미리보기 (read-only validation).
* POST /api/departments/companies/{companyCode}/departments/bulk/preview
* body: { action: "create"|"update_department"|"update_manager", rows: List<Map> }
* response: { rows: [...with row_index/result/error_detail], ok_count, error_count }
*/
@PostMapping("/companies/{companyCode}/departments/bulk/preview")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkPreview(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다."));
}
String action = body.get("action") != null ? body.get("action").toString() : "";
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null) {
return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다."));
}
try {
List<Map<String, Object>> result;
switch (action) {
case "create":
result = departmentService.bulkPreviewCreate(companyCode, rows);
break;
case "update_department":
result = departmentService.bulkPreviewUpdate(companyCode, "department", rows);
break;
case "update_manager":
result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows);
break;
default:
return ResponseEntity.status(400)
.body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나."));
}
int ok = 0, err = 0;
for (Map<String, Object> r : result) {
if ("ok".equals(r.get("result"))) ok++; else err++;
}
Map<String, Object> data = new java.util.HashMap<>();
data.put("rows", result);
data.put("ok_count", ok);
data.put("error_count", err);
return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 일괄등록 적용 (@Transactional, all-or-nothing).
* POST /api/departments/companies/{companyCode}/departments/bulk/create
* body: { rows: List<Map> } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함.
*/
@PostMapping("/companies/{companyCode}/departments/bulk/create")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkCreate(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다."));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null || rows.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다."));
}
try {
int inserted = departmentService.bulkSaveCreate(companyCode, rows);
Map<String, Object> data = new java.util.HashMap<>();
data.put("inserted", inserted);
return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 일괄업데이트 적용 (@Transactional). mode = department | manager.
* POST /api/departments/companies/{companyCode}/departments/bulk/update
* body: { mode: "department"|"manager", rows: List<Map> } — 각 row 에 dept_code 필수.
*/
@PostMapping("/companies/{companyCode}/departments/bulk/update")
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkUpdate(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestAttribute("role") String role,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다."));
}
String mode = body.get("mode") != null ? body.get("mode").toString() : "";
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = body.get("rows") instanceof List
? (List<Map<String, Object>>) body.get("rows") : null;
if (rows == null || rows.isEmpty()) {
return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다."));
}
try {
int updated = departmentService.bulkUpdate(companyCode, mode, rows);
Map<String, Object> data = new java.util.HashMap<>();
data.put("updated", updated);
return ResponseEntity.ok(ApiResponse.success(data, updated + "건이 수정되었습니다."));
} catch (DepartmentService.DuplicateDeptNameException e) {
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 부서 삭제 (soft-delete, V1 slim scope).
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* Cross-tenant 어드민 엔드포인트 진입 가드.
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
public static boolean isMetaContext() {
return DbContextHolder.isMeta();
}
/**
* 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절.
* cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙.
*/
public static void requireManagementHost(HttpServletRequest request) {
String host = request.getHeader("Host");
if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Cross-tenant operations are only available on the management site");
}
}
}
@@ -59,6 +59,12 @@ public class CrossTenantController {
*/
@GetMapping("/_active-companies")
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -92,6 +98,12 @@ public class CrossTenantController {
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
HttpServletRequest request,
@RequestParam Map<String, Object> queryParams) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -173,6 +185,12 @@ public class CrossTenantController {
Map<String, Object> queryParams,
String mapperId,
boolean wrapSearchWithPercent) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
public ResponseEntity<Map<String, Object>> listDepartments(
HttpServletRequest request,
@RequestParam("company_code") String companyCode) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody("super_admin_required", request.getRequestURI()));
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.RoleService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
private final CrossTenantExecutor executor;
private final RoleService roleService;
private final CompanyAuditLogService auditLogService;
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
}
return roleService.createRoleGroup(params);
});
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
(String) body.get("auth_code"),
auditDetails(request, null));
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
}
return roleService.updateRoleGroup(params);
});
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
if (g != null) return g;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> p = new HashMap<>();
p.put("objid", id);
roleService.deleteRoleGroup(p);
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
}
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -290,6 +319,12 @@ public class CrossTenantRoleController {
}
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
return null;
}
/** audit log details 기본 맵 생성 헬퍼. */
private Map<String, Object> auditDetails(HttpServletRequest request, String roleId) {
Map<String, Object> d = new HashMap<>();
d.put("host", request.getHeader("Host"));
if (roleId != null) d.put("role_id", roleId);
return d;
}
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
private String asYn(Object raw) {
if (raw == null) return null;
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
private final CrossTenantExecutor executor;
private final AdminService adminService;
private final CompanyAuditLogService auditLogService;
// ── 등록 / 수정 ─────────────────────────────────────────────────────
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> adminService.saveUser(body));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_CREATE,
(String) body.get("user_id"),
auditDetails(request, (String) body.get("user_id")));
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> existing = adminService.getUserInfo(userId);
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
}
adminService.changeUserStatus(userId, "inactive");
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_DELETE,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -166,9 +178,14 @@ public class CrossTenantUserController {
String targetCompanyCode = (String) body.get("company_code");
String userId = (String) body.get("user_id");
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(targetCompanyCode, () ->
adminService.resetUserPassword(userId));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_PW_RESET,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
/** Void 응답용 가드 (제네릭만 다름). */
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
try {
CrossTenantContext.requireManagementHost(request);
} catch (org.springframework.web.server.ResponseStatusException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
}
if (!CrossTenantContext.isSuperAdmin(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
}
return null;
}
/** audit log details 기본 맵 생성 헬퍼. */
private Map<String, Object> auditDetails(HttpServletRequest request, String targetUserId) {
Map<String, Object> d = new HashMap<>();
d.put("host", request.getHeader("Host"));
if (targetUserId != null) d.put("target_user_id", targetUserId);
return d;
}
}
@@ -203,7 +203,13 @@ public class StartupSchemaMigrator {
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
)
""",
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)"
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)",
// V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가.
// 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그.
// 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL"
);
@EventListener(ApplicationReadyEvent.class)
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
// cross-tenant write 감사 액션
public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE";
public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE";
public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET";
public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE";
private final SqlSession sqlSession;
private final ObjectMapper objectMapper;
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
import java.util.Arrays;
@@ -40,13 +37,7 @@ public class ProvisioningController {
private final ProvisioningRegistry registry;
private final SqlSession sqlSession;
private final CompanyStatsService statsService;
/**
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
*/
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
private final SuperAdminGuard guard;
@GetMapping("/table-groups")
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
@@ -208,23 +199,11 @@ public class ProvisioningController {
}
// ------------------------------------------------------------------
// 권한 체크
//
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
// CompanyMgmtController 와 동일한 가드를 공유.
// ------------------------------------------------------------------
private void enforceSuperAdmin(HttpServletRequest request) {
String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
"tenant.provisioning.require-super-admin=true in production)");
return;
}
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
guard.enforce(request);
}
// --- Validation helpers ---
@@ -1,5 +1,6 @@
package com.erp.provisioning;
import com.erp.tenant.ReservedSubdomains;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.regex.Pattern;
/**
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
*
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
*
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
@Slf4j
public class SuperAdminGuard {
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
public void enforce(HttpServletRequest request) {
// 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함.
String host = request.getHeader("Host");
if (isTenantHost(host)) {
log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}",
host, request.getRequestURI(), request.getAttribute("user_type"));
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"Provisioning is only available on the management site");
}
// 2) role 체크
String userType = (String) request.getAttribute("user_type");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
String userId = (String) request.getAttribute("user_id");
return userId == null ? "dev-anonymous" : userId;
}
/** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */
public String requestHost(HttpServletRequest request) {
String host = request.getHeader("Host");
return host == null ? "" : host;
}
/**
* "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙.
* - localhost / IP / 베이스 도메인 → false (관리 호스트)
* - solution.invyone.com 등 예약어 prefix → false (관리 호스트)
* - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true
*/
public static boolean isTenantHost(String host) {
if (host == null || host.isBlank()) return false;
int colon = host.indexOf(':');
if (colon != -1) host = host.substring(0, colon);
host = host.toLowerCase();
if ("localhost".equals(host)) return false;
if (IPV4.matcher(host).matches()) return false;
String[] parts = host.split("\\.");
if (parts.length == 2) {
// {sub}.localhost (dev)
if (!"localhost".equals(parts[1])) return false;
String first = parts[0];
if (first.isEmpty()) return false;
return !ReservedSubdomains.VALUES.contains(first);
}
if (parts.length < 3) return false;
String first = parts[0];
return !ReservedSubdomains.VALUES.contains(first);
}
}
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -365,6 +366,330 @@ public class DepartmentService extends BaseService {
}
}
// ──────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트 (Bulk)
// ──────────────────────────────────────────────────
private static final int BULK_MAX_ROWS = 1000;
/**
* 일괄등록 — preview (read-only validation). DB 쓰기 없음.
* batch 내 dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증.
* 각 row 에 row_index / result(ok|error) / error_detail 채워서 반환.
*/
public List<Map<String, Object>> bulkPreviewCreate(String companyCode, List<Map<String, Object>> rows) {
List<Map<String, Object>> results = new ArrayList<>();
if (rows == null || rows.isEmpty()) return results;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
}
Set<String> existingNames = collectActiveDeptNames(companyCode);
Set<String> batchNames = new HashSet<>();
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> input = rows.get(i);
Map<String, Object> out = new HashMap<>(input);
out.put("row_index", i);
String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames);
if (error == null) {
out.put("result", "ok");
out.put("error_detail", null);
String dn = trimString(input.get("dept_name"));
if (dn != null) batchNames.add(dn.toLowerCase());
} else {
out.put("result", "error");
out.put("error_detail", error);
}
results.add(out);
}
return results;
}
/**
* 일괄업데이트 — preview (read-only). mode = department | manager.
*/
public List<Map<String, Object>> bulkPreviewUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
List<Map<String, Object>> results = new ArrayList<>();
if (rows == null || rows.isEmpty()) return results;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
}
if (!"department".equals(mode) && !"manager".equals(mode)) {
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
}
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> input = rows.get(i);
Map<String, Object> out = new HashMap<>(input);
out.put("row_index", i);
String error = validateBulkUpdateRow(input, companyCode, mode);
if (error == null) {
out.put("result", "ok");
out.put("error_detail", null);
} else {
out.put("result", "error");
out.put("error_detail", error);
}
results.add(out);
}
return results;
}
/**
* 일괄등록 — 실제 저장 (@Transactional, all-or-nothing).
* 각 row 를 createDepartment 로 위임 — 검증 + manager sync 까지 동일 흐름.
* 중간 실패 시 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 → 전체 롤백.
*/
@Transactional
public int bulkSaveCreate(String companyCode, List<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) return 0;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다.");
}
int inserted = 0;
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
String label = trimString(row.get("dept_name"));
try {
createDepartment(companyCode, row);
inserted++;
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
throw new IllegalArgumentException("" + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage());
}
}
log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted);
return inserted;
}
/**
* 일괄업데이트 — 실제 적용 (@Transactional). mode = department | manager.
* department: 부서 정보 부분 업데이트 (row 의 null/미지정 필드는 기존값 보존).
* manager: row 에 명시된 매니저 role 만 sync (delete-all + insert-all).
*/
@Transactional
public int bulkUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) return 0;
if (rows.size() > BULK_MAX_ROWS) {
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다.");
}
if (!"department".equals(mode) && !"manager".equals(mode)) {
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
}
int updated = 0;
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> row = rows.get(i);
String deptCode = trimString(row.get("dept_code"));
if (deptCode == null) {
throw new IllegalArgumentException("" + (i + 1) + ": 부서코드(dept_code) 필수.");
}
Map<String, Object> existing = getDepartment(deptCode);
if (existing == null) {
throw new IllegalArgumentException("" + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode);
}
String deptCompanyCode = existing.get("company_code") != null
? existing.get("company_code").toString() : null;
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
throw new IllegalArgumentException("" + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode);
}
try {
if ("department".equals(mode)) {
Map<String, Object> merged = buildMergedDeptBody(existing, row);
Map<String, Object> result = updateDepartment(deptCode, merged);
if (result == null) {
throw new IllegalStateException("수정 실패: " + deptCode);
}
} else {
// manager mode — row 에 명시된 role 만 sync
if (row.containsKey("approval_managers")) {
syncManagers(deptCode, companyCode, row, "approval");
}
if (row.containsKey("dept_managers")) {
syncManagers(deptCode, companyCode, row, "dept");
}
if (row.containsKey("org_leaders")) {
syncManagers(deptCode, companyCode, row, "org_leader");
}
}
updated++;
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
throw new IllegalArgumentException("" + (i + 1) + " (" + deptCode + "): " + e.getMessage());
}
}
log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated);
return updated;
}
/** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */
private Set<String> collectActiveDeptNames(String companyCode) {
Set<String> names = new HashSet<>();
for (Map<String, Object> d : getDepartments(companyCode, false, null)) {
Object name = d.get("dept_name");
if (name != null) names.add(name.toString().trim().toLowerCase());
}
return names;
}
/**
* 일괄등록 row 검증. null = ok. 에러 메시지 반환 시 해당 row 는 error.
*/
private String validateBulkCreateRow(Map<String, Object> row, String companyCode,
Set<String> existingNames, Set<String> batchNames) {
String deptName = trimString(row.get("dept_name"));
if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다.";
String lower = deptName.toLowerCase();
if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName;
if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName;
String dt = trimString(row.get("dept_type"));
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
return "부서유형은 dept|team|temp 중 하나: " + dt;
}
String parent = trimString(row.get("parent_dept_code"));
String parentErr = validateParentForBulk(parent, companyCode);
if (parentErr != null) return parentErr;
String dateErr = validateDateRange(row);
if (dateErr != null) return dateErr;
String mgrErr = validateManagerIds(row, companyCode);
if (mgrErr != null) return mgrErr;
return null;
}
/**
* 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증.
*/
private String validateBulkUpdateRow(Map<String, Object> row, String companyCode, String mode) {
String deptCode = trimString(row.get("dept_code"));
if (deptCode == null) return "부서코드(dept_code) 필수.";
Map<String, Object> existing = getDepartment(deptCode);
if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode;
String deptCompanyCode = existing.get("company_code") != null
? existing.get("company_code").toString() : null;
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
return "다른 회사의 부서: " + deptCode;
}
if ("department".equals(mode)) {
String newName = trimString(row.get("dept_name"));
if (newName != null && !newName.isEmpty()) {
String existingName = existing.get("dept_name") != null
? existing.get("dept_name").toString().trim() : "";
if (!newName.equalsIgnoreCase(existingName)) {
Map<String, Object> dupParams = new HashMap<>();
dupParams.put("company_code", companyCode);
dupParams.put("dept_name", newName);
Map<String, Object> dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
if (dup != null && !deptCode.equals(dup.get("dept_code"))) {
return "이미 존재하는 부서명: " + newName;
}
}
}
String dt = trimString(row.get("dept_type"));
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
return "부서유형은 dept|team|temp 중 하나: " + dt;
}
String dateErr = validateDateRange(row);
if (dateErr != null) return dateErr;
String parent = trimString(row.get("parent_dept_code"));
String parentErr = validateParentForBulk(parent, companyCode);
if (parentErr != null) return parentErr;
}
return validateManagerIds(row, companyCode);
}
private String validateParentForBulk(String parent, String companyCode) {
if (parent == null) return null;
Map<String, Object> p = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent));
if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent;
if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent;
Object pc = p.get("company_code");
if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) {
return "다른 회사의 부서를 상위로 지정 불가: " + parent;
}
return null;
}
private String validateDateRange(Map<String, Object> row) {
String sd = trimString(row.get("start_date"));
String ed = trimString(row.get("end_date"));
if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd;
if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed;
if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음.";
return null;
}
private String validateManagerIds(Map<String, Object> row, String companyCode) {
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
Object raw = row.get(key);
if (raw instanceof List<?> list && list.size() > 10) {
return key + " 는 최대 10명까지 등록 가능합니다.";
}
}
List<String> ids = collectManagerUserIds(row);
if (ids.isEmpty()) return null;
Map<String, Object> vParams = new HashMap<>();
vParams.put("user_ids", ids);
vParams.put("company_code", companyCode);
List<String> valid = sqlSession.selectList("department.selectValidUserIds", vParams);
if (valid == null || valid.size() != ids.size()) {
Set<String> invalid = new HashSet<>(ids);
if (valid != null) invalid.removeAll(valid);
return "유효하지 않은 사용자 ID: " + invalid;
}
return null;
}
private List<String> collectManagerUserIds(Map<String, Object> row) {
List<String> ids = new ArrayList<>();
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
Object raw = row.get(key);
if (raw instanceof List<?> list) {
for (Object item : list) {
String uid = null;
if (item instanceof Map<?, ?> m) {
Object v = m.get("user_id");
if (v != null) uid = v.toString().trim();
} else if (item != null) {
uid = item.toString().trim();
}
if (uid != null && !uid.isEmpty() && !ids.contains(uid)) ids.add(uid);
}
}
}
return ids;
}
/**
* 일괄업데이트 department mode — 기존값 + row override 머지.
* row 값이 null/미지정이면 기존값 보존 (PATCH semantic).
* 매니저 매핑 키는 항상 제거 (department mode 에서는 안 다룸).
*/
private Map<String, Object> buildMergedDeptBody(Map<String, Object> existing, Map<String, Object> row) {
Map<String, Object> merged = new HashMap<>();
String[] textKeys = {
"dept_name", "parent_dept_code", "short_name", "dept_type", "org_system",
"approval_manager", "dept_manager", "zipcode", "address1", "address2",
"sort_order", "status", "location"
};
for (String k : textKeys) merged.put(k, existing.get(k));
merged.put("start_date", stringifyDate(existing.get("start_date")));
merged.put("end_date", stringifyDate(existing.get("end_date")));
for (Map.Entry<String, Object> e : row.entrySet()) {
String k = e.getKey();
if ("dept_code".equals(k)) continue;
if (e.getValue() == null) continue;
if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue;
merged.put(k, e.getValue());
}
return merged;
}
private String stringifyDate(Object date) {
if (date == null) return null;
String s = date.toString();
return s.length() >= 10 ? s.substring(0, 10) : null;
}
// ──────────────────────────────────────────────────
// 부서원 관리
// ──────────────────────────────────────────────────
@@ -0,0 +1,12 @@
ALTER TABLE MENU_INFO
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.';
-- 솔루션 전용 메뉴 마킹
UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE
WHERE MENU_URL IN (
'/admin/sysMng/subdomainList',
'/admin/userMng/companyList',
'/admin/audit-log'
);
@@ -187,6 +187,9 @@
AND MENU.COMPANY_CODE = #{company_code}
</otherwise>
</choose>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL
@@ -212,6 +215,9 @@
ON S.PARENT_OBJ_ID = V.OBJID
WHERE S.OBJID != ALL(V.PATH)
AND S.STATUS = 'active'
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
)
SELECT
V.LEV
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -64,6 +65,7 @@ import {
import { getCompanyList } from "@/lib/api/company";
import { useAuth } from "@/hooks/useAuth";
import { Company } from "@/types/company";
import { isManagementHost } from "@/lib/tenant/subdomain";
const RESOURCE_TYPE_CONFIG: Record<
string,
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
}
export default function AuditLogPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const { user } = useAuth();
const isSuperAdmin = user?.company_code === "*";
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
setDetailOpen(true);
};
if (hostBlocked) return null;
return (
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
<div className="flex items-center justify-between">
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { getCompaniesStats } from "@/lib/api/provisioning";
@@ -9,6 +10,7 @@ import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordio
import Wizard from "@/components/admin/provisioning/wizard/Wizard";
import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
import { toCsvString, downloadCsv } from "@/lib/csvExport";
import { isManagementHost } from "@/lib/tenant/subdomain";
const PAGE_SIZE = 10;
@@ -18,8 +20,22 @@ const PAGE_SIZE = 10;
*
* 기존 /admin/userMng/companyList (회사 기본 CRUD) 와는 스코프가 다름.
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" 전용.
*
* 호스트 격리: 솔루션/관리 호스트(solution.invyone.com, localhost 등) 에서만 접근 가능.
* 테넌트 사이트(qnc.invyone.com 등) 에서 URL 직접 진입 시 /main 으로 리다이렉트.
* 백엔드 SuperAdminGuard 도 동일 정책으로 API 자체를 거절.
*/
export default function SubdomainListPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const [openKey, setOpenKey] = useState<string | null>(null);
const [q, setQ] = useState("");
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
@@ -51,6 +67,7 @@ export default function SubdomainListPage() {
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
queryKey: ["companies-stats"],
queryFn: getCompaniesStats,
enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect
refetchInterval: (query) => {
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
const hasProvisioning = Array.isArray(query.state.data)
@@ -95,6 +112,12 @@ export default function SubdomainListPage() {
const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
// 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면.
// 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단.
if (hostBlocked) {
return null;
}
return (
<div
style={{
@@ -1,5 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isManagementHost } from "@/lib/tenant/subdomain";
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
import { CompanyTable } from "@/components/admin/CompanyTable";
@@ -13,6 +16,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* 모든 회사 관리 기능을 통합하여 제공
*/
export default function CompanyPage() {
const router = useRouter();
const [hostBlocked, setHostBlocked] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
if (!isManagementHost(window.location.hostname)) {
setHostBlocked(true);
router.replace("/main");
}
}, [router]);
const {
// 데이터
companies,
@@ -51,6 +64,8 @@ export default function CompanyPage() {
clearError,
} = useCompanyManagement();
if (hostBlocked) return null;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import * as XLSX from "xlsx";
import {
ArrowDownToLine,
ArrowUpToLine,
Building2,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
@@ -12,6 +14,7 @@ import {
ChevronsUpDown,
Eye,
EyeOff,
FileDown,
Folder,
FolderOpen,
FolderTree,
@@ -28,6 +31,7 @@ import {
Upload,
Users,
X,
XCircle,
} from "lucide-react";
import {
DropdownMenu,
@@ -42,7 +46,9 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
@@ -152,11 +158,15 @@ export default function DeptMngListPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
// ── 일괄등록 / 일괄업데이트 모달 ─────────────────────
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState("");
const [bulkUploading, setBulkUploading] = useState(false);
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
const [bulkTab, setBulkTab] = useState<"create" | "update">("create");
const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department");
const [bulkRows, setBulkRows] = useState<Record<string, any>[]>([]);
const [bulkPreviewRows, setBulkPreviewRows] = useState<departmentAPI.BulkPreviewRow[]>([]);
const [bulkSelected, setBulkSelected] = useState<Set<number>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkFileName, setBulkFileName] = useState<string>("");
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
@@ -611,6 +621,251 @@ export default function DeptMngListPage() {
}
};
// ─────────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트 helpers
// ─────────────────────────────────────────────────────
const BULK_HEADERS_CREATE: Record<string, string> = {
"부서명": "dept_name",
"상위부서코드": "parent_dept_code",
"부서유형": "dept_type",
"약칭": "short_name",
"조직체계": "org_system",
"정렬순서": "sort_order",
"사용여부": "status",
"시작일": "start_date",
"종료일": "end_date",
"결재관리자": "approval_managers",
"부서관리자": "dept_managers",
"조직장": "org_leaders",
};
const BULK_HEADERS_UPDATE_DEPT: Record<string, string> = {
"부서코드": "dept_code",
"부서명": "dept_name",
"상위부서코드": "parent_dept_code",
"부서유형": "dept_type",
"약칭": "short_name",
"조직체계": "org_system",
"정렬순서": "sort_order",
"사용여부": "status",
"시작일": "start_date",
"종료일": "end_date",
};
const BULK_HEADERS_UPDATE_MGR: Record<string, string> = {
"부서코드": "dept_code",
"결재관리자": "approval_managers",
"부서관리자": "dept_managers",
"조직장": "org_leaders",
};
const MANAGER_KEYS = new Set(["approval_managers", "dept_managers", "org_leaders"]);
const currentHeaderMap = () =>
bulkTab === "create"
? BULK_HEADERS_CREATE
: bulkUpdateMode === "department"
? BULK_HEADERS_UPDATE_DEPT
: BULK_HEADERS_UPDATE_MGR;
const currentBulkAction = (): departmentAPI.BulkAction =>
bulkTab === "create"
? "create"
: bulkUpdateMode === "department"
? "update_department"
: "update_manager";
const resetBulkData = useCallback(() => {
setBulkRows([]);
setBulkPreviewRows([]);
setBulkSelected(new Set());
setBulkFileName("");
}, []);
const openBulkModal = () => {
if (!selectedCompanyCode) {
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
return;
}
setBulkTab("create");
setBulkUpdateMode("department");
resetBulkData();
setBulkOpen(true);
};
/** 엑셀 템플릿 다운로드 — action 별 컬럼 다름. 예시 row 1개 포함 */
const downloadBulkTemplate = () => {
const action = currentBulkAction();
const headerMap =
action === "create"
? BULK_HEADERS_CREATE
: action === "update_department"
? BULK_HEADERS_UPDATE_DEPT
: BULK_HEADERS_UPDATE_MGR;
const columns = Object.keys(headerMap);
const example: Record<string, any> = {};
columns.forEach((c) => {
const snake = headerMap[c];
if (snake === "dept_name") example[c] = "경영지원본부";
else if (snake === "dept_code") example[c] = "DEPT_1";
else if (snake === "dept_type") example[c] = "dept";
else if (snake === "status") example[c] = "active";
else if (snake === "sort_order") example[c] = 10;
else if (MANAGER_KEYS.has(snake)) example[c] = action === "update_manager" ? "user001,user002" : "";
else example[c] = "";
});
const ws = XLSX.utils.json_to_sheet([example], { header: columns });
ws["!cols"] = columns.map(() => ({ wch: 16 }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "부서");
const fileName =
action === "create"
? "부서_일괄등록_템플릿.xlsx"
: action === "update_department"
? "부서정보_일괄업데이트_템플릿.xlsx"
: "부서관리자_일괄업데이트_템플릿.xlsx";
XLSX.writeFile(wb, fileName);
};
/** 업로드된 xlsx → 한글 헤더를 snake_case 로 매핑 + 매니저 필드는 CSV 분해 */
const handleBulkFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setBulkFileName(file.name);
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: "array" });
const ws = wb.Sheets[wb.SheetNames[0]];
const raw = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: "" });
const headerMap = currentHeaderMap();
const rows = raw
.map((row) => {
const out: Record<string, any> = {};
for (const [korean, snake] of Object.entries(headerMap)) {
const v = row[korean];
if (v === undefined || v === null || v === "") continue;
if (MANAGER_KEYS.has(snake)) {
const ids = String(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) out[snake] = ids;
} else if (snake === "sort_order") {
const n = Number(v);
if (!Number.isNaN(n)) out[snake] = n;
} else {
out[snake] = String(v).trim();
}
}
return out;
})
.filter((r) => Object.keys(r).length > 0);
setBulkRows(rows);
setBulkPreviewRows([]);
setBulkSelected(new Set());
toast({ title: `${rows.length}건 로드됨`, description: "[미리보기] 를 눌러 검증하세요." });
} catch (err: any) {
toast({ title: "파일 읽기 실패", description: err.message || String(err), variant: "destructive" });
} finally {
// 동일 파일 재선택 가능하도록
e.target.value = "";
}
};
const handleBulkPreview = async () => {
if (bulkRows.length === 0) return;
setBulkBusy(true);
try {
const res = await departmentAPI.bulkPreviewDepartments(selectedCompanyCode, currentBulkAction(), bulkRows);
if (res.success && (res as any).data) {
const rows: departmentAPI.BulkPreviewRow[] = (res as any).data.rows;
setBulkPreviewRows(rows);
// 기본: ok 인 row 만 선택
setBulkSelected(new Set(rows.filter((r) => r.result === "ok").map((r) => r.row_index)));
} else {
toast({
title: "미리보기 실패",
description: (res as any).error || (res as any).message || "오류",
variant: "destructive",
});
}
} finally {
setBulkBusy(false);
}
};
const handleBulkApply = async () => {
const okSelected = bulkPreviewRows.filter(
(r) => bulkSelected.has(r.row_index) && r.result === "ok",
);
if (okSelected.length === 0) {
toast({ title: "반영할 정상 행이 없습니다", variant: "destructive" });
return;
}
const payload = okSelected.map((r) => {
const { row_index, result, error_detail, ...rest } = r as any;
return rest as Record<string, any>;
});
setBulkBusy(true);
try {
const res =
bulkTab === "create"
? await departmentAPI.bulkCreateDepartments(selectedCompanyCode, payload)
: await departmentAPI.bulkUpdateDepartments(selectedCompanyCode, bulkUpdateMode, payload);
if (res.success) {
const count =
(res as any).data?.inserted ?? (res as any).data?.updated ?? payload.length;
toast({
title: bulkTab === "create" ? "일괄등록 완료" : "일괄업데이트 완료",
description: `${count}건 처리됨`,
});
setBulkOpen(false);
resetBulkData();
await loadDepartments();
} else {
toast({
title: bulkTab === "create" ? "일괄등록 실패" : "일괄업데이트 실패",
description: (res as any).error || (res as any).message || "오류",
variant: "destructive",
});
}
} finally {
setBulkBusy(false);
}
};
const previewColumns = useMemo(() => {
if (bulkTab === "create") {
return [
{ key: "dept_name", label: "부서명" },
{ key: "parent_dept_code", label: "상위부서코드" },
{ key: "dept_type", label: "유형" },
{ key: "sort_order", label: "순서" },
{ key: "approval_managers", label: "결재관리자", manager: true },
{ key: "dept_managers", label: "부서관리자", manager: true },
{ key: "org_leaders", label: "조직장", manager: true },
];
}
if (bulkUpdateMode === "department") {
return [
{ key: "dept_code", label: "부서코드" },
{ key: "dept_name", label: "부서명" },
{ key: "parent_dept_code", label: "상위부서코드" },
{ key: "dept_type", label: "유형" },
{ key: "sort_order", label: "순서" },
];
}
return [
{ key: "dept_code", label: "부서코드" },
{ key: "approval_managers", label: "결재관리자", manager: true },
{ key: "dept_managers", label: "부서관리자", manager: true },
{ key: "org_leaders", label: "조직장", manager: true },
];
}, [bulkTab, bulkUpdateMode]);
const bulkOkCount = bulkPreviewRows.filter((r) => r.result === "ok").length;
const bulkErrCount = bulkPreviewRows.length - bulkOkCount;
const allOkSelected =
bulkOkCount > 0 &&
bulkPreviewRows
.filter((r) => r.result === "ok")
.every((r) => bulkSelected.has(r.row_index));
const isDirty = originalDraft
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
@@ -636,14 +891,7 @@ export default function DeptMngListPage() {
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => {
if (!selectedCompanyCode) {
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
return;
}
setBulkText("");
setBulkOpen(true);
}}
onClick={openBulkModal}
>
<Upload className="h-3.5 w-3.5" />
@@ -1013,106 +1261,229 @@ export default function DeptMngListPage() {
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
/>
{/* 일괄등록 */}
{/* 일괄등록 / 일괄업데이트 */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="max-w-[640px]">
<DialogContent className="flex max-h-[88vh] max-w-[1040px] flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle> / </DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1.5 font-semibold">CSV </p>
<p className="text-muted-foreground">
: <code className="rounded bg-background px-1 py-0.5 font-mono">,,(dept|team|temp)</code>
</p>
<p className="mt-1 text-muted-foreground"> (DEPT_n).</p>
<p className="mt-1 text-muted-foreground">: <code className="rounded bg-background px-1 py-0.5 font-mono">,,dept</code></p>
</div>
<textarea
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
placeholder={"경영지원본부,,dept\n인사팀,DEPT_1,team"}
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBulkOpen(false)}></Button>
<Button
disabled={bulkUploading || !bulkText.trim()}
onClick={async () => {
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return;
setBulkUploading(true);
const failures: { line: number; deptName: string; reason: string }[] = [];
let success = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const cols = line.split(",").map((c) => c.trim());
const [dept_name, parent, dept_type] = cols;
if (!dept_name) {
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
continue;
}
try {
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
dept_name,
parent_dept_code: parent || null,
dept_type: (dept_type || "dept") as any,
} as any);
if (res.success) success++;
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
} catch (e: any) {
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
}
}
setBulkUploading(false);
toast({
title: `일괄등록 완료`,
description: `성공 ${success}건 / 실패 ${failures.length}`,
variant: failures.length > 0 ? "destructive" : "default",
});
if (failures.length > 0) {
setBulkFailures(failures);
} else {
setBulkOpen(false);
}
await loadDepartments();
}}
>
{bulkUploading ? "등록 중..." : "등록"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 일괄등록 실패 결과 */}
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
<DialogContent className="max-w-[640px]">
<DialogHeader>
<DialogTitle> ({bulkFailures.length})</DialogTitle>
</DialogHeader>
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
<table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-semibold w-16"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y">
{bulkFailures.map((f, idx) => (
<tr key={idx}>
<td className="px-3 py-1.5 font-mono">{f.line}</td>
<td className="px-3 py-1.5">{f.deptName}</td>
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}></Button>
<Tabs
value={bulkTab}
onValueChange={(v) => {
setBulkTab(v as "create" | "update");
resetBulkData();
}}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="mb-2">
<TabsTrigger value="create"></TabsTrigger>
<TabsTrigger value="update"></TabsTrigger>
</TabsList>
<TabsContent value="create" className="m-0 space-y-2">
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1 font-semibold"> </p>
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
<li>[ 릿] .</li>
<li> [] [].</li>
<li> (DEPT_n).</li>
<li> user_id (,) . 10/role.</li>
<li> 1000 .</li>
</ul>
</div>
</TabsContent>
<TabsContent value="update" className="m-0 space-y-2">
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1 font-semibold"> </p>
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
<li> <code className="rounded bg-background px-1 font-mono">(dept_code)</code> .</li>
<li><b> </b>: /// . .</li>
<li><b> </b>: // . role .</li>
</ul>
</div>
<div className="flex items-center gap-3 px-1">
<Label className="text-xs font-semibold"> </Label>
<RadioGroup
value={bulkUpdateMode}
onValueChange={(v) => {
setBulkUpdateMode(v as "department" | "manager");
resetBulkData();
}}
className="flex items-center gap-4"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="department" id="bulk-mode-dept" className="h-3.5 w-3.5" />
<Label htmlFor="bulk-mode-dept" className="cursor-pointer text-xs"> </Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="manager" id="bulk-mode-mgr" className="h-3.5 w-3.5" />
<Label htmlFor="bulk-mode-mgr" className="cursor-pointer text-xs"> </Label>
</div>
</RadioGroup>
</div>
</TabsContent>
{/* 회사 + 파일 선택 (탭 공통) */}
<div className="mt-2 space-y-2 rounded-md border p-3">
<div className="grid grid-cols-[100px_1fr_auto] items-center gap-3">
<Label className="text-xs font-semibold"> </Label>
<div className="text-xs">
<span className="font-mono">{selectedCompanyCode}</span>
{selectedCompany?.company_name && (
<span className="ml-2 text-muted-foreground">{selectedCompany.company_name}</span>
)}
</div>
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={downloadBulkTemplate}>
<FileDown className="h-3.5 w-3.5" />
릿
</Button>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-3">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-2">
<Input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleBulkFile}
className="h-8 cursor-pointer text-xs file:mr-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs"
/>
{bulkFileName && (
<span className="shrink-0 text-xs text-muted-foreground">
{bulkRows.length}
</span>
)}
</div>
</div>
</div>
{/* 미리보기 테이블 */}
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
<div className="flex items-center justify-between bg-muted/40 px-3 py-1.5 text-xs">
<span className="font-semibold">
({bulkSelected.size}/{bulkPreviewRows.length})
</span>
{bulkPreviewRows.length > 0 && (
<span className="text-muted-foreground">
<span className="text-emerald-600 dark:text-emerald-400"> {bulkOkCount}</span>
{" / "}
<span className="text-destructive"> {bulkErrCount}</span>
</span>
)}
</div>
<div className="min-h-[200px] flex-1 overflow-auto">
{bulkPreviewRows.length === 0 ? (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-xs text-muted-foreground">
<FileDown className="h-6 w-6 opacity-30" />
<p>{bulkRows.length === 0 ? "엑셀 파일을 업로드하세요" : "[미리보기] 버튼을 눌러 검증하세요"}</p>
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 z-10 bg-muted/60">
<tr>
<th className="w-9 px-2 py-1.5">
<Checkbox
checked={allOkSelected}
onCheckedChange={(c) => {
if (c) {
setBulkSelected(
new Set(
bulkPreviewRows.filter((r) => r.result === "ok").map((r) => r.row_index),
),
);
} else {
setBulkSelected(new Set());
}
}}
/>
</th>
{previewColumns.map((c) => (
<th key={c.key} className="px-2 py-1.5 text-left font-semibold">{c.label}</th>
))}
<th className="w-16 px-2 py-1.5 text-left font-semibold"></th>
<th className="px-2 py-1.5 text-left font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y">
{bulkPreviewRows.map((r) => {
const isErr = r.result === "error";
return (
<tr
key={r.row_index}
className={cn("hover:bg-muted/30", isErr && "bg-destructive/5")}
>
<td className="px-2 py-1.5">
<Checkbox
disabled={isErr}
checked={bulkSelected.has(r.row_index)}
onCheckedChange={(c) => {
setBulkSelected((prev) => {
const next = new Set(prev);
if (c) next.add(r.row_index);
else next.delete(r.row_index);
return next;
});
}}
/>
</td>
{previewColumns.map((c) => {
const v = (r as any)[c.key];
const display = (c as any).manager
? Array.isArray(v) && v.length > 0 ? v.join(", ") : "-"
: v != null && v !== "" ? String(v) : "-";
return (
<td key={c.key} className="max-w-[180px] truncate px-2 py-1.5" title={display}>
{display}
</td>
);
})}
<td className="px-2 py-1.5">
{isErr ? (
<Badge variant="destructive" className="gap-1 text-[10px]">
<XCircle className="h-3 w-3" />
</Badge>
) : (
<Badge className="gap-1 border-emerald-500/30 bg-emerald-500/15 text-[10px] text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-300">
<CheckCircle2 className="h-3 w-3" />
</Badge>
)}
</td>
<td
className="max-w-[300px] truncate px-2 py-1.5 text-destructive"
title={r.error_detail || ""}
>
{r.error_detail || ""}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</Tabs>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setBulkOpen(false)} disabled={bulkBusy}>
</Button>
<Button
variant="outline"
onClick={handleBulkPreview}
disabled={bulkBusy || bulkRows.length === 0}
>
{bulkBusy ? "검증 중..." : "미리보기"}
</Button>
<Button
onClick={handleBulkApply}
disabled={bulkBusy || bulkSelected.size === 0}
>
{bulkBusy ? "처리 중..." : `반영 (${bulkSelected.size}건)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+7
View File
@@ -73,6 +73,9 @@ import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
import { getIconComponent } from "@/components/admin/MenuIconPicker";
import { animatedThemeChange } from "@/lib/themeTransition";
// MANAGEMENT_ONLY_MENU_URLS — DB 컬럼 IS_SOLUTION_ONLY 로 이전 (PR #D).
// 백엔드 /api/admin/user-menus 가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 Set 불필요.
interface ExtendedUserInfo {
user_id: string;
user_name: string;
@@ -286,6 +289,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
const [settingsOpen, setSettingsOpen] = useState(false);
// isMgmtSite / MANAGEMENT_ONLY_MENU_URLS — DB IS_SOLUTION_ONLY 컬럼으로 이전 (PR #D).
// 백엔드가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 상태 불필요.
const tweaksAnchorRef = useRef<HTMLButtonElement>(null);
const { theme, setTheme: rawSetTheme } = useTheme();
@@ -924,6 +930,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
}
// 솔루션 전용 메뉴 필터는 백엔드 IS_SOLUTION_ONLY 컬럼 + Host 헤더 기반 SQL 필터로 위임 (PR #D).
const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
// 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장.
+84
View File
@@ -191,3 +191,87 @@ export async function setPrimaryDepartment(deptCode: string, userId: string) {
return { success: false, error: error.message };
}
}
// ──────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트
// ──────────────────────────────────────────────────
export type BulkAction = "create" | "update_department" | "update_manager";
export interface BulkPreviewRow extends Record<string, any> {
row_index: number;
result: "ok" | "error";
error_detail: string | null;
}
/**
* 일괄 미리보기 — read-only validation, write 없음.
* action 에 따라 create/update_department/update_manager 로 검증.
* 응답 rows 각 element 에 result(ok|error), error_detail 채워짐.
*/
export async function bulkPreviewDepartments(
companyCode: string,
action: BulkAction,
rows: Record<string, any>[],
) {
try {
const response = await apiClient.post<{
success: boolean;
data?: { rows: BulkPreviewRow[]; ok_count: number; error_count: number };
message?: string;
}>(`/departments/companies/${companyCode}/departments/bulk/preview`, { action, rows });
return response.data;
} catch (error: any) {
console.error("일괄 미리보기 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
/**
* 일괄등록 적용 (트랜잭션, all-or-nothing).
* rows 는 보통 미리보기에서 ok 인 row 만 보냄.
*/
export async function bulkCreateDepartments(companyCode: string, rows: Record<string, any>[]) {
try {
const response = await apiClient.post<{
success: boolean;
data?: { inserted: number };
message?: string;
}>(`/departments/companies/${companyCode}/departments/bulk/create`, { rows });
return response.data;
} catch (error: any) {
console.error("일괄등록 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
/**
* 일괄업데이트 적용 (트랜잭션). mode = department | manager.
* 각 row 에 dept_code 필수.
*/
export async function bulkUpdateDepartments(
companyCode: string,
mode: "department" | "manager",
rows: Record<string, any>[],
) {
try {
const response = await apiClient.post<{
success: boolean;
data?: { updated: number };
message?: string;
}>(`/departments/companies/${companyCode}/departments/bulk/update`, { mode, rows });
return response.data;
} catch (error: any) {
console.error("일괄업데이트 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
+11
View File
@@ -70,3 +70,14 @@ export function extractTenantSubdomain(host: string): string | null {
return null;
}
/**
* "관리 호스트" 여부.
* solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인처럼
* 테넌트가 아닌 호스트만 true. 실제 고객 사이트(qnc, test02 등) 는 false.
*
* 프로비저닝 UI/API 노출 여부 판단에 사용. 백엔드 SuperAdminGuard.isTenantHost 와 같은 규칙.
*/
export function isManagementHost(host: string): boolean {
return extractTenantSubdomain(host) === null;
}