68f85f3736
- 첫 로그인 비번 강제 변경 (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>
228 lines
11 KiB
Java
228 lines
11 KiB
Java
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);
|
|
}
|
|
}
|