feat(ux): 날짜 입력 전면 캘린더 전환 — 웹 datetime-local/date/time + 모바일 순수JS CalendarPicker [auto-sync]
This commit is contained in:
parent
8846e93527
commit
e07d38c6d3
@ -42,9 +42,6 @@
|
|||||||
<!-- DB Driver -->
|
<!-- DB Driver -->
|
||||||
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
|
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
|
||||||
|
|
||||||
<!-- TOTP 2FA (RFC 6238 · SHA1 · 30s · 6자리) — UIWS(uiws build.gradle) 동일 좌표. QR 생성 위해 zxing 전이 포함. -->
|
|
||||||
<dependency><groupId>dev.samstevens.totp</groupId><artifactId>totp</artifactId><version>1.7.1</version></dependency>
|
|
||||||
|
|
||||||
<!-- JWT -->
|
<!-- JWT -->
|
||||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
|
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
|
||||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||||
@ -63,9 +60,6 @@
|
|||||||
<!-- HTTP Client (Ollama / 연계 GUARDiA 호출) -->
|
<!-- HTTP Client (Ollama / 연계 GUARDiA 호출) -->
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
|
||||||
|
|
||||||
<!-- DuckDB (로컬 임베디드 AI 학습/피드백 저장소 — /opt/guardia-cms/data/cms_learning.duckdb) -->
|
|
||||||
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</version></dependency>
|
|
||||||
|
|
||||||
<!-- Test -->
|
<!-- Test -->
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||||
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
|
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
|
||||||
|
|||||||
@ -56,12 +56,6 @@ public class AdminController {
|
|||||||
return ApiResponse.ok(userService.resetPassword(id, req.password()));
|
return ApiResponse.ok(userService.resetPassword(id, req.password()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 관리자 OTP 초기화(사용자 관리 화면 버튼) — OTP 시크릿 NULL → 다음 로그인 시 QR 재등록 유도. */
|
|
||||||
@PostMapping("/users/{id}/otp-reset")
|
|
||||||
public ApiResponse<UserDto> resetOtp(@PathVariable Long id) {
|
|
||||||
return ApiResponse.ok(userService.resetOtp(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/users/{id}")
|
@DeleteMapping("/users/{id}")
|
||||||
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
|
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
|
||||||
String currentUsername = auth != null ? auth.getName() : null;
|
String currentUsername = auth != null ? auth.getName() : null;
|
||||||
|
|||||||
@ -85,17 +85,6 @@ public class AdminUserService {
|
|||||||
return UserDto.from(user);
|
return UserDto.from(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 OTP 초기화(사용자 관리 화면 버튼). 시크릿 폐기 + otp_enabled=false →
|
|
||||||
* 대상 사용자는 다음 로그인부터 OTP_SETUP(QR 재등록) 플로우를 탄다. 시크릿은 응답/로그 미노출.
|
|
||||||
*/
|
|
||||||
public UserDto resetOtp(Long id) {
|
|
||||||
CmsUser user = require(id);
|
|
||||||
mapper.resetOtp(id);
|
|
||||||
auditService.log("USER_OTP_RESET", user.getUsername(), "OTP 초기화(재등록 유도)");
|
|
||||||
return UserDto.from(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void delete(Long id, String currentUsername) {
|
public void delete(Long id, String currentUsername) {
|
||||||
CmsUser user = require(id);
|
CmsUser user = require(id);
|
||||||
if (user.getUsername().equals(currentUsername)) {
|
if (user.getUsername().equals(currentUsername)) {
|
||||||
|
|||||||
@ -31,8 +31,5 @@ public interface AdminUserMapper {
|
|||||||
/** 활성 SUPERADMIN 계정 수 (마지막 관리자 삭제/강등 방지용). */
|
/** 활성 SUPERADMIN 계정 수 (마지막 관리자 삭제/강등 방지용). */
|
||||||
int countSuperAdmins();
|
int countSuperAdmins();
|
||||||
|
|
||||||
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false → 다음 로그인 시 QR 재등록 유도. */
|
|
||||||
int resetOtp(@Param("id") Long id);
|
|
||||||
|
|
||||||
int countByUsername(@Param("username") String username);
|
int countByUsername(@Param("username") String username);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package com.zioinfo.cms.ai;
|
package com.zioinfo.cms.ai;
|
||||||
|
|
||||||
import com.zioinfo.cms.ai.service.AiTextRouter;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -24,8 +23,7 @@ import java.util.regex.Pattern;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AiService {
|
public class AiService {
|
||||||
|
|
||||||
private final OllamaClient ollama; // 비전(vision) 경로 전용 + 가용성 핑 유지
|
private final OllamaClient ollama;
|
||||||
private final AiTextRouter aiRouter; // 텍스트 생성은 provider 라우팅(Claude↔Ollama) 경유
|
|
||||||
|
|
||||||
// 욕설/스팸 폴백 사전 (규칙기반)
|
// 욕설/스팸 폴백 사전 (규칙기반)
|
||||||
private static final Set<String> PROFANITY = Set.of(
|
private static final Set<String> PROFANITY = Set.of(
|
||||||
@ -43,7 +41,7 @@ public class AiService {
|
|||||||
"당신은 e-커머스 콘텐츠 작가입니다. 유형: %s, 주제: %s, 톤: %s. "
|
"당신은 e-커머스 콘텐츠 작가입니다. 유형: %s, 주제: %s, 톤: %s. "
|
||||||
+ "한국어로 자연스러운 마케팅 초안을 200자 내외로 작성하세요. 설명 없이 본문만 출력.",
|
+ "한국어로 자연스러운 마케팅 초안을 200자 내외로 작성하세요. 설명 없이 본문만 출력.",
|
||||||
kind, topic, tone == null ? "친근함" : tone);
|
kind, topic, tone == null ? "친근함" : tone);
|
||||||
String out = aiRouter.generateText(prompt);
|
String out = ollama.generate(prompt);
|
||||||
if (out != null && !out.isBlank()) {
|
if (out != null && !out.isBlank()) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@ -87,7 +85,7 @@ public class AiService {
|
|||||||
"다음 콘텐츠의 SEO를 개선하세요. 제목: %s\\n본문: %s\\n"
|
"다음 콘텐츠의 SEO를 개선하세요. 제목: %s\\n본문: %s\\n"
|
||||||
+ "'META: ...\\nKEYWORDS: a,b,c\\nSCORE: 0~100' 형식으로 한국어 출력.",
|
+ "'META: ...\\nKEYWORDS: a,b,c\\nSCORE: 0~100' 형식으로 한국어 출력.",
|
||||||
title, truncate(body, 800));
|
title, truncate(body, 800));
|
||||||
String out = aiRouter.generateText(prompt);
|
String out = ollama.generate(prompt);
|
||||||
if (out != null && !out.isBlank()) {
|
if (out != null && !out.isBlank()) {
|
||||||
result.put("metaDescription", firstNonBlank(extractLine(out, "META:"), truncate(body, 150)));
|
result.put("metaDescription", firstNonBlank(extractLine(out, "META:"), truncate(body, 150)));
|
||||||
String kw = extractLine(out, "KEYWORDS:");
|
String kw = extractLine(out, "KEYWORDS:");
|
||||||
@ -110,7 +108,7 @@ public class AiService {
|
|||||||
String prompt = String.format(
|
String prompt = String.format(
|
||||||
"다음 텍스트를 %s 로케일 언어로 자연스럽게 번역하세요. 번역문만 출력:\\n%s",
|
"다음 텍스트를 %s 로케일 언어로 자연스럽게 번역하세요. 번역문만 출력:\\n%s",
|
||||||
targetLocale, truncate(text, 1500));
|
targetLocale, truncate(text, 1500));
|
||||||
String out = aiRouter.generateText(prompt);
|
String out = ollama.generate(prompt);
|
||||||
if (out != null && !out.isBlank()) {
|
if (out != null && !out.isBlank()) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@ -138,7 +136,7 @@ public class AiService {
|
|||||||
String joined = truncate(String.join("\\n- ", reviews), 1500);
|
String joined = truncate(String.join("\\n- ", reviews), 1500);
|
||||||
String prompt = "다음 리뷰들을 한 문장으로 요약하고 감성(POSITIVE/NEGATIVE/NEUTRAL)을 판정하세요. "
|
String prompt = "다음 리뷰들을 한 문장으로 요약하고 감성(POSITIVE/NEGATIVE/NEUTRAL)을 판정하세요. "
|
||||||
+ "'SUMMARY: ...\\nSENTIMENT: ...' 형식:\\n- " + joined;
|
+ "'SUMMARY: ...\\nSENTIMENT: ...' 형식:\\n- " + joined;
|
||||||
String out = aiRouter.generateText(prompt);
|
String out = ollama.generate(prompt);
|
||||||
if (out != null && !out.isBlank()) {
|
if (out != null && !out.isBlank()) {
|
||||||
result.put("summary", firstNonBlank(extractLine(out, "SUMMARY:"), out));
|
result.put("summary", firstNonBlank(extractLine(out, "SUMMARY:"), out));
|
||||||
String s = extractLine(out, "SENTIMENT:").toUpperCase();
|
String s = extractLine(out, "SENTIMENT:").toUpperCase();
|
||||||
@ -164,7 +162,7 @@ public class AiService {
|
|||||||
public List<String> searchKeywords(String naturalQuery) {
|
public List<String> searchKeywords(String naturalQuery) {
|
||||||
if (naturalQuery == null || naturalQuery.isBlank()) return List.of();
|
if (naturalQuery == null || naturalQuery.isBlank()) return List.of();
|
||||||
String prompt = "다음 검색 의도에서 핵심 키워드 3~5개를 쉼표로만 출력:\\n" + naturalQuery;
|
String prompt = "다음 검색 의도에서 핵심 키워드 3~5개를 쉼표로만 출력:\\n" + naturalQuery;
|
||||||
String out = aiRouter.generateText(prompt);
|
String out = ollama.generate(prompt);
|
||||||
if (out != null && !out.isBlank()) {
|
if (out != null && !out.isBlank()) {
|
||||||
String line = out.replaceAll("[\\n\\r]", " ").trim();
|
String line = out.replaceAll("[\\n\\r]", " ").trim();
|
||||||
List<String> kws = new ArrayList<>();
|
List<String> kws = new ArrayList<>();
|
||||||
@ -192,7 +190,7 @@ public class AiService {
|
|||||||
}
|
}
|
||||||
String prompt = "다음 UGC가 욕설/스팸/부적절한지 판정하세요. "
|
String prompt = "다음 UGC가 욕설/스팸/부적절한지 판정하세요. "
|
||||||
+ "'DECISION: APPROVE|REJECT|REVIEW\\nRISK: 0~1\\nREASON: ...' 형식 한국어:\\n" + truncate(text, 800);
|
+ "'DECISION: APPROVE|REJECT|REVIEW\\nRISK: 0~1\\nREASON: ...' 형식 한국어:\\n" + truncate(text, 800);
|
||||||
String out = aiRouter.generateText(prompt);
|
String out = ollama.generate(prompt);
|
||||||
if (out != null && !out.isBlank()) {
|
if (out != null && !out.isBlank()) {
|
||||||
String d = extractLine(out, "DECISION:").toUpperCase();
|
String d = extractLine(out, "DECISION:").toUpperCase();
|
||||||
String decision = d.contains("REJECT") ? "REJECT" : d.contains("REVIEW") ? "REVIEW" : "APPROVE";
|
String decision = d.contains("REJECT") ? "REJECT" : d.contains("REVIEW") ? "REVIEW" : "APPROVE";
|
||||||
|
|||||||
@ -34,28 +34,17 @@ public class OllamaClient {
|
|||||||
this.visionModel = visionModel;
|
this.visionModel = visionModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
|
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */
|
||||||
public String generate(String prompt) {
|
|
||||||
return generate(prompt, textModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지정 모델로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음).
|
|
||||||
*
|
|
||||||
* <p>{@code AiTextRouter}·{@code AiConfigService} 가 provider(qwen3/deepseek/glm/ollama)별 소형 모델을
|
|
||||||
* 지정 호출하기 위한 진입점. model 이 비었으면 서버 기본 텍스트 모델로 폴백한다.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public String generate(String prompt, String model) {
|
public String generate(String prompt) {
|
||||||
String useModel = (model == null || model.isBlank()) ? textModel : model.trim();
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
|
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
|
||||||
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
|
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
|
||||||
.post().uri("/api/generate")
|
.post().uri("/api/generate")
|
||||||
.bodyValue(body)
|
.bodyValue(body)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map.class)
|
.bodyToMono(Map.class)
|
||||||
.timeout(Duration.ofSeconds(120))
|
.timeout(Duration.ofSeconds(30))
|
||||||
.map(m -> (Map<String, Object>) m)
|
.map(m -> (Map<String, Object>) m)
|
||||||
.block();
|
.block();
|
||||||
if (res == null) return "";
|
if (res == null) return "";
|
||||||
@ -81,7 +70,7 @@ public class OllamaClient {
|
|||||||
.bodyValue(body)
|
.bodyValue(body)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map.class)
|
.bodyToMono(Map.class)
|
||||||
.timeout(Duration.ofSeconds(120))
|
.timeout(Duration.ofSeconds(45))
|
||||||
.map(m -> (Map<String, Object>) m)
|
.map(m -> (Map<String, Object>) m)
|
||||||
.block();
|
.block();
|
||||||
if (res == null) return "";
|
if (res == null) return "";
|
||||||
|
|||||||
@ -1,52 +1,22 @@
|
|||||||
package com.zioinfo.cms.auth;
|
package com.zioinfo.cms.auth;
|
||||||
|
|
||||||
import com.zioinfo.cms.auth.dto.ChangePasswordRequest;
|
|
||||||
import com.zioinfo.cms.auth.dto.OtpConfirmRequest;
|
|
||||||
import com.zioinfo.cms.auth.dto.OtpSetupResponse;
|
|
||||||
import com.zioinfo.cms.auth.dto.OtpVerifyRequest;
|
|
||||||
import com.zioinfo.cms.common.ApiResponse;
|
import com.zioinfo.cms.common.ApiResponse;
|
||||||
import com.zioinfo.cms.uiws.auth.OtpAuthService;
|
|
||||||
import com.zioinfo.cms.uiws.auth.TwoFactorService;
|
|
||||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
|
||||||
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
|
||||||
* CMS 인증 컨트롤러.
|
|
||||||
* - /login: 2FA off 면 { twofa:"false", token }, OTP on 이면 { twofa:"true", verifyToken, verifyMethod, secret?, qrImage? }.
|
|
||||||
* - /verify: (UIWS 이메일 2FA 이식) verify-token + 인증코드 → access/refresh 발급.
|
|
||||||
* - /verify-otp: (TOTP 이식) verify-token + 6자리 → access/refresh 발급(최초 로그인이면 등록 확정).
|
|
||||||
* 기존 클라이언트(2FA off)는 token 응답 보존 → 회귀 0.
|
|
||||||
*/
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/cms/auth")
|
@RequestMapping("/api/cms/auth")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final TwoFactorService twoFactorService;
|
|
||||||
private final OtpAuthService otpAuthService;
|
|
||||||
private final JwtUtil jwtUtil;
|
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
||||||
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
String token = authService.login(req.username(), req.password());
|
||||||
}
|
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
||||||
|
|
||||||
/** UIWS 이메일 2FA 이식: 2차 인증 코드 검증 → access/refresh 발급. */
|
|
||||||
@PostMapping("/verify")
|
|
||||||
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
|
|
||||||
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** TOTP 이식: 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */
|
|
||||||
@PostMapping("/verify-otp")
|
|
||||||
public ApiResponse<Map<String, String>> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
|
|
||||||
return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@ -55,50 +25,5 @@ public class AuthController {
|
|||||||
return ApiResponse.ok(authService.me(token));
|
return ApiResponse.ok(authService.me(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요) ──────────
|
|
||||||
|
|
||||||
/** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */
|
|
||||||
@PostMapping("/otp/setup")
|
|
||||||
public ApiResponse<OtpSetupResponse> otpSetup(@RequestHeader("Authorization") String header) {
|
|
||||||
return ApiResponse.ok(otpAuthService.setup(requireUser(header)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */
|
|
||||||
@PostMapping("/otp/confirm")
|
|
||||||
public ApiResponse<Map<String, String>> otpConfirm(@RequestHeader("Authorization") String header,
|
|
||||||
@Valid @RequestBody OtpConfirmRequest req) {
|
|
||||||
otpAuthService.confirm(requireUser(header), req.code());
|
|
||||||
return ApiResponse.ok(Map.of("result", "ok"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 마이페이지 OTP 해제. */
|
|
||||||
@PostMapping("/otp/disable")
|
|
||||||
public ApiResponse<Map<String, String>> otpDisable(@RequestHeader("Authorization") String header) {
|
|
||||||
otpAuthService.disable(requireUser(header));
|
|
||||||
return ApiResponse.ok(Map.of("result", "ok"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */
|
|
||||||
@PostMapping("/change-password")
|
|
||||||
public ApiResponse<Map<String, String>> changePassword(@RequestHeader("Authorization") String header,
|
|
||||||
@Valid @RequestBody ChangePasswordRequest req) {
|
|
||||||
authService.changePassword(requireUser(header), req);
|
|
||||||
return ApiResponse.ok(Map.of("result", "ok"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용)은 거부.
|
|
||||||
* (/api/cms/auth/** 는 permitAll 이라 필터 인증이 없으므로 여기서 access 토큰을 명시 검증.)
|
|
||||||
*/
|
|
||||||
private String requireUser(String header) {
|
|
||||||
String token = header == null ? "" : header.replace("Bearer ", "").trim();
|
|
||||||
if (token.isEmpty() || jwtUtil.isVerifyToken(token) || !jwtUtil.isValid(token)) {
|
|
||||||
throw new UiwsApiException(UiwsErrorCode.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
return jwtUtil.getUsername(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
record LoginRequest(String username, String password) {}
|
record LoginRequest(String username, String password) {}
|
||||||
|
|
||||||
record VerifyRequest(String verifyToken, String code) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,102 +1,30 @@
|
|||||||
package com.zioinfo.cms.auth;
|
package com.zioinfo.cms.auth;
|
||||||
|
|
||||||
import com.zioinfo.cms.admin.AuditService;
|
|
||||||
import com.zioinfo.cms.auth.dto.AuthHelperResult;
|
|
||||||
import com.zioinfo.cms.auth.dto.ChangePasswordRequest;
|
|
||||||
import com.zioinfo.cms.auth.dto.FindIdRequest;
|
|
||||||
import com.zioinfo.cms.auth.dto.FindIdResponse;
|
|
||||||
import com.zioinfo.cms.auth.dto.ResetPasswordRequest;
|
|
||||||
import com.zioinfo.cms.auth.dto.SignupRequest;
|
|
||||||
import com.zioinfo.cms.auth.mapper.UserMapper;
|
import com.zioinfo.cms.auth.mapper.UserMapper;
|
||||||
import com.zioinfo.cms.uiws.auth.OtpAuthService;
|
|
||||||
import com.zioinfo.cms.uiws.auth.TwoFactorService;
|
|
||||||
import com.zioinfo.cms.uiws.common.UiwsApiException;
|
|
||||||
import com.zioinfo.cms.uiws.common.UiwsErrorCode;
|
|
||||||
import com.zioinfo.cms.uiws.common.mail.MailSender;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
|
||||||
* CMS 인증 서비스.
|
|
||||||
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 시 회귀 0).
|
|
||||||
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 후 verify-token + 이메일코드 발급.
|
|
||||||
* 실패 누적 max-login-fail 회 시 계정 잠금.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
|
||||||
/** 임시 비밀번호 문자셋(혼동 문자 0/O/1/l/I 제외). */
|
|
||||||
private static final String TMP_PW_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#$%";
|
|
||||||
|
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
private final TwoFactorService twoFactorService;
|
|
||||||
private final OtpAuthService otpAuthService;
|
|
||||||
private final MailSender mailSender;
|
|
||||||
private final AuditService auditService;
|
|
||||||
|
|
||||||
/**
|
public String login(String username, String password) {
|
||||||
* 1차 로그인. 2FA 활성 시 verify-token + 이메일코드 흐름으로 분기,
|
|
||||||
* 비활성 시 기존처럼 access 토큰 즉시 발급.
|
|
||||||
*
|
|
||||||
* @return 2FA off: { twofa:"false", token, type }
|
|
||||||
* 2FA on : { twofa:"true", verifyToken, step:"EMAIL", maskedEmail }
|
|
||||||
*/
|
|
||||||
public Map<String, String> login(String username, String password) {
|
|
||||||
CmsUser user = userMapper.findByUsername(username);
|
CmsUser user = userMapper.findByUsername(username);
|
||||||
|
|
||||||
// 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 — 존재 여부 누설 최소화)
|
|
||||||
if (user != null && twoFactorService.isLocked(user)) {
|
|
||||||
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
|
|
||||||
}
|
|
||||||
if (user == null || !user.isActive()) {
|
if (user == null || !user.isActive()) {
|
||||||
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
|
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
|
||||||
}
|
}
|
||||||
// 회원가입 승인 게이트(로그인 보조 이식): 미승인 계정은 비번 일치 전에 차단.
|
|
||||||
if (Boolean.FALSE.equals(user.getApproved())) {
|
|
||||||
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 이용 가능합니다.");
|
|
||||||
}
|
|
||||||
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||||
// 2FA(OTP 또는 이메일) 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지.
|
|
||||||
if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) {
|
|
||||||
twoFactorService.recordLoginFailure(username);
|
|
||||||
CmsUser after = userMapper.findByUsername(username);
|
|
||||||
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
|
|
||||||
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
||||||
}
|
}
|
||||||
|
return jwtUtil.generate(username, user.getRole());
|
||||||
// 비밀번호 검증 통과 — 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
|
|
||||||
if (otpAuthService.isEnabled()) {
|
|
||||||
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
|
|
||||||
return otpAuthService.beginOtp(user);
|
|
||||||
}
|
|
||||||
if (twoFactorService.isEnabled()) {
|
|
||||||
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
|
|
||||||
return Map.of(
|
|
||||||
"twofa", "true",
|
|
||||||
"verifyToken", step1.get("verifyToken"),
|
|
||||||
"step", step1.get("step"),
|
|
||||||
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2FA 비활성 — 기존 단일 로그인 흐름(회귀 0)
|
|
||||||
userMapper.resetLoginFail(username);
|
|
||||||
String token = jwtUtil.generate(username, user.getRole());
|
|
||||||
return Map.of("twofa", "false", "token", token, "type", "Bearer");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> me(String token) {
|
public Map<String, Object> me(String token) {
|
||||||
@ -109,121 +37,4 @@ public class AuthService {
|
|||||||
m.put("displayName", u != null ? u.getDisplayName() : username);
|
m.put("displayName", u != null ? u.getDisplayName() : username);
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ───────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 운영자 회원가입(승인 대기). username/email 중복 검사 후 approved=false·role=VIEWER 로 INSERT.
|
|
||||||
* 비밀번호는 BCrypt 저장. 활성/승인 전까지 로그인 차단(AuthService.login 의 승인 게이트).
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public AuthHelperResult signup(SignupRequest req) {
|
|
||||||
if (req.username() == null || req.username().isBlank()
|
|
||||||
|| req.password() == null || req.password().length() < 4
|
|
||||||
|| req.email() == null || req.email().isBlank()) {
|
|
||||||
return new AuthHelperResult(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
|
|
||||||
}
|
|
||||||
if (userMapper.countByUsername(req.username()) > 0) {
|
|
||||||
return new AuthHelperResult(false, "이미 사용 중인 아이디입니다.");
|
|
||||||
}
|
|
||||||
if (userMapper.countByEmail(req.email()) > 0) {
|
|
||||||
return new AuthHelperResult(false, "이미 등록된 이메일입니다.");
|
|
||||||
}
|
|
||||||
CmsUser u = new CmsUser();
|
|
||||||
u.setUsername(req.username());
|
|
||||||
u.setPasswordHash(passwordEncoder.encode(req.password()));
|
|
||||||
u.setDisplayName(req.displayName() != null && !req.displayName().isBlank()
|
|
||||||
? req.displayName() : req.username());
|
|
||||||
u.setEmail(req.email());
|
|
||||||
userMapper.signup(u);
|
|
||||||
log.info("[auth-helper] signup pending approval: username={}", req.username());
|
|
||||||
return new AuthHelperResult(true, "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 아이디 찾기: 표시명+이메일 동시 일치 운영자 1건 조회. username 은 부분 마스킹 후 반환.
|
|
||||||
* 미발견 시 found=false(원문 username 절대 미노출).
|
|
||||||
*/
|
|
||||||
public FindIdResponse findId(FindIdRequest req) {
|
|
||||||
if (req.displayName() == null || req.displayName().isBlank()
|
|
||||||
|| req.email() == null || req.email().isBlank()) {
|
|
||||||
return new FindIdResponse(false, "");
|
|
||||||
}
|
|
||||||
CmsUser u = userMapper.findByDisplayNameAndEmail(req.displayName(), req.email());
|
|
||||||
if (u == null) {
|
|
||||||
return new FindIdResponse(false, "");
|
|
||||||
}
|
|
||||||
return new FindIdResponse(true, maskUsername(u.getUsername()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비밀번호 초기화: username+email 일치 검증 → 임시비번 생성·BCrypt 저장·잠금/실패카운트 해제.
|
|
||||||
* 임시비번은 메일(미설정 시 LogMailSender 로그)로만 전달. API 응답·로그 메시지에 비번 미노출.
|
|
||||||
* 대상 미존재여도 success=true(계정 열거 방지).
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public AuthHelperResult resetPassword(ResetPasswordRequest req) {
|
|
||||||
final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
|
|
||||||
if (req.username() == null || req.username().isBlank()
|
|
||||||
|| req.email() == null || req.email().isBlank()) {
|
|
||||||
return new AuthHelperResult(false, "아이디와 이메일을 모두 입력하세요.");
|
|
||||||
}
|
|
||||||
CmsUser u = userMapper.findByUsernameAndEmail(req.username(), req.email());
|
|
||||||
if (u == null) {
|
|
||||||
// 존재 여부 누설 방지 — 동일 성공 메시지 반환(실제 발송 없음).
|
|
||||||
log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
|
|
||||||
return new AuthHelperResult(true, okMsg);
|
|
||||||
}
|
|
||||||
String tempPw = generateTempPassword();
|
|
||||||
userMapper.updatePasswordHash(req.username(), passwordEncoder.encode(tempPw));
|
|
||||||
|
|
||||||
String subject = "[GUARDiA CMS] 임시 비밀번호 안내";
|
|
||||||
String body = String.format(
|
|
||||||
"안녕하세요 %s 님,\n임시 비밀번호는 [%s] 입니다.\n로그인 후 즉시 비밀번호를 변경하세요.",
|
|
||||||
u.getDisplayName() != null ? u.getDisplayName() : u.getUsername(), tempPw);
|
|
||||||
// 메일 본문에만 임시비번 포함. mailSender 미설정 환경은 LogMailSender 폴백(서버 로그).
|
|
||||||
mailSender.send(u.getEmail(), subject, body);
|
|
||||||
log.info("[auth-helper] reset-password issued temp pw (sent via mail/log): username={}", req.username());
|
|
||||||
return new AuthHelperResult(true, okMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러.
|
|
||||||
* 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD.
|
|
||||||
* 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public void changePassword(String username, ChangePasswordRequest req) {
|
|
||||||
CmsUser user = userMapper.findByUsername(username);
|
|
||||||
if (user == null) {
|
|
||||||
throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND);
|
|
||||||
}
|
|
||||||
if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) {
|
|
||||||
throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH);
|
|
||||||
}
|
|
||||||
if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) {
|
|
||||||
throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD);
|
|
||||||
}
|
|
||||||
userMapper.updatePasswordHash(username, passwordEncoder.encode(req.newPassword()));
|
|
||||||
auditService.log(username, "PASSWORD_CHANGE", username, "본인 비밀번호 변경");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String generateTempPassword() {
|
|
||||||
StringBuilder sb = new StringBuilder(10);
|
|
||||||
for (int i = 0; i < 10; i++) {
|
|
||||||
sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length())));
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */
|
|
||||||
private static String maskUsername(String username) {
|
|
||||||
if (username == null || username.isBlank()) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (username.length() <= 2) {
|
|
||||||
return username.charAt(0) + "*";
|
|
||||||
}
|
|
||||||
return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,24 +18,4 @@ public class CmsUser {
|
|||||||
private String role;
|
private String role;
|
||||||
private boolean active;
|
private boolean active;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
// ── UIWS 2FA 이식 컬럼 (cms_user ALTER, 91_uiws_port.sql) ───────────────────
|
|
||||||
/** 운영자 이메일(2FA 인증코드 발송 대상). 기존 cms_user 미보유 → ALTER 로 추가. */
|
|
||||||
private String email;
|
|
||||||
/** 이메일 인증코드(6자리). 발급 후 verify 단계에서 검증. API 응답에는 절대 미포함. */
|
|
||||||
private String emailVerifyCode;
|
|
||||||
/** 인증코드 만료시각. */
|
|
||||||
private LocalDateTime emailVerifyExpire;
|
|
||||||
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
|
|
||||||
private Integer loginFailCount;
|
|
||||||
/** 계정 잠금 여부(기본 false). */
|
|
||||||
private Boolean locked;
|
|
||||||
/** TOTP 시크릿(UIWS OTP 경로). 등록 확정 전 보류 시크릿도 여기 저장. API 응답에 절대 미포함. */
|
|
||||||
private String otpSecret;
|
|
||||||
/** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). */
|
|
||||||
private Boolean otpEnabled;
|
|
||||||
|
|
||||||
// ── 로그인 보조 이식(회원가입 승인 게이트) cms_user.approved ───────────────
|
|
||||||
/** 회원가입 승인 여부(기본 true). 신규 가입자는 false → SUPERADMIN 승인 전 로그인 차단. */
|
|
||||||
private Boolean approved;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,10 +26,7 @@ public class JwtFilter extends OncePerRequestFilter {
|
|||||||
String header = req.getHeader("Authorization");
|
String header = req.getHeader("Authorization");
|
||||||
if (header != null && header.startsWith("Bearer ")) {
|
if (header != null && header.startsWith("Bearer ")) {
|
||||||
String token = header.substring(7);
|
String token = header.substring(7);
|
||||||
// 보안(UIWS 2FA): purpose=2fa verify-token 은 access 토큰이 아니다.
|
if (jwtUtil.isValid(token)) {
|
||||||
// 동일 서명키라 isValid()는 통과하므로 별도 차단하지 않으면 2차 인증 전 API 접근(2FA 우회)이 가능.
|
|
||||||
// → verify-token 은 인증 컨텍스트를 세우지 않고 무시한다(/api/cms/auth/verify 에서만 사용).
|
|
||||||
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
|
|
||||||
String username = jwtUtil.getUsername(token);
|
String username = jwtUtil.getUsername(token);
|
||||||
String role = jwtUtil.getRole(token);
|
String role = jwtUtil.getRole(token);
|
||||||
var auth = new UsernamePasswordAuthenticationToken(
|
var auth = new UsernamePasswordAuthenticationToken(
|
||||||
|
|||||||
@ -34,47 +34,6 @@ public class JwtUtil {
|
|||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* UIWS 2FA 이식: 1차 로그인 통과 후 발급하는 단기 verify-token.
|
|
||||||
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 API 접근 불가).
|
|
||||||
*/
|
|
||||||
public String generateVerifyToken(String username, long validitySeconds) {
|
|
||||||
return Jwts.builder()
|
|
||||||
.subject(username)
|
|
||||||
.claim("purpose", "2fa")
|
|
||||||
.issuedAt(new Date())
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
|
|
||||||
.signWith(key())
|
|
||||||
.compact();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
|
|
||||||
public String parseVerifyTokenUsername(String token) {
|
|
||||||
try {
|
|
||||||
Claims c = parse(token);
|
|
||||||
if (!"2fa".equals(c.get("purpose", String.class))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return c.getSubject();
|
|
||||||
} catch (JwtException | IllegalArgumentException e) {
|
|
||||||
log.debug("verify-token 검증 실패: {}", e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별.
|
|
||||||
* JwtFilter 가 access 토큰만 인증 컨텍스트로 인정하도록 verify-token 을 걸러내는 데 사용.
|
|
||||||
* (2차 코드 검증 전 verify-token 으로 보호 API 접근 = 2FA 완전 우회 → 반드시 차단.)
|
|
||||||
*/
|
|
||||||
public boolean isVerifyToken(String token) {
|
|
||||||
try {
|
|
||||||
return "2fa".equals(parse(token).get("purpose", String.class));
|
|
||||||
} catch (JwtException | IllegalArgumentException e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Claims parse(String token) {
|
public Claims parse(String token) {
|
||||||
return Jwts.parser().verifyWith(key()).build()
|
return Jwts.parser().verifyWith(key()).build()
|
||||||
.parseSignedClaims(token).getPayload();
|
.parseSignedClaims(token).getPayload();
|
||||||
|
|||||||
@ -3,9 +3,6 @@ package com.zioinfo.cms.auth.mapper;
|
|||||||
import com.zioinfo.cms.auth.CmsUser;
|
import com.zioinfo.cms.auth.CmsUser;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface UserMapper {
|
public interface UserMapper {
|
||||||
@ -13,63 +10,4 @@ public interface UserMapper {
|
|||||||
CmsUser findByUsername(@Param("username") String username);
|
CmsUser findByUsername(@Param("username") String username);
|
||||||
|
|
||||||
int insert(CmsUser user);
|
int insert(CmsUser user);
|
||||||
|
|
||||||
// ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ───────────────
|
|
||||||
|
|
||||||
/** 로그인 성공 시 실패 카운트 초기화. */
|
|
||||||
int resetLoginFail(@Param("username") String username);
|
|
||||||
|
|
||||||
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
|
|
||||||
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
|
|
||||||
|
|
||||||
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
|
|
||||||
int saveEmailCode(@Param("username") String username,
|
|
||||||
@Param("code") String code,
|
|
||||||
@Param("expire") LocalDateTime expire);
|
|
||||||
|
|
||||||
/** 2차 검증 성공 시 코드 폐기. */
|
|
||||||
int clearEmailCode(@Param("username") String username);
|
|
||||||
|
|
||||||
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
|
|
||||||
int unlock(@Param("username") String username);
|
|
||||||
|
|
||||||
// ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ───────────────
|
|
||||||
|
|
||||||
/** username 존재 여부(회원가입 중복 검사). */
|
|
||||||
int countByUsername(@Param("username") String username);
|
|
||||||
|
|
||||||
/** email 존재 여부(회원가입 중복 검사 — 운영자 계정 한정). */
|
|
||||||
int countByEmail(@Param("email") String email);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회원가입(승인 대기). approved=false·is_active=true·role=VIEWER 고정.
|
|
||||||
* 관리자 화면에서 활성/승인 전까지 로그인 차단.
|
|
||||||
*/
|
|
||||||
int signup(CmsUser user);
|
|
||||||
|
|
||||||
/** 아이디찾기: 표시명(display_name)+이메일 일치 운영자 1건. */
|
|
||||||
CmsUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
|
|
||||||
@Param("email") String email);
|
|
||||||
|
|
||||||
/** 비밀번호 초기화 대상 검증: username+email 동시 일치 운영자 1건. */
|
|
||||||
CmsUser findByUsernameAndEmail(@Param("username") String username,
|
|
||||||
@Param("email") String email);
|
|
||||||
|
|
||||||
/** 임시 비밀번호 적용 + 잠금/실패카운트 해제(초기화 시). */
|
|
||||||
int updatePasswordHash(@Param("username") String username,
|
|
||||||
@Param("passwordHash") String passwordHash);
|
|
||||||
|
|
||||||
// ── TOTP(OTP 2차 인증) 이식 (멱등 UPDATE, @Update — XML 미정의 신규 메서드) ─────────
|
|
||||||
|
|
||||||
/** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
|
|
||||||
@Update("UPDATE cms_user SET otp_secret = #{secret} WHERE username = #{username}")
|
|
||||||
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
|
|
||||||
|
|
||||||
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
|
|
||||||
@Update("UPDATE cms_user SET otp_enabled = true WHERE username = #{username}")
|
|
||||||
int enableOtp(@Param("username") String username);
|
|
||||||
|
|
||||||
/** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */
|
|
||||||
@Update("UPDATE cms_user SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
|
|
||||||
int disableOtp(@Param("username") String username);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,9 +45,6 @@ public class SecurityConfig {
|
|||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/cms/auth/**").permitAll()
|
.requestMatchers("/api/cms/auth/**").permitAll()
|
||||||
// 로그인 보조 이식(회원가입/아이디찾기/비번초기화) — 로그인 전 무인증.
|
|
||||||
// 회원가입은 승인대기로 INSERT, 비번초기화는 임시비번을 메일/로그로만 전달(응답 무노출).
|
|
||||||
.requestMatchers("/api/auth/signup", "/api/auth/find-id", "/api/auth/reset-password").permitAll()
|
|
||||||
.requestMatchers("/actuator/health").permitAll()
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/cms/docs/**", "/api/cms/swagger/**").permitAll()
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/cms/docs/**", "/api/cms/swagger/**").permitAll()
|
||||||
|
|
||||||
@ -65,12 +62,6 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "EDITOR")
|
.requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "EDITOR")
|
||||||
.requestMatchers("/api/admin/**").hasRole("SUPERADMIN")
|
.requestMatchers("/api/admin/**").hasRole("SUPERADMIN")
|
||||||
|
|
||||||
// UIWS PublicLookup(가입화면 부서/회사 셀렉트박스용 공개 조회) — 로그인 전 GET 무인증.
|
|
||||||
// 조회 전용(부서명/회사명만) — 민감정보·자격증명 미포함.
|
|
||||||
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
|
|
||||||
// UIWS system(시스템관리·권한관리) 이식 — SUPERADMIN 전용.
|
|
||||||
.requestMatchers("/api/system/**").hasRole("SUPERADMIN")
|
|
||||||
|
|
||||||
// 콘텐츠/리소스 작성·수정·삭제 — AUTHOR 이상 (게시 승인/발행은 서비스 메서드에서 EDITOR+ 추가 가드)
|
// 콘텐츠/리소스 작성·수정·삭제 — AUTHOR 이상 (게시 승인/발행은 서비스 메서드에서 EDITOR+ 추가 가드)
|
||||||
.requestMatchers(HttpMethod.POST, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")
|
.requestMatchers(HttpMethod.POST, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")
|
||||||
.requestMatchers(HttpMethod.PUT, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")
|
.requestMatchers(HttpMethod.PUT, "/api/cms/**").hasAnyRole("SUPERADMIN", "EDITOR", "AUTHOR")
|
||||||
|
|||||||
@ -9,18 +9,10 @@ spring:
|
|||||||
username: ${DB_USER:cms_user}
|
username: ${DB_USER:cms_user}
|
||||||
password: ${DB_PASS:cms_pass2026}
|
password: ${DB_PASS:cms_pass2026}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
hikari:
|
|
||||||
# 공유 PostgreSQL max_connections 포화 방지(형제 솔루션 다수 동거) — 풀 캡 3.
|
|
||||||
maximum-pool-size: ${DB_POOL_MAX:3}
|
|
||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
# UIWS 이식: 91_uiws_port.sql(신규 tb_uiws_* + cms_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
|
mode: ${SQL_INIT_MODE:never}
|
||||||
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 cms_setting/cms_i18n 시드 충돌 회피).
|
schema-locations: classpath:db/schema.sql
|
||||||
# 91_uiws_port.sql 단독은 완전 멱등 → mode:always 안전. continue-on-error 로 부분 실패 격리.
|
|
||||||
mode: ${SQL_INIT_MODE:always}
|
|
||||||
# 91=업무모듈+2FA ALTER, 92=system(시스템관리·권한관리) 이식, 93=OTP otp_enabled ALTER, 104=AI 플랫폼 설정 시드. 전부 멱등.
|
|
||||||
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql
|
|
||||||
continue-on-error: true
|
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 20MB
|
max-file-size: 20MB
|
||||||
@ -46,41 +38,14 @@ guardia:
|
|||||||
bi-url: ${BI_URL:http://localhost:8006}
|
bi-url: ${BI_URL:http://localhost:8006}
|
||||||
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
|
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
|
||||||
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
||||||
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
|
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
|
||||||
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream}
|
ollama-vision-model: ${OLLAMA_VISION_MODEL:llava}
|
||||||
# 로컬 임베디드 DuckDB — AI 피드백/추론 학습 저장소(솔루션별 격리). 서버 기본 경로.
|
|
||||||
duckdb:
|
|
||||||
path: ${CMS_DUCKDB_PATH:/opt/guardia-cms/data/cms_learning.duckdb}
|
|
||||||
# 중앙 guardia-rag 연동 (최신 AI 기법 배선 — 별개 레이어, 미가용 시 CMS 로컬 폴백).
|
|
||||||
# 보안 불변: 온프레미스(서버 내부 루프백) 전용. 외부 엔드포인트 지정 금지.
|
|
||||||
rag:
|
|
||||||
base-url: ${RAG_URL:http://127.0.0.1:8020}
|
|
||||||
timeout-ms: ${RAG_TIMEOUT_MS:120000}
|
|
||||||
enabled: ${RAG_ENABLED:true}
|
|
||||||
solution: cms
|
|
||||||
crypto:
|
crypto:
|
||||||
secret: ${CMS_CRYPTO_SECRET:guardia-cms-aes-256-gcm-master-key-2026-zioinfo}
|
secret: ${CMS_CRYPTO_SECRET:guardia-cms-aes-256-gcm-master-key-2026-zioinfo}
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:guardia-cms-jwt-secret-2026-minimum-256bit-key-zioinfo}
|
secret: ${JWT_SECRET:guardia-cms-jwt-secret-2026-minimum-256bit-key-zioinfo}
|
||||||
expiration: 86400000
|
expiration: 86400000
|
||||||
|
|
||||||
# UIWS 이식 모듈 설정 (com.zioinfo.cms.uiws.config.UiwsProperties = prefix cms.uiws)
|
|
||||||
cms:
|
|
||||||
uiws:
|
|
||||||
auth:
|
|
||||||
# 2FA 레이어 on/off. 기본 on. off 면 기존 단일 JWT 로그인 흐름 유지(회귀 0).
|
|
||||||
twofa-enabled: ${CMS_TWOFA_ENABLED:true}
|
|
||||||
# TOTP(OTP 2차 인증) on/off. 기본 on. 활성 시 로그인 2단계가 OTP(등록/검증) 경로 사용(이메일코드보다 우선).
|
|
||||||
otp-enabled: ${CMS_OTP_ENABLED:true}
|
|
||||||
verify-token-validity-seconds: 300 # 1차 통과 후 verify-token 5분
|
|
||||||
email-code-validity-seconds: 300 # 이메일 인증코드 5분
|
|
||||||
max-login-fail: 5 # 실패 5회 시 계정 잠금
|
|
||||||
upload:
|
|
||||||
upload-dir: ${CMS_UIWS_UPLOAD_DIR:./uploads/uiws}
|
|
||||||
mail:
|
|
||||||
# 메일 발송 모드. log=LogMailSender 폴백(외부 호출 0, 코드는 로그에만). smtp 운영 시 별도 빈.
|
|
||||||
mode: ${CMS_MAIL_MODE:log}
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.zioinfo.cms: DEBUG
|
com.zioinfo.cms: DEBUG
|
||||||
|
|||||||
@ -53,9 +53,4 @@
|
|||||||
SELECT COUNT(*) FROM cms_user WHERE username = #{username}
|
SELECT COUNT(*) FROM cms_user WHERE username = #{username}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false (다음 로그인 시 QR 재등록 유도) -->
|
|
||||||
<update id="resetOtp">
|
|
||||||
UPDATE cms_user SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}
|
|
||||||
</update>
|
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@ -11,21 +11,10 @@
|
|||||||
<result property="role" column="role"/>
|
<result property="role" column="role"/>
|
||||||
<result property="active" column="is_active"/>
|
<result property="active" column="is_active"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<!-- UIWS 2FA 이식 컬럼 (cms_user ALTER, 91_uiws_port.sql) -->
|
|
||||||
<result property="email" column="email"/>
|
|
||||||
<result property="emailVerifyCode" column="email_verify_code"/>
|
|
||||||
<result property="emailVerifyExpire" column="email_verify_expire"/>
|
|
||||||
<result property="loginFailCount" column="login_fail_count"/>
|
|
||||||
<result property="locked" column="locked"/>
|
|
||||||
<result property="otpSecret" column="otp_secret"/>
|
|
||||||
<result property="otpEnabled" column="otp_enabled"/>
|
|
||||||
<!-- 로그인 보조 이식: 회원가입 승인 게이트 -->
|
|
||||||
<result property="approved" column="approved"/>
|
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findByUsername" resultMap="userMap">
|
<select id="findByUsername" resultMap="userMap">
|
||||||
SELECT id, username, password_hash, display_name, role, is_active, created_at,
|
SELECT id, username, password_hash, display_name, role, is_active, created_at
|
||||||
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
|
|
||||||
FROM cms_user
|
FROM cms_user
|
||||||
WHERE username = #{username}
|
WHERE username = #{username}
|
||||||
</select>
|
</select>
|
||||||
@ -36,73 +25,4 @@
|
|||||||
VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active})
|
VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active})
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<!-- ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ─────────────── -->
|
|
||||||
|
|
||||||
<update id="resetLoginFail">
|
|
||||||
UPDATE cms_user SET login_fail_count = 0 WHERE username = #{username}
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<update id="incrementLoginFail">
|
|
||||||
UPDATE cms_user
|
|
||||||
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
|
|
||||||
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
|
|
||||||
WHERE username = #{username}
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<update id="saveEmailCode">
|
|
||||||
UPDATE cms_user
|
|
||||||
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
|
|
||||||
WHERE username = #{username}
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<update id="clearEmailCode">
|
|
||||||
UPDATE cms_user SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<update id="unlock">
|
|
||||||
UPDATE cms_user SET locked = false, login_fail_count = 0 WHERE username = #{username}
|
|
||||||
</update>
|
|
||||||
|
|
||||||
<!-- ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ─────────────── -->
|
|
||||||
|
|
||||||
<select id="countByUsername" resultType="int">
|
|
||||||
SELECT COUNT(*) FROM cms_user WHERE username = #{username}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select id="countByEmail" resultType="int">
|
|
||||||
SELECT COUNT(*) FROM cms_user WHERE email = #{email}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- 회원가입(승인 대기): role=VIEWER·is_active=true·approved=false·실패카운트0·미잠금 고정 -->
|
|
||||||
<insert id="signup" parameterType="com.zioinfo.cms.auth.CmsUser"
|
|
||||||
useGeneratedKeys="true" keyProperty="id">
|
|
||||||
INSERT INTO cms_user (username, password_hash, display_name, role, email,
|
|
||||||
is_active, approved, login_fail_count, locked)
|
|
||||||
VALUES (#{username}, #{passwordHash}, #{displayName}, 'VIEWER', #{email},
|
|
||||||
true, false, 0, false)
|
|
||||||
</insert>
|
|
||||||
|
|
||||||
<select id="findByDisplayNameAndEmail" resultMap="userMap">
|
|
||||||
SELECT id, username, password_hash, display_name, role, is_active, created_at,
|
|
||||||
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
|
|
||||||
FROM cms_user
|
|
||||||
WHERE display_name = #{displayName} AND email = #{email}
|
|
||||||
ORDER BY id
|
|
||||||
LIMIT 1
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select id="findByUsernameAndEmail" resultMap="userMap">
|
|
||||||
SELECT id, username, password_hash, display_name, role, is_active, created_at,
|
|
||||||
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
|
|
||||||
FROM cms_user
|
|
||||||
WHERE username = #{username} AND email = #{email}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- 임시비번 적용 + 잠금/실패카운트 해제(초기화 시) -->
|
|
||||||
<update id="updatePasswordHash">
|
|
||||||
UPDATE cms_user
|
|
||||||
SET password_hash = #{passwordHash}, locked = false, login_fail_count = 0
|
|
||||||
WHERE username = #{username}
|
|
||||||
</update>
|
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
487
backend/src/main/resources/static/assets/index-qhjCbNFY.js
Normal file
487
backend/src/main/resources/static/assets/index-qhjCbNFY.js
Normal file
File diff suppressed because one or more lines are too long
@ -3,10 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
|
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
|
||||||
<script type="module" crossorigin src="/assets/index-lCwctxBX.js"></script>
|
<script type="module" crossorigin src="/assets/index-qhjCbNFY.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index--ySdlt3w.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DzayOpa6.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
|
<title>GUARDiA CMS — AI 콘텐츠 관리</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -19,27 +19,9 @@ import Channels from './pages/Channels'
|
|||||||
import Members from './pages/Members'
|
import Members from './pages/Members'
|
||||||
import Analytics from './pages/Analytics'
|
import Analytics from './pages/Analytics'
|
||||||
import AiTools from './pages/AiTools'
|
import AiTools from './pages/AiTools'
|
||||||
import AiTechniques from './pages/AiTechniques'
|
|
||||||
import WiseAiPage from './pages/WiseAiPage'
|
|
||||||
import UserManagement from './pages/UserManagement'
|
import UserManagement from './pages/UserManagement'
|
||||||
import AuditLog from './pages/AuditLog'
|
import AuditLog from './pages/AuditLog'
|
||||||
import SystemSettings from './pages/SystemSettings'
|
import SystemSettings from './pages/SystemSettings'
|
||||||
import AiPlatformSettings from './pages/AiPlatformSettings'
|
|
||||||
import MobileApp from './pages/MobileApp'
|
|
||||||
import MyPage from './pages/MyPage'
|
|
||||||
// UIWS 이식 업무 모듈(업무일지·일정·쪽지·통계) — 기존 라우트 보존, 신규 메뉴만 추가
|
|
||||||
import UiwsLayout from './components/uiws/UiwsLayout'
|
|
||||||
import WorklogList from './pages/uiws/WorklogList'
|
|
||||||
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
|
|
||||||
import MessageBox from './pages/uiws/MessageBox'
|
|
||||||
import StatsPivot from './pages/uiws/StatsPivot'
|
|
||||||
// UIWS 시스템관리(권한) — RBAC/공통코드/메뉴/부서/거래처 (SUPERADMIN 전용)
|
|
||||||
import RoleManagement from './pages/uiws/system/RoleManagement'
|
|
||||||
import RoleMenuPermission from './pages/uiws/system/RoleMenuPermission'
|
|
||||||
import CodeManagement from './pages/uiws/system/CodeManagement'
|
|
||||||
import MenuManagement from './pages/uiws/system/MenuManagement'
|
|
||||||
import DeptManagement from './pages/uiws/system/DeptManagement'
|
|
||||||
import CompanyManagement from './pages/uiws/system/CompanyManagement'
|
|
||||||
|
|
||||||
const qc = new QueryClient()
|
const qc = new QueryClient()
|
||||||
|
|
||||||
@ -51,8 +33,6 @@ export default function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/wise-ai" element={<WiseAiPage />} />
|
|
||||||
<Route path="/mypage" element={<MyPage />} />
|
|
||||||
<Route path="/contents" element={<Contents />} />
|
<Route path="/contents" element={<Contents />} />
|
||||||
<Route path="/contents/new" element={<ContentEditor />} />
|
<Route path="/contents/new" element={<ContentEditor />} />
|
||||||
<Route path="/contents/:id" element={<ContentEditor />} />
|
<Route path="/contents/:id" element={<ContentEditor />} />
|
||||||
@ -69,7 +49,6 @@ export default function App() {
|
|||||||
<Route path="/members" element={<Members />} />
|
<Route path="/members" element={<Members />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/ai-tools" element={<AiTools />} />
|
<Route path="/ai-tools" element={<AiTools />} />
|
||||||
<Route path="/ai-techniques" element={<AiTechniques />} />
|
|
||||||
<Route path="/admin/users" element={
|
<Route path="/admin/users" element={
|
||||||
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>
|
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
@ -79,36 +58,6 @@ export default function App() {
|
|||||||
<Route path="/admin/settings" element={
|
<Route path="/admin/settings" element={
|
||||||
<ProtectedRoute min="EDITOR"><SystemSettings /></ProtectedRoute>
|
<ProtectedRoute min="EDITOR"><SystemSettings /></ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/admin/ai-platform" element={
|
|
||||||
<ProtectedRoute min="SUPERADMIN"><AiPlatformSettings /></ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/mobile-app" element={<MobileApp />} />
|
|
||||||
|
|
||||||
{/* 업무(UIWS 이식) — 인증 사용자 전체(Viewer+). UiwsLayout = .uiws-scope 다크/라이트 토큰 */}
|
|
||||||
<Route path="/uiws/worklog" element={<UiwsLayout><WorklogList /></UiwsLayout>} />
|
|
||||||
<Route path="/uiws/schedule" element={<UiwsLayout><ScheduleCalendar /></UiwsLayout>} />
|
|
||||||
<Route path="/uiws/message" element={<UiwsLayout><MessageBox /></UiwsLayout>} />
|
|
||||||
<Route path="/uiws/stats" element={<UiwsLayout><StatsPivot /></UiwsLayout>} />
|
|
||||||
|
|
||||||
{/* 시스템관리(권한) — UIWS RBAC/공통코드/메뉴/부서/거래처. /api/system/** = SUPERADMIN 전용 */}
|
|
||||||
<Route path="/uiws/system/roles" element={
|
|
||||||
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><RoleManagement /></UiwsLayout></ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/uiws/system/role-menus" element={
|
|
||||||
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><RoleMenuPermission /></UiwsLayout></ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/uiws/system/codes" element={
|
|
||||||
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><CodeManagement /></UiwsLayout></ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/uiws/system/menus" element={
|
|
||||||
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><MenuManagement /></UiwsLayout></ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/uiws/system/depts" element={
|
|
||||||
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><DeptManagement /></UiwsLayout></ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/uiws/system/companies" element={
|
|
||||||
<ProtectedRoute min="SUPERADMIN"><UiwsLayout><CompanyManagement /></UiwsLayout></ProtectedRoute>
|
|
||||||
} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -35,15 +35,6 @@ export const login = (username: string, password: string) =>
|
|||||||
api.post('/api/cms/auth/login', { username, password })
|
api.post('/api/cms/auth/login', { username, password })
|
||||||
export const getMe = () => api.get('/api/cms/auth/me')
|
export const getMe = () => api.get('/api/cms/auth/me')
|
||||||
|
|
||||||
// ── 2FA / OTP / 계정 보안 (access 토큰 필요, 마이페이지) ────────────────────
|
|
||||||
// OTP 등록/재설정/해제 + 비밀번호 변경. 응답은 { success, message, data } 봉투(raw).
|
|
||||||
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 절대 금지.
|
|
||||||
export const otpSetup = () => api.post('/api/cms/auth/otp/setup')
|
|
||||||
export const otpConfirm = (code: string) => api.post('/api/cms/auth/otp/confirm', { code })
|
|
||||||
export const otpDisable = () => api.post('/api/cms/auth/otp/disable')
|
|
||||||
export const changePassword = (currentPassword: string, newPassword: string) =>
|
|
||||||
api.post('/api/cms/auth/change-password', { currentPassword, newPassword })
|
|
||||||
|
|
||||||
// ── Dashboard ─────────────────────────────────────────────────────────
|
// ── Dashboard ─────────────────────────────────────────────────────────
|
||||||
export const getDashboardSummary = () => unwrap(api.get('/api/cms/dashboard/summary'))
|
export const getDashboardSummary = () => unwrap(api.get('/api/cms/dashboard/summary'))
|
||||||
export const getDashboardWorkflow = () => unwrap(api.get('/api/cms/dashboard/workflow'))
|
export const getDashboardWorkflow = () => unwrap(api.get('/api/cms/dashboard/workflow'))
|
||||||
@ -197,16 +188,6 @@ export const aiReviewSummary = (reviews: string[]) => unwrap(api.post('/api/cms/
|
|||||||
export const aiSearchKeywords = (query: string) => unwrap(api.post('/api/cms/ai/search-keywords', { query }))
|
export const aiSearchKeywords = (query: string) => unwrap(api.post('/api/cms/ai/search-keywords', { query }))
|
||||||
export const aiModerate = (text: string) => unwrap(api.post('/api/cms/ai/moderate', { text }))
|
export const aiModerate = (text: string) => unwrap(api.post('/api/cms/ai/moderate', { text }))
|
||||||
|
|
||||||
// ── RAG (중앙 guardia-rag 최신 AI 기법 배선 — 별개 레이어, 폴백 degraded) ──
|
|
||||||
export const getRagToggles = () => unwrap(api.get('/api/cms/rag/toggles'))
|
|
||||||
export const updateRagToggle = (key: string, value: string) =>
|
|
||||||
unwrap(api.put(`/api/cms/rag/toggles/${encodeURIComponent(key)}`, { value }))
|
|
||||||
export const ragContentDraft = (kind: string, topic: string, tone: string) =>
|
|
||||||
unwrap(api.post('/api/cms/rag/content-draft', { kind, topic, tone }))
|
|
||||||
export const ragModerate = (text: string) => unwrap(api.post('/api/cms/rag/moderate', { text }))
|
|
||||||
export const ragFeedback = (d: { answerId?: string; query?: string; answer?: string; verdict: 'up' | 'down'; correction?: string }) =>
|
|
||||||
unwrap(api.post('/api/cms/rag/feedback', d))
|
|
||||||
|
|
||||||
// ── Integration ───────────────────────────────────────────────────────
|
// ── Integration ───────────────────────────────────────────────────────
|
||||||
export const getIntegrationStatus = () => unwrap(api.get('/api/cms/integration/status'))
|
export const getIntegrationStatus = () => unwrap(api.get('/api/cms/integration/status'))
|
||||||
export const getMallProducts = () => unwrap(api.get('/api/cms/integration/mall/products'))
|
export const getMallProducts = () => unwrap(api.get('/api/cms/integration/mall/products'))
|
||||||
@ -221,8 +202,6 @@ export const updateUserRole = (id: number, role: string) => api.put(`/api/admin/
|
|||||||
export const updateUserActive = (id: number, active: boolean) => api.put(`/api/admin/users/${id}/active`, { active })
|
export const updateUserActive = (id: number, active: boolean) => api.put(`/api/admin/users/${id}/active`, { active })
|
||||||
export const resetPassword = (id: number, password: string) => api.put(`/api/admin/users/${id}/password`, { password })
|
export const resetPassword = (id: number, password: string) => api.put(`/api/admin/users/${id}/password`, { password })
|
||||||
export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`)
|
export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`)
|
||||||
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
|
|
||||||
export const adminOtpReset = (id: number) => api.post(`/api/admin/users/${id}/otp-reset`)
|
|
||||||
|
|
||||||
export const getAuditLogs = (action = '', actor = '', limit = 100) =>
|
export const getAuditLogs = (action = '', actor = '', limit = 100) =>
|
||||||
api.get(`/api/admin/audit${qs({ action, actor, limit })}`)
|
api.get(`/api/admin/audit${qs({ action, actor, limit })}`)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { LogOut, UserCircle } from 'lucide-react'
|
import { LogOut, UserCircle } from 'lucide-react'
|
||||||
import { getAiStatus } from '../api/client'
|
import { getAiStatus } from '../api/client'
|
||||||
|
|
||||||
@ -30,10 +30,10 @@ export default function Header() {
|
|||||||
<span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} />
|
||||||
AI {ollama === null ? '확인 중' : ollama ? '온라인' : '폴백'}
|
AI {ollama === null ? '확인 중' : ollama ? '온라인' : '폴백'}
|
||||||
</span>
|
</span>
|
||||||
<Link to="/mypage" className="flex items-center gap-1.5 text-sm text-slate-300 hover:text-brand" title="마이페이지">
|
<span className="flex items-center gap-1.5 text-sm text-slate-300">
|
||||||
<UserCircle size={18} /> {user}
|
<UserCircle size={18} /> {user}
|
||||||
{role && <span className="text-[11px] text-slate-500">({role})</span>}
|
{role && <span className="text-[11px] text-slate-500">({role})</span>}
|
||||||
</Link>
|
</span>
|
||||||
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand">
|
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand">
|
||||||
<LogOut size={16} /> 로그아웃
|
<LogOut size={16} /> 로그아웃
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -4,11 +4,8 @@ import {
|
|||||||
LayoutDashboard, FileText, Image, Package, Megaphone, ListTree, Palette,
|
LayoutDashboard, FileText, Image, Package, Megaphone, ListTree, Palette,
|
||||||
Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound,
|
Languages, Search, FileInput, MessageSquareWarning, Share2, UsersRound,
|
||||||
Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack,
|
Sparkles, BarChart3, ShieldCheck, ScrollText, Settings, FileStack,
|
||||||
BookOpen, CalendarDays, Mail, Smartphone, Cpu,
|
|
||||||
KeyRound, ListChecks, Hash, SquareMenu, Network, Building2, BrainCircuit,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { currentRole, hasRole } from './rbac'
|
import { currentRole, hasRole } from './rbac'
|
||||||
import ThemeToggle from './uiws/ThemeToggle'
|
|
||||||
|
|
||||||
interface Link { to: string; label: string; icon: any; min?: string }
|
interface Link { to: string; label: string; icon: any; min?: string }
|
||||||
|
|
||||||
@ -32,33 +29,11 @@ const engageLinks: Link[] = [
|
|||||||
{ to: '/members', label: '회원', icon: UsersRound },
|
{ to: '/members', label: '회원', icon: UsersRound },
|
||||||
{ to: '/analytics', label: '콘텐츠 분석', icon: BarChart3 },
|
{ to: '/analytics', label: '콘텐츠 분석', icon: BarChart3 },
|
||||||
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
|
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles },
|
||||||
{ to: '/ai-techniques', label: 'AI 기법 (RAG)', icon: Cpu },
|
|
||||||
]
|
]
|
||||||
const adminLinks: Link[] = [
|
const adminLinks: Link[] = [
|
||||||
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
|
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
|
||||||
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'EDITOR' },
|
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'EDITOR' },
|
||||||
{ to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'EDITOR' },
|
{ to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'EDITOR' },
|
||||||
{ to: '/admin/ai-platform', label: 'AI 플랫폼', icon: Cpu, min: 'SUPERADMIN' },
|
|
||||||
]
|
|
||||||
// 업무(UIWS 이식) — 업무일지·일정·쪽지·통계 (인증 사용자 전체)
|
|
||||||
const uiwsLinks: Link[] = [
|
|
||||||
{ to: '/uiws/worklog', label: '업무일지', icon: BookOpen },
|
|
||||||
{ to: '/uiws/schedule', label: '일정', icon: CalendarDays },
|
|
||||||
{ to: '/uiws/message', label: '쪽지', icon: Mail },
|
|
||||||
{ to: '/uiws/stats', label: '업무통계', icon: BarChart3 },
|
|
||||||
]
|
|
||||||
// 시스템관리(권한) — UIWS RBAC/공통코드/메뉴/부서/거래처 (SUPERADMIN 전용)
|
|
||||||
const systemLinks: Link[] = [
|
|
||||||
{ to: '/uiws/system/roles', label: '권한 관리', icon: KeyRound, min: 'SUPERADMIN' },
|
|
||||||
{ to: '/uiws/system/role-menus', label: '역할-메뉴 권한', icon: ListChecks, min: 'SUPERADMIN' },
|
|
||||||
{ to: '/uiws/system/codes', label: '공통코드', icon: Hash, min: 'SUPERADMIN' },
|
|
||||||
{ to: '/uiws/system/menus', label: '메뉴 관리', icon: SquareMenu, min: 'SUPERADMIN' },
|
|
||||||
{ to: '/uiws/system/depts', label: '부서 관리', icon: Network, min: 'SUPERADMIN' },
|
|
||||||
{ to: '/uiws/system/companies', label: '거래처 관리', icon: Building2, min: 'SUPERADMIN' },
|
|
||||||
]
|
|
||||||
// 통합 메신저 앱(읽기전용 QR 설치) — 인증 사용자 전체
|
|
||||||
const appLinks: Link[] = [
|
|
||||||
{ to: '/admin/mobile-app', label: '모바일 앱 설치', icon: Smartphone },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
@ -97,7 +72,6 @@ export default function Sidebar() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role))
|
const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role))
|
||||||
const visibleSystem = systemLinks.filter(l => hasRole(l.min || 'VIEWER', role))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 bg-panel border-r border-edge flex flex-col">
|
<aside className="w-60 bg-panel border-r border-edge flex flex-col">
|
||||||
@ -109,37 +83,16 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 py-2 overflow-auto">
|
<nav className="flex-1 py-2 overflow-auto">
|
||||||
<NavLink to="/wise-ai" className={linkClass}>
|
|
||||||
<BrainCircuit size={17} />
|
|
||||||
<span className="flex flex-col leading-tight">
|
|
||||||
<span>WISE AI</span>
|
|
||||||
<span className="text-[10px] text-slate-500">Enterprise AI for Trusted Knowledge</span>
|
|
||||||
</span>
|
|
||||||
</NavLink>
|
|
||||||
<Section links={contentLinks} />
|
<Section links={contentLinks} />
|
||||||
<Section title="사이트" links={siteLinks} />
|
<Section title="사이트" links={siteLinks} />
|
||||||
<Section title="참여 · 분석" links={engageLinks} />
|
<Section title="참여 · 분석" links={engageLinks} />
|
||||||
<div className="border-t border-edge mt-3">
|
|
||||||
<Section title="업무(UIWS)" links={uiwsLinks} />
|
|
||||||
</div>
|
|
||||||
{visibleSystem.length > 0 && (
|
|
||||||
<div className="border-t border-edge mt-3">
|
|
||||||
<Section title="시스템관리(권한)" links={visibleSystem} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="border-t border-edge mt-3">
|
|
||||||
<Section title="모바일" links={appLinks} />
|
|
||||||
</div>
|
|
||||||
{visibleAdmin.length > 0 && (
|
{visibleAdmin.length > 0 && (
|
||||||
<div className="border-t border-edge mt-3">
|
<div className="border-t border-edge mt-3">
|
||||||
<Section title="관리자" links={visibleAdmin} />
|
<Section title="관리자" links={visibleAdmin} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="px-4 pt-3 border-t border-edge">
|
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
<div className="p-4 text-[11px] text-slate-500">
|
|
||||||
Ollama 온프레미스 · 헤드리스 CMS
|
Ollama 온프레미스 · 헤드리스 CMS
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@ -2,18 +2,9 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
// UIWS 이식: 모듈 스코프 테마 토큰(다크/라이트). 전역 CMS 다크 셸은 index.css 유지 — 덮어쓰지 않음.
|
|
||||||
import './theme/theme.css'
|
|
||||||
import { ThemeProvider } from './theme/ThemeContext'
|
|
||||||
|
|
||||||
// 초기 테마 적용(FOUC 방지) — ThemeProvider 마운트 전에 data-theme 세팅(uiws-scope 화면에만 영향)
|
|
||||||
const saved = localStorage.getItem('cms_theme')
|
|
||||||
document.documentElement.setAttribute('data-theme', saved === 'light' ? 'light' : 'dark')
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
|
||||||
<App />
|
<App />
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,44 +1,23 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ShieldCheck } from 'lucide-react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { login, getMe } from '../api/client'
|
import { login, getMe } from '../api/client'
|
||||||
import { verify2fa, verifyOtp } from '../api/uiws'
|
|
||||||
import AuthHelperModals, { type AuthHelperKind } from './AuthHelperModals'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CMS 로그인 — UIWS 2FA 레이어 + Authenticator(OTP) 확장.
|
|
||||||
* 1단계: 아이디/비밀번호 → /api/cms/auth/login.
|
|
||||||
* - twofa="false": 즉시 { token } 발급(기존 단일 로그인 회귀 0).
|
|
||||||
* - twofa="true": verifyMethod 로 2차 분기.
|
|
||||||
* · OTP : Authenticator 6자리 → /verify-otp
|
|
||||||
* · OTP_SETUP : QR(qrImage)+수동키(secret) 등록 후 6자리 → /verify-otp
|
|
||||||
* · EMAIL(기타): 이메일 인증코드 6자리 → /verify (하위호환)
|
|
||||||
* 2단계: verify-token + 코드 → access 발급 → getMe 로 역할 저장 → /dashboard.
|
|
||||||
* 보안: OTP secret/QR·인증코드는 화면 표시용만 — 로그/저장 절대 금지.
|
|
||||||
*/
|
|
||||||
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [step, setStep] = useState<'login' | 'verify'>('login')
|
|
||||||
const [username, setUsername] = useState('admin')
|
const [username, setUsername] = useState('admin')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [code, setCode] = useState('')
|
|
||||||
const [verifyToken, setVerifyToken] = useState('')
|
|
||||||
const [verifyMethod, setVerifyMethod] = useState<VerifyMethod>('EMAIL')
|
|
||||||
const [maskedEmail, setMaskedEmail] = useState('')
|
|
||||||
const [qrImage, setQrImage] = useState('') // OTP_SETUP 등록 순간만 존재
|
|
||||||
const [secret, setSecret] = useState('') // OTP_SETUP 등록 순간만 존재
|
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [helper, setHelper] = useState<AuthHelperKind | null>(null)
|
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
|
|
||||||
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
|
const submit = async (e: React.FormEvent) => {
|
||||||
const isSetup = verifyMethod === 'OTP_SETUP'
|
e.preventDefault()
|
||||||
|
setErr(''); setBusy(true)
|
||||||
// 토큰 저장 + 역할 선조회 후 대시보드 진입(공통)
|
try {
|
||||||
const finishLogin = async (token: string) => {
|
const res = await login(username, password)
|
||||||
|
const token = res.data?.data?.token
|
||||||
|
if (!token) throw new Error('no token')
|
||||||
localStorage.setItem('cms_token', token)
|
localStorage.setItem('cms_token', token)
|
||||||
|
// 역할 선조회 (RBAC 메뉴 즉시 반영)
|
||||||
try {
|
try {
|
||||||
const me = await getMe()
|
const me = await getMe()
|
||||||
const d = me.data?.data || {}
|
const d = me.data?.data || {}
|
||||||
@ -46,27 +25,6 @@ export default function Login() {
|
|||||||
if (d.username) localStorage.setItem('cms_user', d.username)
|
if (d.username) localStorage.setItem('cms_user', d.username)
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
nav('/dashboard')
|
nav('/dashboard')
|
||||||
}
|
|
||||||
|
|
||||||
const submitLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setErr(''); setBusy(true)
|
|
||||||
try {
|
|
||||||
const res = await login(username, password)
|
|
||||||
const d = res.data?.data || {}
|
|
||||||
if (d.twofa === 'true') {
|
|
||||||
setVerifyToken(d.verifyToken || '')
|
|
||||||
setVerifyMethod((d.verifyMethod as VerifyMethod) || 'EMAIL')
|
|
||||||
setMaskedEmail(d.maskedEmail || '')
|
|
||||||
setQrImage(d.qrImage || '')
|
|
||||||
setSecret(d.secret || '')
|
|
||||||
setCode('')
|
|
||||||
setStep('verify')
|
|
||||||
} else {
|
|
||||||
const token = d.token
|
|
||||||
if (!token) throw new Error('no token')
|
|
||||||
await finishLogin(token)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
|
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
|
||||||
} finally {
|
} finally {
|
||||||
@ -74,119 +32,26 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitVerify = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setErr(''); setBusy(true)
|
|
||||||
try {
|
|
||||||
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
|
|
||||||
const res = isOtp
|
|
||||||
? await verifyOtp(verifyToken, code.trim())
|
|
||||||
: await verify2fa(verifyToken, code.trim())
|
|
||||||
const token = res.data?.data?.token
|
|
||||||
if (!token) throw new Error('no token')
|
|
||||||
await finishLogin(token)
|
|
||||||
} catch {
|
|
||||||
setErr('인증 실패 — 코드가 올바르지 않거나 만료되었습니다.')
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const backToLogin = () => {
|
|
||||||
setStep('login'); setCode(''); setErr('')
|
|
||||||
setQrImage(''); setSecret('') // 시크릿 잔존 방지
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-ink">
|
<div className="min-h-screen flex items-center justify-center bg-ink">
|
||||||
<form onSubmit={step === 'login' ? submitLogin : submitVerify}
|
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
|
||||||
className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
|
|
||||||
<div className="flex items-center gap-2 justify-center mb-6">
|
<div className="flex items-center gap-2 justify-center mb-6">
|
||||||
<img src="/login.png" alt="GUARDiA" style={{ height: 36, width: 'auto' }}
|
<img src="/login.png" alt="GUARDiA" style={{ height: 36, width: 'auto' }} onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
|
||||||
<span className="text-xl font-bold">GUARDiA CMS</span>
|
<span className="text-xl font-bold">GUARDiA CMS</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step === 'login' ? (
|
|
||||||
<>
|
|
||||||
<p className="text-center text-sm text-slate-400 mb-6">AI 콘텐츠 관리 시스템</p>
|
<p className="text-center text-sm text-slate-400 mb-6">AI 콘텐츠 관리 시스템</p>
|
||||||
<label className="block text-xs text-slate-400 mb-1">아이디</label>
|
<label className="block text-xs text-slate-400 mb-1">아이디</label>
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} autoComplete="username"
|
<input value={username} onChange={e => setUsername(e.target.value)}
|
||||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
||||||
<label className="block text-xs text-slate-400 mb-1">비밀번호</label>
|
<label className="block text-xs text-slate-400 mb-1">비밀번호</label>
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="current-password"
|
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
||||||
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
|
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
|
||||||
<button disabled={busy} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
|
<button disabled={busy} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
|
||||||
{busy ? '로그인 중…' : '로그인'}
|
{busy ? '로그인 중…' : '로그인'}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-center text-[11px] text-slate-500 mt-4">admin / editor / author · admin123</p>
|
<p className="text-center text-[11px] text-slate-500 mt-4">admin / editor / author · admin123</p>
|
||||||
{/* 로그인 보조 3링크(UIWS auth 패턴 이식) */}
|
|
||||||
<div className="flex items-center justify-center gap-2 text-[11px] text-slate-400 mt-3">
|
|
||||||
<button type="button" onClick={() => setHelper('signup')} className="hover:text-brand">회원가입</button>
|
|
||||||
<span className="text-slate-600">|</span>
|
|
||||||
<button type="button" onClick={() => setHelper('findId')} className="hover:text-brand">아이디 찾기</button>
|
|
||||||
<span className="text-slate-600">|</span>
|
|
||||||
<button type="button" onClick={() => setHelper('reset')} className="hover:text-brand">비밀번호 초기화</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2 justify-center mb-4">
|
|
||||||
<ShieldCheck size={20} className="text-brand" />
|
|
||||||
<span className="text-lg font-bold">2차 인증</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOtp ? (
|
|
||||||
isSetup ? (
|
|
||||||
<>
|
|
||||||
<p className="text-center text-sm text-slate-400 mb-3">
|
|
||||||
최초 로그인입니다. 아래 QR을 Authenticator 앱(Google·Microsoft)으로 스캔해
|
|
||||||
등록한 뒤 6자리 코드를 입력하세요.
|
|
||||||
</p>
|
|
||||||
{qrImage && (
|
|
||||||
<div className="flex justify-center mb-3">
|
|
||||||
<img src={qrImage} alt="OTP QR" width={180} height={180}
|
|
||||||
style={{ background: '#fff', padding: 8, borderRadius: 8 }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{secret && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="text-[11px] text-slate-500 mb-1">QR 스캔이 안 되면 수동 입력 키</div>
|
|
||||||
<code className="block text-xs text-accent bg-ink border border-edge rounded-md px-2 py-1.5 break-all select-all">
|
|
||||||
{secret}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-sm text-slate-400 mb-6">
|
|
||||||
Authenticator 앱에 표시된 6자리 코드를 입력하세요.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-xs text-slate-400 mb-4">
|
|
||||||
이메일로 발송된 6자리 인증 코드를 입력하세요{maskedEmail ? ` (${maskedEmail})` : ''}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="block text-xs text-slate-400 mb-1">인증 코드</label>
|
|
||||||
<input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
||||||
inputMode="numeric" autoComplete="one-time-code" maxLength={6} autoFocus placeholder="000000"
|
|
||||||
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm tracking-[0.4em] text-center focus:border-brand outline-none" />
|
|
||||||
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
|
|
||||||
<button disabled={busy || code.length !== 6}
|
|
||||||
className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
|
|
||||||
{busy ? '인증 중…' : isSetup ? '등록하고 로그인' : '인증 확인'}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={backToLogin}
|
|
||||||
className="w-full py-2 mt-2 rounded-lg text-slate-400 text-xs hover:text-slate-200">
|
|
||||||
← 로그인으로 돌아가기
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
<AuthHelperModals kind={helper} onClose={() => setHelper(null)} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Plus, Trash2, KeyRound, Power, ShieldCheck, RefreshCw } from 'lucide-react'
|
import { Plus, Trash2, KeyRound, Power, ShieldCheck } from 'lucide-react'
|
||||||
import { CMS_ROLES } from '../components/rbac'
|
import { CMS_ROLES } from '../components/rbac'
|
||||||
import {
|
import {
|
||||||
getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser,
|
getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser,
|
||||||
adminOtpReset,
|
|
||||||
} from '../api/client'
|
} from '../api/client'
|
||||||
|
|
||||||
interface User { id: number; username: string; displayName?: string; role: string; active: boolean; createdAt: string | null }
|
interface User { id: number; username: string; displayName?: string; role: string; active: boolean; createdAt: string | null }
|
||||||
@ -36,10 +35,6 @@ export default function UserManagement() {
|
|||||||
const changeRole = (u: User, role: string) => wrap(() => updateUserRole(u.id, role))
|
const changeRole = (u: User, role: string) => wrap(() => updateUserRole(u.id, role))
|
||||||
const toggleActive = (u: User) => wrap(() => updateUserActive(u.id, !u.active))
|
const toggleActive = (u: User) => wrap(() => updateUserActive(u.id, !u.active))
|
||||||
const doReset = (u: User) => { const pw = window.prompt(`'${u.username}' 의 새 비밀번호`); if (pw) wrap(() => resetPassword(u.id, pw)) }
|
const doReset = (u: User) => { const pw = window.prompt(`'${u.username}' 의 새 비밀번호`); if (pw) wrap(() => resetPassword(u.id, pw)) }
|
||||||
const otpReset = (u: User) => {
|
|
||||||
if (!window.confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`)) return
|
|
||||||
wrap(() => adminOtpReset(u.id))
|
|
||||||
}
|
|
||||||
const remove = (u: User) => { if (window.confirm(`'${u.username}' 삭제?`)) wrap(() => deleteUser(u.id)) }
|
const remove = (u: User) => { if (window.confirm(`'${u.username}' 삭제?`)) wrap(() => deleteUser(u.id)) }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -86,7 +81,6 @@ export default function UserManagement() {
|
|||||||
<td className="px-4"><div className="flex items-center justify-end gap-3">
|
<td className="px-4"><div className="flex items-center justify-end gap-3">
|
||||||
<button onClick={() => toggleActive(u)} title={u.active ? '비활성화' : '활성화'} className="text-slate-400 hover:text-brand"><Power size={16} /></button>
|
<button onClick={() => toggleActive(u)} title={u.active ? '비활성화' : '활성화'} className="text-slate-400 hover:text-brand"><Power size={16} /></button>
|
||||||
<button onClick={() => doReset(u)} title="비밀번호 재설정" className="text-slate-400 hover:text-brand"><KeyRound size={16} /></button>
|
<button onClick={() => doReset(u)} title="비밀번호 재설정" className="text-slate-400 hover:text-brand"><KeyRound size={16} /></button>
|
||||||
<button onClick={() => otpReset(u)} title="OTP 초기화" className="text-slate-400 hover:text-brand"><RefreshCw size={16} /></button>
|
|
||||||
<button onClick={() => remove(u)} title="삭제" className="text-rose-400 hover:text-rose-300"><Trash2 size={16} /></button>
|
<button onClick={() => remove(u)} title="삭제" className="text-rose-400 hover:text-rose-300"><Trash2 size={16} /></button>
|
||||||
</div></td>
|
</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -84,16 +84,22 @@ export default function ScheduleCalendar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// datetime-local(yyyy-MM-ddTHH:mm) → API 계약(yyyy-MM-ddTHH:mm:ss) 보정: 초 없으면 :00 부가
|
||||||
|
function toApiDt(v: string): string {
|
||||||
|
if (!v) return v
|
||||||
|
return v.length === 16 ? `${v}:00` : v
|
||||||
|
}
|
||||||
|
|
||||||
function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: string; onClose: () => void; onSaved: () => void }) {
|
function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: string; onClose: () => void; onSaved: () => void }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [startDt, setStartDt] = useState(`${date}T09:00:00`)
|
const [startDt, setStartDt] = useState(`${date}T09:00`)
|
||||||
const [endDt, setEndDt] = useState(`${date}T18:00:00`)
|
const [endDt, setEndDt] = useState(`${date}T18:00`)
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setErr('')
|
setErr('')
|
||||||
try {
|
try {
|
||||||
await createSchedule({ scheType: type, title, startDt, endDt, content, attachmentIds: [] })
|
await createSchedule({ scheType: type, title, startDt: toApiDt(startDt), endDt: toApiDt(endDt), content, attachmentIds: [] })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') }
|
} catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') }
|
||||||
}
|
}
|
||||||
@ -101,8 +107,8 @@ function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: st
|
|||||||
<Modal title="일정 등록" onClose={onClose}
|
<Modal title="일정 등록" onClose={onClose}
|
||||||
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
|
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
|
||||||
<FormField label="시작(yyyy-MM-ddTHH:mm:ss)"><Input value={startDt} onChange={setStartDt} style={{ width: '100%' }} /></FormField>
|
<FormField label="시작"><Input type="datetime-local" value={startDt} onChange={setStartDt} style={{ width: '100%' }} /></FormField>
|
||||||
<FormField label="종료"><Input value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
|
<FormField label="종료"><Input type="datetime-local" value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
|
||||||
<FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
|
<FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
|
||||||
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user