chore(deploy): HRM 클린 재동기화 + Claude provider flip
- workspace→repos 전체 소스 클린 재동기화(부분 auto-sync 컴파일불가 해소) - db/202_ai_provider_claude.sql: hrm_settings ai.provider UPSERT claude(멱등, 104 시드 이후) - application.yml schema-locations 에 202 등재(mode=always) - 키 미설정/실패 시 AiTextRouter 가 Ollama 자동 폴백(무중단·무회귀) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4e9436edbe
commit
94705cf941
@ -42,11 +42,17 @@
|
|||||||
<!-- 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>
|
||||||
|
|
||||||
|
<!-- DuckDB — 로컬 임베디드 AI 학습 저장소(ai_feedback·ai_infer_log). 파일: /opt/guardia-hrm/data/hrm_learning.duckdb -->
|
||||||
|
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</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>
|
||||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></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>
|
||||||
|
|
||||||
<!-- OpenAPI -->
|
<!-- OpenAPI -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
|
|||||||
@ -51,6 +51,31 @@ public class AdminController {
|
|||||||
return ApiResponse.ok(null);
|
return ApiResponse.ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 OTP 초기화(SUPERADMIN). 대상 사용자의 OTP 시크릿을 폐기하고 등록을 해제한다.
|
||||||
|
* 사용자는 다음 로그인 시 OTP_SETUP(QR 재등록) 플로우를 탄다. 시크릿은 응답/로그에 노출하지 않는다.
|
||||||
|
*/
|
||||||
|
@PostMapping("/users/{id}/otp-reset")
|
||||||
|
public ApiResponse<HrmUser> resetOtp(@PathVariable Long id, Authentication auth) {
|
||||||
|
HrmUser user = adminMapper.findUserById(id);
|
||||||
|
if (user == null) {
|
||||||
|
throw new RuntimeException("ERR-HRM-404: 대상 사용자를 찾을 수 없습니다");
|
||||||
|
}
|
||||||
|
adminMapper.clearOtp(id);
|
||||||
|
|
||||||
|
Map<String, Object> log = new HashMap<>();
|
||||||
|
log.put("actor", AuthSupport.actor(auth));
|
||||||
|
log.put("action", "USER_OTP_RESET");
|
||||||
|
log.put("targetType", "auth.otp");
|
||||||
|
log.put("targetId", user.getUsername());
|
||||||
|
log.put("detail", "OTP 초기화(다음 로그인 재등록)");
|
||||||
|
log.put("ipAddr", null);
|
||||||
|
adminMapper.insertAuditLog(log);
|
||||||
|
|
||||||
|
user.setPasswordHash(null); // 비밀번호 해시 미노출
|
||||||
|
return ApiResponse.ok(user);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/audit")
|
@GetMapping("/audit")
|
||||||
public ApiResponse<Map<String, Object>> audit(
|
public ApiResponse<Map<String, Object>> audit(
|
||||||
@RequestParam(required = false) String actor,
|
@RequestParam(required = false) String actor,
|
||||||
|
|||||||
@ -14,6 +14,8 @@ public interface AdminMapper {
|
|||||||
int insertUser(HrmUser user);
|
int insertUser(HrmUser user);
|
||||||
int updateUser(HrmUser user);
|
int updateUser(HrmUser user);
|
||||||
int updateUserActive(@Param("id") Long id, @Param("active") boolean active);
|
int updateUserActive(@Param("id") Long id, @Param("active") boolean active);
|
||||||
|
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false(다음 로그인 시 재등록 유도). 시크릿 미노출. */
|
||||||
|
int clearOtp(@Param("id") Long id);
|
||||||
List<Map<String, Object>> findAuditLogs(@Param("actor") String actor,
|
List<Map<String, Object>> findAuditLogs(@Param("actor") String actor,
|
||||||
@Param("action") String action,
|
@Param("action") String action,
|
||||||
@Param("offset") int offset,
|
@Param("offset") int offset,
|
||||||
|
|||||||
@ -1,37 +1,27 @@
|
|||||||
package com.zioinfo.hrm.ai;
|
package com.zioinfo.hrm.ai;
|
||||||
|
|
||||||
|
import com.zioinfo.hrm.ai.service.AiTextRouter;
|
||||||
|
import com.zioinfo.hrm.common.ai.TextAiClient.GenResult;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ollama 온프레미스 AI 인사 분석 서비스.
|
* AI 인사 분석 서비스. 텍스트 생성은 {@link AiTextRouter} 경유(provider 선택: Claude ↔ Ollama).
|
||||||
* 외부 AI API 절대 금지 — localhost:11434 만 사용.
|
* 외부 AI API 는 소유자 승인 예외인 Claude(api.anthropic.com) 만 허용, 그 외 온프레미스 Ollama 전용.
|
||||||
* Ollama 응답 실패 시 Java 폴백 로직으로 대체.
|
* Claude/Ollama 응답 실패 시 Java 폴백 로직으로 대체(무회귀).
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AiService {
|
public class AiService {
|
||||||
|
|
||||||
@Value("${guardia.ollama-url:http://localhost:11434}")
|
/** provider 라우터(Claude→Ollama 폴백 + 추론 로그). 기존 Ollama 직접호출을 대체. */
|
||||||
private String ollamaUrl;
|
private final AiTextRouter aiTextRouter;
|
||||||
|
|
||||||
@Value("${guardia.ollama-text-model:llama3}")
|
|
||||||
private String textModel;
|
|
||||||
|
|
||||||
private WebClient ollamaClient() {
|
|
||||||
return WebClient.builder().baseUrl(ollamaUrl)
|
|
||||||
.codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, Object> predictTurnover(Long empId) {
|
public Map<String, Object> predictTurnover(Long empId) {
|
||||||
try {
|
try {
|
||||||
@ -117,16 +107,12 @@ public class AiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> callOllama(String prompt) {
|
private Map<String, Object> callOllama(String prompt) {
|
||||||
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
|
// provider 라우터 경유: provider=claude·키설정·활성 → Claude, 실패/그외 → Ollama(선택모델) 폴백.
|
||||||
@SuppressWarnings("unchecked")
|
GenResult gr = aiTextRouter.generate(prompt);
|
||||||
Map<String, Object> resp = ollamaClient().post().uri("/api/generate")
|
if (gr.degraded() || gr.text() == null || gr.text().isBlank()) {
|
||||||
.bodyValue(body)
|
return fallbackTurnover();
|
||||||
.retrieve()
|
}
|
||||||
.bodyToMono(Map.class)
|
String response = gr.text();
|
||||||
.timeout(Duration.ofSeconds(30))
|
|
||||||
.block();
|
|
||||||
if (resp == null) return fallbackTurnover();
|
|
||||||
String response = (String) resp.getOrDefault("response", "{}");
|
|
||||||
// JSON 추출
|
// JSON 추출
|
||||||
int start = response.indexOf('{');
|
int start = response.indexOf('{');
|
||||||
int end = response.lastIndexOf('}');
|
int end = response.lastIndexOf('}');
|
||||||
@ -134,10 +120,10 @@ public class AiService {
|
|||||||
// 간단 파싱: 실제로는 Jackson 사용
|
// 간단 파싱: 실제로는 Jackson 사용
|
||||||
Map<String, Object> r = new HashMap<>();
|
Map<String, Object> r = new HashMap<>();
|
||||||
r.put("raw", response.substring(start, end + 1));
|
r.put("raw", response.substring(start, end + 1));
|
||||||
r.put("source", "ollama");
|
r.put("source", "ai");
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
return Map.of("response", response, "source", "ollama");
|
return Map.of("response", response, "source", "ai");
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> fallbackTurnover() {
|
private Map<String, Object> fallbackTurnover() {
|
||||||
|
|||||||
@ -1,22 +1,54 @@
|
|||||||
package com.zioinfo.hrm.auth;
|
package com.zioinfo.hrm.auth;
|
||||||
|
|
||||||
|
import com.zioinfo.hrm.auth.dto.ChangePasswordRequest;
|
||||||
|
import com.zioinfo.hrm.auth.dto.OtpConfirmRequest;
|
||||||
|
import com.zioinfo.hrm.auth.dto.OtpSetupResponse;
|
||||||
|
import com.zioinfo.hrm.auth.dto.OtpVerifyRequest;
|
||||||
import com.zioinfo.hrm.common.ApiResponse;
|
import com.zioinfo.hrm.common.ApiResponse;
|
||||||
|
import com.zioinfo.hrm.uiws.auth.OtpAuthService;
|
||||||
|
import com.zioinfo.hrm.uiws.auth.TwoFactorService;
|
||||||
|
import com.zioinfo.hrm.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.hrm.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HRM 인증 컨트롤러.
|
||||||
|
* - /login: 2FA off 면 { token, type, twofa:"false" }.
|
||||||
|
* OTP on 이면 { twofa:"true", verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }.
|
||||||
|
* 이메일 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
|
||||||
|
* - /verify-otp: (UIMS 방식) verify-token + 6자리 → access/refresh(최초 로그인이면 등록 확정).
|
||||||
|
* - /verify: (이메일 2FA) verify-token + 인증코드 → access/refresh.
|
||||||
|
* 기존 클라이언트(2FA off)는 응답에 token 보존 → 회귀 0.
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/hrm/auth")
|
@RequestMapping("/api/hrm/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) {
|
||||||
String token = authService.login(req.username(), req.password());
|
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
||||||
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
}
|
||||||
|
|
||||||
|
/** 이메일 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")
|
||||||
@ -25,5 +57,50 @@ public class AuthController {
|
|||||||
return ApiResponse.ok(authService.me(token));
|
return ApiResponse.ok(authService.me(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
record LoginRequest(String username, String password) {}
|
// ── 마이페이지: 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/hrm/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 VerifyRequest(String verifyToken, String code) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,35 @@
|
|||||||
package com.zioinfo.hrm.auth;
|
package com.zioinfo.hrm.auth;
|
||||||
|
|
||||||
|
import com.zioinfo.hrm.auth.dto.FindIdRequest;
|
||||||
|
import com.zioinfo.hrm.auth.dto.FindIdResponse;
|
||||||
|
import com.zioinfo.hrm.auth.dto.ResetPwRequest;
|
||||||
|
import com.zioinfo.hrm.auth.dto.SignupRequest;
|
||||||
|
import com.zioinfo.hrm.auth.dto.SignupResponse;
|
||||||
|
import com.zioinfo.hrm.auth.dto.ChangePasswordRequest;
|
||||||
import com.zioinfo.hrm.auth.mapper.UserMapper;
|
import com.zioinfo.hrm.auth.mapper.UserMapper;
|
||||||
|
import com.zioinfo.hrm.uiws.auth.OtpAuthService;
|
||||||
|
import com.zioinfo.hrm.uiws.auth.TwoFactorService;
|
||||||
|
import com.zioinfo.hrm.uiws.common.UiwsApiException;
|
||||||
|
import com.zioinfo.hrm.uiws.common.UiwsErrorCode;
|
||||||
|
import com.zioinfo.hrm.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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HRM 인증 서비스.
|
||||||
|
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 시 회귀 0).
|
||||||
|
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 후 verify-token + 이메일코드 발급.
|
||||||
|
* 실패 누적 max-login-fail 회 시 계정 잠금.
|
||||||
|
* - UIWS 로그인 보조 3종(회원가입·아이디찾기·비밀번호 초기화) 이식.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
@ -15,16 +37,66 @@ public class AuthService {
|
|||||||
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;
|
||||||
|
|
||||||
public String login(String username, String password) {
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
// 혼동 문자(0/O/1/l/I) 제외 — 임시비번 가독성.
|
||||||
|
private static final String TMP_PW_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1차 로그인. 2FA 활성 시 verify-token + 이메일코드 흐름으로 분기,
|
||||||
|
* 비활성 시 기존처럼 access 토큰 즉시 발급.
|
||||||
|
*
|
||||||
|
* @return 2FA off: { token, type, twofa:"false" }
|
||||||
|
* 2FA on : { verifyToken, step:"EMAIL", maskedEmail, twofa:"true" }
|
||||||
|
*/
|
||||||
|
public Map<String, String> login(String username, String password) {
|
||||||
HrmUser user = userMapper.findByUsername(username);
|
HrmUser 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: 존재하지 않거나 비활성 계정");
|
||||||
}
|
}
|
||||||
|
// 회원가입 승인 게이트(UIWS 로그인 보조 이식): 가입 신청자(approved=false)는 관리자 승인 전 로그인 차단.
|
||||||
|
// 기존 계정은 approved=true(91_uiws_port.sql 멱등 보정)라 회귀 없음.
|
||||||
|
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);
|
||||||
|
HrmUser 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) {
|
||||||
@ -37,4 +109,122 @@ public class AuthService {
|
|||||||
m.put("displayName", u != null ? u.getDisplayName() : username);
|
m.put("displayName", u != null ? u.getDisplayName() : username);
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIMS changePassword 미러.
|
||||||
|
* 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD.
|
||||||
|
* 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void changePassword(String username, ChangePasswordRequest req) {
|
||||||
|
HrmUser 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.updatePasswordByUsername(username, passwordEncoder.encode(req.newPassword()));
|
||||||
|
log.info("[auth] password changed: username={}", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 로그인 보조 3종 (UIWS auth 패턴 이식) ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영자 회원가입(승인 대기 INSERT). username/email 중복 차단, 비번 BCrypt, approved=false.
|
||||||
|
* 보안: 응답에 자격증명·임시정보 미포함, 일반 메시지만.
|
||||||
|
*/
|
||||||
|
public SignupResponse 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 SignupResponse(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
|
||||||
|
}
|
||||||
|
if (userMapper.countByUsername(req.username()) > 0) {
|
||||||
|
return new SignupResponse(false, "이미 사용 중인 아이디입니다.");
|
||||||
|
}
|
||||||
|
if (userMapper.countByEmail(req.email()) > 0) {
|
||||||
|
return new SignupResponse(false, "이미 등록된 이메일입니다.");
|
||||||
|
}
|
||||||
|
HrmUser u = new HrmUser();
|
||||||
|
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());
|
||||||
|
// insertSignup: role='VIEWER', approved=false, locked=false, login_fail_count=0 (XML 고정)
|
||||||
|
userMapper.insertSignup(u);
|
||||||
|
log.info("[auth-helper] signup pending approval: username={}", req.username());
|
||||||
|
return new SignupResponse(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, "");
|
||||||
|
}
|
||||||
|
HrmUser 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 SignupResponse resetPassword(ResetPwRequest req) {
|
||||||
|
final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요.";
|
||||||
|
if (req.username() == null || req.username().isBlank()
|
||||||
|
|| req.email() == null || req.email().isBlank()) {
|
||||||
|
return new SignupResponse(false, "아이디와 이메일을 모두 입력하세요.");
|
||||||
|
}
|
||||||
|
HrmUser u = userMapper.findByUsernameAndEmail(req.username(), req.email());
|
||||||
|
if (u == null) {
|
||||||
|
// 존재 여부 누설 방지 — 동일 성공 메시지 반환(실제 발송 없음).
|
||||||
|
log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username());
|
||||||
|
return new SignupResponse(true, okMsg);
|
||||||
|
}
|
||||||
|
String tempPw = generateTempPassword();
|
||||||
|
// applyTempPassword: BCrypt 저장 + pw_change_yn=true + locked=false + login_fail_count=0 (멱등)
|
||||||
|
userMapper.applyTempPassword(req.username(), passwordEncoder.encode(tempPw));
|
||||||
|
|
||||||
|
String subject = "[GUARDiA HRM] 임시 비밀번호 안내";
|
||||||
|
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 SignupResponse(true, okMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,4 +17,20 @@ public class HrmUser {
|
|||||||
private String role;
|
private String role;
|
||||||
private boolean active;
|
private boolean active;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
// UIWS 2FA 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql).
|
||||||
|
private String emailVerifyCode;
|
||||||
|
private LocalDateTime emailVerifyExpire;
|
||||||
|
private Integer loginFailCount;
|
||||||
|
private Boolean locked;
|
||||||
|
/** TOTP 시크릿(보류/확정 공용). API 응답·로그에 절대 미노출. (db/91_uiws_port.sql ALTER) */
|
||||||
|
private String otpSecret;
|
||||||
|
/** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). (db/93_auth_otp.sql ALTER) */
|
||||||
|
private Boolean otpEnabled;
|
||||||
|
|
||||||
|
// UIWS 로그인 보조기능 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql).
|
||||||
|
// approved: 가입 신청자는 false(관리자 승인 전 로그인 차단), 기존 계정은 true 보정.
|
||||||
|
// pwChangeYn: 임시비번 발급 시 true(다음 로그인 시 변경 유도).
|
||||||
|
private Boolean approved;
|
||||||
|
private Boolean pwChangeYn;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,10 @@ 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);
|
||||||
if (jwtUtil.isValid(token)) {
|
// 보안(UIWS 2FA): purpose=2fa verify-token 은 access 토큰이 아니다.
|
||||||
|
// 동일 서명키라 isValid()는 통과하므로 별도 차단하지 않으면 2차 인증 전 보호 API 접근(2FA 우회)이 가능.
|
||||||
|
// → verify-token 은 인증 컨텍스트를 세우지 않고 무시한다(/api/hrm/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,6 +34,47 @@ 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,123 @@ package com.zioinfo.hrm.auth.mapper;
|
|||||||
import com.zioinfo.hrm.auth.HrmUser;
|
import com.zioinfo.hrm.auth.HrmUser;
|
||||||
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.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HRM 운영자 계정 매퍼.
|
||||||
|
* 기존 메서드(findByUsername/insert)에 UIWS 2FA·로그인 보조기능 이식 메서드를 추가.
|
||||||
|
* 대상 테이블 hrm_users (컬럼 active — pms_user.is_active 와 다름).
|
||||||
|
*/
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface UserMapper {
|
public interface UserMapper {
|
||||||
|
|
||||||
HrmUser findByUsername(@Param("username") String username);
|
HrmUser findByUsername(@Param("username") String username);
|
||||||
|
|
||||||
int insert(HrmUser user);
|
int insert(HrmUser user);
|
||||||
|
|
||||||
|
/** 회원가입(승인 대기): username·password_hash·display_name·email + role='VIEWER'·approved=false. */
|
||||||
|
int insertSignup(HrmUser user);
|
||||||
|
|
||||||
|
// ── UIWS 로그인 보조기능 이식(회원가입·아이디찾기·비번초기화) ───────────────
|
||||||
|
|
||||||
|
/** 가입 중복 검사: username 존재 여부. */
|
||||||
|
@Select("SELECT COUNT(1) FROM hrm_users WHERE username = #{username}")
|
||||||
|
int countByUsername(@Param("username") String username);
|
||||||
|
|
||||||
|
/** 가입 중복 검사: email 존재 여부(enumeration 최소화 — 내부 사용). */
|
||||||
|
@Select("SELECT COUNT(1) FROM hrm_users WHERE email = #{email}")
|
||||||
|
int countByEmail(@Param("email") String email);
|
||||||
|
|
||||||
|
/** 아이디찾기: 이름(display_name)·이메일 일치 사용자(존재 시 마스킹 반환). */
|
||||||
|
@Select("""
|
||||||
|
SELECT id, username, password_hash, role, active, created_at,
|
||||||
|
display_name, email,
|
||||||
|
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret,
|
||||||
|
approved, pw_change_yn
|
||||||
|
FROM hrm_users
|
||||||
|
WHERE display_name = #{displayName} AND email = #{email}
|
||||||
|
ORDER BY id LIMIT 1
|
||||||
|
""")
|
||||||
|
HrmUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
|
||||||
|
@Param("email") String email);
|
||||||
|
|
||||||
|
/** 비번초기화: username·email 동시 일치 검증용. */
|
||||||
|
@Select("""
|
||||||
|
SELECT id, username, password_hash, role, active, created_at,
|
||||||
|
display_name, email,
|
||||||
|
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret,
|
||||||
|
approved, pw_change_yn
|
||||||
|
FROM hrm_users
|
||||||
|
WHERE username = #{username} AND email = #{email}
|
||||||
|
""")
|
||||||
|
HrmUser findByUsernameAndEmail(@Param("username") String username,
|
||||||
|
@Param("email") String email);
|
||||||
|
|
||||||
|
/** 비번초기화: 임시비번 적용 + 변경유도 + 잠금/실패카운트 해제(멱등 UPDATE). */
|
||||||
|
@Update("""
|
||||||
|
UPDATE hrm_users
|
||||||
|
SET password_hash = #{passwordHash}, pw_change_yn = true,
|
||||||
|
locked = false, login_fail_count = 0
|
||||||
|
WHERE username = #{username}
|
||||||
|
""")
|
||||||
|
int applyTempPassword(@Param("username") String username,
|
||||||
|
@Param("passwordHash") String passwordHash);
|
||||||
|
|
||||||
|
// ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ───────────────
|
||||||
|
|
||||||
|
/** 로그인 성공 시 실패 카운트 초기화. */
|
||||||
|
@Update("UPDATE hrm_users SET login_fail_count = 0 WHERE username = #{username}")
|
||||||
|
int resetLoginFail(@Param("username") String username);
|
||||||
|
|
||||||
|
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
|
||||||
|
@Update("""
|
||||||
|
UPDATE hrm_users
|
||||||
|
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
|
||||||
|
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
|
||||||
|
WHERE username = #{username}
|
||||||
|
""")
|
||||||
|
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
|
||||||
|
|
||||||
|
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
|
||||||
|
@Update("""
|
||||||
|
UPDATE hrm_users
|
||||||
|
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
|
||||||
|
WHERE username = #{username}
|
||||||
|
""")
|
||||||
|
int saveEmailCode(@Param("username") String username,
|
||||||
|
@Param("code") String code,
|
||||||
|
@Param("expire") LocalDateTime expire);
|
||||||
|
|
||||||
|
/** 2차 검증 성공 시 코드 폐기. */
|
||||||
|
@Update("UPDATE hrm_users SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}")
|
||||||
|
int clearEmailCode(@Param("username") String username);
|
||||||
|
|
||||||
|
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
|
||||||
|
@Update("UPDATE hrm_users SET locked = false, login_fail_count = 0 WHERE username = #{username}")
|
||||||
|
int unlock(@Param("username") String username);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCrypt 비밀번호 해시 갱신(username 기준). 평문은 절대 저장하지 않는다.
|
||||||
|
* 마이페이지 비밀번호 변경 + AdminPasswordSeeder(env 재시드) 공용.
|
||||||
|
*/
|
||||||
|
@Update("UPDATE hrm_users SET password_hash = #{passwordHash} WHERE username = #{username}")
|
||||||
|
int updatePasswordByUsername(@Param("username") String username,
|
||||||
|
@Param("passwordHash") String passwordHash);
|
||||||
|
|
||||||
|
// ── TOTP(OTP 2차 인증) 이식 (멱등 UPDATE) ────────────────────────────────────
|
||||||
|
|
||||||
|
/** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
|
||||||
|
@Update("UPDATE hrm_users SET otp_secret = #{secret} WHERE username = #{username}")
|
||||||
|
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
|
||||||
|
|
||||||
|
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
|
||||||
|
@Update("UPDATE hrm_users SET otp_enabled = true WHERE username = #{username}")
|
||||||
|
int enableOtp(@Param("username") String username);
|
||||||
|
|
||||||
|
/** 마이페이지 해제: 시크릿 폐기 + otp_enabled=false. */
|
||||||
|
@Update("UPDATE hrm_users SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
|
||||||
|
int disableOtp(@Param("username") String username);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,10 +41,18 @@ public class SecurityConfig {
|
|||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/hrm/auth/**").permitAll()
|
.requestMatchers("/api/hrm/auth/**").permitAll()
|
||||||
|
// UIWS 로그인 보조 3종(회원가입·아이디찾기·비번초기화) — 로그인 전 무인증 접근.
|
||||||
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
|
// UIWS system: 가입 화면 공개 조회(부서/회사 GET) — permitAll.
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
|
||||||
.requestMatchers("/actuator/health").permitAll()
|
.requestMatchers("/actuator/health").permitAll()
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/hrm/docs/**", "/api/hrm/swagger/**").permitAll()
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/hrm/docs/**", "/api/hrm/swagger/**").permitAll()
|
||||||
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
|
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
|
||||||
|
|
||||||
|
// UIWS system(권한관리): 조회는 MANAGER 이상, 변경은 SUPERADMIN.
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/system/**").hasAnyRole("SUPERADMIN", "MANAGER")
|
||||||
|
.requestMatchers("/api/system/**").hasRole("SUPERADMIN")
|
||||||
|
|
||||||
.requestMatchers("/api/hrm/admin/users/**").hasRole("SUPERADMIN")
|
.requestMatchers("/api/hrm/admin/users/**").hasRole("SUPERADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/hrm/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER")
|
.requestMatchers(HttpMethod.GET, "/api/hrm/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER")
|
||||||
.requestMatchers("/api/hrm/admin/settings/**").hasRole("SUPERADMIN")
|
.requestMatchers("/api/hrm/admin/settings/**").hasRole("SUPERADMIN")
|
||||||
|
|||||||
@ -15,8 +15,15 @@ spring:
|
|||||||
minimum-idle: 1
|
minimum-idle: 1
|
||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
mode: ${SQL_INIT_MODE:never}
|
# schema.sql(전부 IF NOT EXISTS + ON CONFLICT DO NOTHING)·UIWS 이식 파일(전부 멱등)만 재적용.
|
||||||
schema-locations: classpath:db/schema.sql
|
# 91_uiws_port.sql: 업무 모듈 tb_uiws_* + hrm_users 2FA/로그인보조 컬럼 ALTER(멱등).
|
||||||
|
# 92_uiws_system.sql: UIWS system(권한관리) tb_uiws_* 10테이블(부서/회사/코드/사용자/역할/메뉴/프로그램, 멱등).
|
||||||
|
mode: ${SQL_INIT_MODE:always}
|
||||||
|
# 104_seed_ai_config.sql: AI 플랫폼(provider/모델) 설정 시드 hrm_settings ai.* (멱등 ON CONFLICT DO NOTHING).
|
||||||
|
# 93_auth_otp.sql: hrm_users otp_enabled 멱등 ALTER(TOTP 2차 인증). ops_otp_reset_all.sql 은 미등재(운영 1회 수동).
|
||||||
|
# 202_ai_provider_claude.sql: AI provider 를 claude 로 flip(멱등 UPSERT). 반드시 104 시드 이후로 등재.
|
||||||
|
schema-locations: classpath:db/schema.sql,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,classpath:db/202_ai_provider_claude.sql
|
||||||
|
continue-on-error: true
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 20MB
|
max-file-size: 20MB
|
||||||
@ -38,15 +45,38 @@ guardia:
|
|||||||
erp-url: ${ERP_URL:http://localhost:8003}
|
erp-url: ${ERP_URL:http://localhost:8003}
|
||||||
itsm-url: ${ITSM_URL:http://localhost:9001}
|
itsm-url: ${ITSM_URL:http://localhost:9001}
|
||||||
groupware-url: ${GROUPWARE_URL:http://localhost:8009}
|
groupware-url: ${GROUPWARE_URL:http://localhost:8009}
|
||||||
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
|
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지(Claude 는 소유자 승인 예외).
|
||||||
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
||||||
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
|
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
|
||||||
|
# 중앙 guardia-rag 연동(피드백 전달). base-url 은 서버 내부 루프백 전용 — 외부 URL 금지.
|
||||||
|
rag:
|
||||||
|
base-url: ${RAG_URL:http://127.0.0.1:8020}
|
||||||
|
enabled: ${RAG_ENABLED:true}
|
||||||
|
# 로컬 임베디드 DuckDB 학습 저장소 파일 경로(솔루션별 격리).
|
||||||
|
learning:
|
||||||
|
duckdb-path: ${HRM_LEARNING_DUCKDB:/opt/guardia-hrm/data/hrm_learning.duckdb}
|
||||||
crypto:
|
crypto:
|
||||||
secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo}
|
secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo}
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:guardia-hrm-jwt-secret-2026-minimum-256bit-key-zioinfo}
|
secret: ${JWT_SECRET:guardia-hrm-jwt-secret-2026-minimum-256bit-key-zioinfo}
|
||||||
expiration: 86400000
|
expiration: 86400000
|
||||||
|
|
||||||
|
# UIWS 이식 모듈 설정 (worklog·schedule·message·stats + 2FA 레이어 + 로그인 보조).
|
||||||
|
# 2FA 토글: hrm.uiws.auth.twofa-enabled=false 면 기존 단일 JWT 로그인 회귀 0.
|
||||||
|
hrm:
|
||||||
|
uiws:
|
||||||
|
auth:
|
||||||
|
twofa-enabled: ${UIWS_2FA:true}
|
||||||
|
# TOTP(OTP 2차 인증, UIMS 방식) on/off. 기본 on. 우선순위: otp → 이메일코드 → 단일. off 시 이메일/단일 경로로 폴백.
|
||||||
|
otp-enabled: ${UIWS_OTP:true}
|
||||||
|
verify-token-validity-seconds: 300 # verify-token 5분
|
||||||
|
email-code-validity-seconds: 300 # 이메일 코드 5분
|
||||||
|
max-login-fail: 5 # 실패 5회 잠금
|
||||||
|
mail:
|
||||||
|
mode: ${UIWS_MAIL_MODE:log} # log 폴백(외부 API 금지). smtp 운영 시 별도 구현
|
||||||
|
upload:
|
||||||
|
upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.zioinfo.hrm: DEBUG
|
com.zioinfo.hrm: DEBUG
|
||||||
|
|||||||
22
backend/src/main/resources/db/202_ai_provider_claude.sql
Normal file
22
backend/src/main/resources/db/202_ai_provider_claude.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- GUARDiA HRM — 202. AI provider 를 claude 로 flip (멱등 UPSERT)
|
||||||
|
-- 대상 DB : hrm_db (테이블 hrm_settings, PK=key — schema.sql 에서 생성)
|
||||||
|
-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일을
|
||||||
|
-- 104_seed_ai_config.sql **이후**로 등재(시드가 먼저 기본값 ollama INSERT → 본 파일이 claude 로 전환).
|
||||||
|
-- 설명 : 런타임 AI provider 를 Claude(Anthropic) 로 전환한다. UIWS db/202 대응본.
|
||||||
|
-- 키 미설정/실패 시 AiTextRouter 가 자동으로 Ollama 로 폴백하므로 무중단·무회귀.
|
||||||
|
-- 멱등 : provider 행이 없어도(UPSERT) INSERT, 있으면 value='claude' 로 UPDATE. 재실행 안전.
|
||||||
|
-- * hrm 서버 실측: hrm_settings 에 ai.provider 행 부재(코드 기본값 ollama) → INSERT 경로로 생성.
|
||||||
|
-- 보안 : Claude API 키는 DB 에 저장하지 않는다 — 서버 환경변수 ANTHROPIC_API_KEY 로만 주입.
|
||||||
|
-- 본 파일에 키/시크릿/IP/비밀번호 일절 미포함.
|
||||||
|
-- 활성화 : 실제 Claude 경로는 provider=claude · ANTHROPIC_API_KEY 설정됨 · ai.enabled=true 3조건
|
||||||
|
-- (AiConfigService.isClaudeActive) 충족 시. 그 외에는 Ollama 폴백(정상 동작).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
|
||||||
|
INSERT INTO hrm_settings (key, value, description) VALUES
|
||||||
|
('ai.provider', 'claude', 'AI provider(ollama/claude/qwen3/deepseek/glm)')
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = 'claude';
|
||||||
|
|
||||||
|
-- end 202_ai_provider_claude.sql
|
||||||
@ -39,6 +39,10 @@
|
|||||||
UPDATE hrm_users SET active=#{active} WHERE id=#{id}
|
UPDATE hrm_users SET active=#{active} WHERE id=#{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="clearOtp">
|
||||||
|
UPDATE hrm_users SET otp_secret=NULL, otp_enabled=false WHERE id=#{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
<select id="findAuditLogs" resultType="map">
|
<select id="findAuditLogs" resultType="map">
|
||||||
SELECT id, actor, action, target_type, target_id, detail, ip_addr, created_at
|
SELECT id, actor, action, target_type, target_id, detail, ip_addr, created_at
|
||||||
FROM hrm_audit_log
|
FROM hrm_audit_log
|
||||||
|
|||||||
@ -12,10 +12,22 @@
|
|||||||
<result property="role" column="role"/>
|
<result property="role" column="role"/>
|
||||||
<result property="active" column="active"/>
|
<result property="active" column="active"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
|
<!-- UIWS 2FA 이식 컬럼 (db/91_uiws_port.sql 멱등 ALTER 로 추가) -->
|
||||||
|
<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"/>
|
||||||
|
<!-- UIWS 로그인 보조기능 이식 컬럼 (회원가입 승인·임시비번 변경유도) -->
|
||||||
|
<result property="approved" column="approved"/>
|
||||||
|
<result property="pwChangeYn" column="pw_change_yn"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findByUsername" resultMap="UserRM">
|
<select id="findByUsername" resultMap="UserRM">
|
||||||
SELECT id, username, password_hash, display_name, email, role, active, created_at
|
SELECT id, username, password_hash, display_name, email, role, active, created_at,
|
||||||
|
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled,
|
||||||
|
approved, pw_change_yn
|
||||||
FROM hrm_users WHERE username = #{username}
|
FROM hrm_users WHERE username = #{username}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@ -24,4 +36,13 @@
|
|||||||
VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active})
|
VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active})
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
|
<!-- UIWS 회원가입 이식: 가입자는 항상 VIEWER(최소권한)·승인 대기(approved=false). -->
|
||||||
|
<insert id="insertSignup" parameterType="com.zioinfo.hrm.auth.HrmUser"
|
||||||
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
|
INSERT INTO hrm_users (username, password_hash, role, display_name, email,
|
||||||
|
active, approved, login_fail_count, locked)
|
||||||
|
VALUES (#{username}, #{passwordHash}, 'VIEWER', #{displayName}, #{email},
|
||||||
|
true, false, 0, false)
|
||||||
|
</insert>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GUARDiA HRM — AI 인사관리 플랫폼</title>
|
<title>GUARDiA HRM — AI 인사관리 플랫폼</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -10,7 +10,21 @@ import PerformancePage from './pages/PerformancePage'
|
|||||||
import RecruitmentPage from './pages/RecruitmentPage'
|
import RecruitmentPage from './pages/RecruitmentPage'
|
||||||
import TrainingPage from './pages/TrainingPage'
|
import TrainingPage from './pages/TrainingPage'
|
||||||
import AiPage from './pages/AiPage'
|
import AiPage from './pages/AiPage'
|
||||||
|
import WiseAiPage from './pages/WiseAiPage'
|
||||||
|
import AiPlatformSettings from './pages/AiPlatformSettings'
|
||||||
import AdminPage from './pages/AdminPage'
|
import AdminPage from './pages/AdminPage'
|
||||||
|
import MyPage from './pages/MyPage'
|
||||||
|
import MobileApp from './pages/MobileApp'
|
||||||
|
// UIWS(UIMS) 이식 — 공통 업무협업 화면(업무일지·일정·쪽지·통계)
|
||||||
|
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 system(권한관리) 이식 — 시스템관리(권한) 화면 셸(탭: 권한/역할-메뉴/공통코드/메뉴/부서/거래처)
|
||||||
|
import SystemPage from './pages/uiws/system/SystemPage'
|
||||||
|
|
||||||
|
// 시스템관리(권한)는 관리자 전용. 백엔드 /api/system/** 조회=SUPERADMIN/MANAGER, 쓰기=SUPERADMIN.
|
||||||
|
const ADMIN_ROLES = ['SUPERADMIN', 'MANAGER', 'ADMIN']
|
||||||
|
|
||||||
const MENU = [
|
const MENU = [
|
||||||
{ path: '/dashboard', label: '대시보드', icon: '📊' },
|
{ path: '/dashboard', label: '대시보드', icon: '📊' },
|
||||||
@ -21,13 +35,25 @@ const MENU = [
|
|||||||
{ path: '/performance', label: '성과평가', icon: '🎯' },
|
{ path: '/performance', label: '성과평가', icon: '🎯' },
|
||||||
{ path: '/recruitment', label: '채용관리', icon: '🔍' },
|
{ path: '/recruitment', label: '채용관리', icon: '🔍' },
|
||||||
{ path: '/training', label: '교육관리', icon: '📚' },
|
{ path: '/training', label: '교육관리', icon: '📚' },
|
||||||
|
// UIWS 이식 — 업무 협업
|
||||||
|
{ path: '/worklogs', label: '업무일지', icon: '📝' },
|
||||||
|
{ path: '/schedules', label: '일정관리', icon: '📅' },
|
||||||
|
{ path: '/messages', label: '쪽지함', icon: '✉️' },
|
||||||
|
{ path: '/work-stats', label: '업무통계', icon: '📈' },
|
||||||
{ path: '/ai', label: 'AI 인사분석', icon: '🤖' },
|
{ path: '/ai', label: 'AI 인사분석', icon: '🤖' },
|
||||||
|
{ path: '/wise-ai', label: 'WISE AI', icon: '✨' },
|
||||||
|
{ path: '/ai-platform', label: 'AI 플랫폼 설정', icon: '🧠', adminOnly: true },
|
||||||
{ path: '/admin', label: '시스템관리', icon: '⚙️' },
|
{ path: '/admin', label: '시스템관리', icon: '⚙️' },
|
||||||
|
// UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용)
|
||||||
|
{ path: '/system', label: '시스템관리(권한)', icon: '🔐', adminOnly: true },
|
||||||
|
{ path: '/mobile-app', label: '모바일 앱', icon: '📱' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function Layout({ children }: { children: React.ReactNode }) {
|
function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"displayName":"관리자","role":"SUPERADMIN"}')
|
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"displayName":"관리자","role":"SUPERADMIN"}')
|
||||||
|
const isAdmin = ADMIN_ROLES.includes(user.role)
|
||||||
|
const visibleMenu = MENU.filter(m => !m.adminOnly || isAdmin)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden">
|
||||||
@ -45,7 +71,7 @@ function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1">
|
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-1">
|
||||||
{MENU.map(m => (
|
{visibleMenu.map(m => (
|
||||||
<NavLink key={m.path} to={m.path}
|
<NavLink key={m.path} to={m.path}
|
||||||
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}>
|
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}>
|
||||||
<span className="text-base">{m.icon}</span>
|
<span className="text-base">{m.icon}</span>
|
||||||
@ -66,10 +92,13 @@ function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<button className="mt-3 w-full text-xs text-slate-400 hover:text-white"
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<NavLink to="/mypage" className="text-xs text-slate-400 hover:text-white">마이페이지</NavLink>
|
||||||
|
<button className="text-xs text-slate-400 hover:text-white"
|
||||||
onClick={() => { localStorage.clear(); window.location.href = '/login'; }}>
|
onClick={() => { localStorage.clear(); window.location.href = '/login'; }}>
|
||||||
로그아웃
|
로그아웃
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -87,6 +116,15 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return <Layout>{children}</Layout>
|
return <Layout>{children}</Layout>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 관리자 전용 라우트 가드(UI 차단). 실제 인가는 백엔드 /api/system/** RBAC가 강제(403).
|
||||||
|
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const token = localStorage.getItem('hrm_token')
|
||||||
|
if (!token) return <Navigate to="/login" replace />
|
||||||
|
const user = JSON.parse(localStorage.getItem('hrm_user') || '{"role":"SUPERADMIN"}')
|
||||||
|
if (!ADMIN_ROLES.includes(user.role)) return <Navigate to="/dashboard" replace />
|
||||||
|
return <Layout>{children}</Layout>
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@ -101,8 +139,23 @@ export default function App() {
|
|||||||
<Route path="/performance" element={<PrivateRoute><PerformancePage /></PrivateRoute>} />
|
<Route path="/performance" element={<PrivateRoute><PerformancePage /></PrivateRoute>} />
|
||||||
<Route path="/recruitment" element={<PrivateRoute><RecruitmentPage /></PrivateRoute>} />
|
<Route path="/recruitment" element={<PrivateRoute><RecruitmentPage /></PrivateRoute>} />
|
||||||
<Route path="/training" element={<PrivateRoute><TrainingPage /></PrivateRoute>} />
|
<Route path="/training" element={<PrivateRoute><TrainingPage /></PrivateRoute>} />
|
||||||
|
{/* UIWS(UIMS) 이식 — 업무 협업 화면 */}
|
||||||
|
<Route path="/worklogs" element={<PrivateRoute><WorklogList /></PrivateRoute>} />
|
||||||
|
<Route path="/schedules" element={<PrivateRoute><ScheduleCalendar /></PrivateRoute>} />
|
||||||
|
<Route path="/messages" element={<PrivateRoute><MessageBox /></PrivateRoute>} />
|
||||||
|
<Route path="/work-stats" element={<PrivateRoute><StatsPivot /></PrivateRoute>} />
|
||||||
<Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} />
|
<Route path="/ai" element={<PrivateRoute><AiPage /></PrivateRoute>} />
|
||||||
|
{/* WISE AI — 중앙 guardia-rag 근거·인용 질의응답(인증 사용자) */}
|
||||||
|
<Route path="/wise-ai" element={<PrivateRoute><WiseAiPage /></PrivateRoute>} />
|
||||||
|
{/* AI 플랫폼 설정(provider/모델·연결테스트·피드백 학습) — 관리자 전용. 백엔드 /api/hrm/admin/** RBAC 강제 */}
|
||||||
|
<Route path="/ai-platform" element={<AdminRoute><AiPlatformSettings /></AdminRoute>} />
|
||||||
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />
|
<Route path="/admin" element={<PrivateRoute><AdminPage /></PrivateRoute>} />
|
||||||
|
{/* 마이페이지 — OTP 2차 인증 등록/재설정/해제 + 비밀번호 변경 */}
|
||||||
|
<Route path="/mypage" element={<PrivateRoute><MyPage /></PrivateRoute>} />
|
||||||
|
{/* UIWS system 이식 — 시스템관리(권한). 관리자 전용(SUPERADMIN/MANAGER/ADMIN), 그 외 대시보드로 */}
|
||||||
|
<Route path="/system" element={<AdminRoute><SystemPage /></AdminRoute>} />
|
||||||
|
{/* 통합 메신저 앱 다운로드 QR (ITSM 중앙 APK 저장소 공개 엔드포인트 재사용·읽기전용) */}
|
||||||
|
<Route path="/mobile-app" element={<PrivateRoute><MobileApp /></PrivateRoute>} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -20,3 +20,30 @@ api.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
||||||
|
// ── WISE AI (중앙 guardia-rag 프록시) ──────────────────────────────────
|
||||||
|
// 별도 baseURL(/api/wise) — 공용 api(/api/hrm)와 경로가 다르므로 전용 인스턴스.
|
||||||
|
// 인증 사용자 전용(로그인 JWT). 응답은 ApiResponse 봉투(r.data.data 에 rag 결과).
|
||||||
|
const wiseApi = axios.create({ baseURL: '/api/wise' })
|
||||||
|
wiseApi.interceptors.request.use(cfg => {
|
||||||
|
const token = localStorage.getItem('hrm_token')
|
||||||
|
if (token) cfg.headers.Authorization = `Bearer ${token}`
|
||||||
|
return cfg
|
||||||
|
})
|
||||||
|
export const wiseAsk = (query: string) => wiseApi.post('/ask', { query })
|
||||||
|
|
||||||
|
// ── Auth / Me ─────────────────────────────────────────────────────────
|
||||||
|
// client baseURL='/api/hrm' → 경로는 상대(/auth/...·/admin/...).
|
||||||
|
export const getMe = () => api.get('/auth/me')
|
||||||
|
|
||||||
|
// ── 2FA / OTP / 계정 보안 (마이페이지, access 토큰 필요) ────────────────
|
||||||
|
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 금지.
|
||||||
|
export const otpSetup = () => api.post('/auth/otp/setup')
|
||||||
|
export const otpConfirm = (code: string) => api.post('/auth/otp/confirm', { code })
|
||||||
|
export const otpDisable = () => api.post('/auth/otp/disable')
|
||||||
|
export const changePassword = (currentPassword: string, newPassword: string) =>
|
||||||
|
api.post('/auth/change-password', { currentPassword, newPassword })
|
||||||
|
|
||||||
|
// ── Admin: OTP 초기화 (SUPERADMIN — /api/hrm/admin/users/** 게이트) ─────
|
||||||
|
// 대상 사용자 OTP 해제(다음 로그인 시 OTP_SETUP 재등록). 시크릿 미조회.
|
||||||
|
export const adminOtpReset = (id: number) => api.post(`/admin/users/${id}/otp-reset`)
|
||||||
|
|||||||
@ -40,3 +40,45 @@ body {
|
|||||||
.table-cell {
|
.table-cell {
|
||||||
@apply px-4 py-3 text-sm text-slate-700;
|
@apply px-4 py-3 text-sm text-slate-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* UIWS 이식 컴포넌트(components/uiws/ui.tsx) 디자인 토큰.
|
||||||
|
* 색상 하드코딩 금지 — 컴포넌트는 var(--uiws-*)만 사용한다.
|
||||||
|
* 기본은 HRM 라이트 테마와 정합. OS 다크 모드 시 자동 전환(대비/가독성 보존).
|
||||||
|
* 전역 HRM 테마를 덮어쓰지 않도록 토큰만 정의(모듈 스코프).
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--uiws-surface: #ffffff;
|
||||||
|
--uiws-surface-2: #f1f5f9;
|
||||||
|
--uiws-border: #e2e8f0;
|
||||||
|
--uiws-text: #1e293b;
|
||||||
|
--uiws-text-muted: #64748b;
|
||||||
|
--uiws-text-faint: #94a3b8;
|
||||||
|
--uiws-primary: #2563eb;
|
||||||
|
--uiws-primary-contrast: #ffffff;
|
||||||
|
--uiws-primary-soft: #eff6ff;
|
||||||
|
--uiws-input-bg: #ffffff;
|
||||||
|
--uiws-row-hover: #f8fafc;
|
||||||
|
--uiws-danger: #dc2626;
|
||||||
|
--uiws-success: #16a34a;
|
||||||
|
--uiws-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--uiws-surface: #1e293b;
|
||||||
|
--uiws-surface-2: #0f172a;
|
||||||
|
--uiws-border: #334155;
|
||||||
|
--uiws-text: #e2e8f0;
|
||||||
|
--uiws-text-muted: #94a3b8;
|
||||||
|
--uiws-text-faint: #64748b;
|
||||||
|
--uiws-primary: #3b82f6;
|
||||||
|
--uiws-primary-contrast: #ffffff;
|
||||||
|
--uiws-primary-soft: #1e3a5f;
|
||||||
|
--uiws-input-bg: #0f172a;
|
||||||
|
--uiws-row-hover: #273449;
|
||||||
|
--uiws-danger: #f87171;
|
||||||
|
--uiws-success: #4ade80;
|
||||||
|
--uiws-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import api from '../api/client'
|
import api, { adminOtpReset } from '../api/client'
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [users, setUsers] = useState<any[]>([])
|
const [users, setUsers] = useState<any[]>([])
|
||||||
@ -28,6 +28,21 @@ export default function AdminPage() {
|
|||||||
loadUsers()
|
loadUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 관리자 OTP 초기화(SUPERADMIN). 대상 사용자는 다음 로그인 시 OTP_SETUP(QR 재등록)을 탄다.
|
||||||
|
const resetOtp = async (id: number, username: string) => {
|
||||||
|
if (!window.confirm(
|
||||||
|
`'${username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`
|
||||||
|
)) return
|
||||||
|
try {
|
||||||
|
await adminOtpReset(id)
|
||||||
|
alert('OTP가 초기화되었습니다.')
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.response?.status === 403
|
||||||
|
? '권한이 없습니다 (SUPERADMIN 전용).'
|
||||||
|
: (e?.response?.data?.message || 'OTP 초기화에 실패했습니다.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveSetting = async (key: string, value: string) => {
|
const saveSetting = async (key: string, value: string) => {
|
||||||
await api.put(`/admin/settings/${key}`, null, { params: { value } })
|
await api.put(`/admin/settings/${key}`, null, { params: { value } })
|
||||||
loadSettings()
|
loadSettings()
|
||||||
@ -79,10 +94,16 @@ export default function AdminPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="table-cell text-xs">{u.last_login_at?.slice(0,16) || '-'}</td>
|
<td className="table-cell text-xs">{u.last_login_at?.slice(0,16) || '-'}</td>
|
||||||
<td className="table-cell">
|
<td className="table-cell">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<button className={`text-xs hover:underline ${u.is_active?'text-red-600':'text-blue-600'}`}
|
<button className={`text-xs hover:underline ${u.is_active?'text-red-600':'text-blue-600'}`}
|
||||||
onClick={() => toggleUser(u.id, u.is_active)}>
|
onClick={() => toggleUser(u.id, u.is_active)}>
|
||||||
{u.is_active ? '비활성화' : '활성화'}
|
{u.is_active ? '비활성화' : '활성화'}
|
||||||
</button>
|
</button>
|
||||||
|
<button className="text-xs text-slate-500 hover:underline hover:text-blue-600"
|
||||||
|
onClick={() => resetOtp(u.id, u.username)} title="OTP 초기화(다음 로그인 재등록)">
|
||||||
|
OTP 초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
import { verify2fa, verifyOtp, signup, findId, resetPasswordHelper } from '../api/uiws'
|
||||||
|
|
||||||
|
type Helper = null | 'signup' | 'findId' | 'resetPw'
|
||||||
|
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
@ -8,17 +12,67 @@ export default function LoginPage() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// UIWS 2FA — 1차 통과 후 코드 입력 단계.
|
||||||
|
// · EMAIL : 이메일 인증코드(maskedEmail) → /verify (하위호환)
|
||||||
|
// · OTP : Authenticator 6자리 → /verify-otp
|
||||||
|
// · OTP_SETUP : 최초 로그인 — QR(qrImage)+수동키(secret) 등록 후 6자리 → /verify-otp
|
||||||
|
const [twofa, setTwofa] = useState<{
|
||||||
|
verifyToken: string; verifyMethod: VerifyMethod
|
||||||
|
maskedEmail: string; qrImage: string; secret: string
|
||||||
|
} | null>(null)
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const isOtp = twofa?.verifyMethod === 'OTP' || twofa?.verifyMethod === 'OTP_SETUP'
|
||||||
|
const isSetup = twofa?.verifyMethod === 'OTP_SETUP'
|
||||||
|
|
||||||
|
// 로그인 보조 3종 모달
|
||||||
|
const [helper, setHelper] = useState<Helper>(null)
|
||||||
|
|
||||||
|
const afterAuthenticated = async () => {
|
||||||
|
const me = await api.get('/auth/me')
|
||||||
|
localStorage.setItem('hrm_user', JSON.stringify(me.data.data))
|
||||||
|
nav('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
const login = async (e: React.FormEvent) => {
|
const login = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true); setError('')
|
setLoading(true); setError('')
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/login', form)
|
const { data } = await api.post('/auth/login', form)
|
||||||
|
const payload = data.data || {}
|
||||||
|
if (payload.twofa === 'true') {
|
||||||
|
// 2FA 활성 — verifyMethod 로 분기(access 토큰 미발급). 시크릿/QR은 화면 표시용만.
|
||||||
|
setTwofa({
|
||||||
|
verifyToken: payload.verifyToken,
|
||||||
|
verifyMethod: (payload.verifyMethod as VerifyMethod) || 'EMAIL',
|
||||||
|
maskedEmail: payload.maskedEmail || '',
|
||||||
|
qrImage: payload.qrImage || '',
|
||||||
|
secret: payload.secret || '',
|
||||||
|
})
|
||||||
|
setCode('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem('hrm_token', payload.token)
|
||||||
|
await afterAuthenticated()
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || ''
|
||||||
|
setError(msg.includes('잠겼') || msg.includes('승인')
|
||||||
|
? msg : '아이디 또는 비밀번호가 올바르지 않습니다.')
|
||||||
|
} finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const verify = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!twofa) return
|
||||||
|
setLoading(true); setError('')
|
||||||
|
try {
|
||||||
|
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
|
||||||
|
const { data } = isOtp
|
||||||
|
? await verifyOtp(twofa.verifyToken, code)
|
||||||
|
: await verify2fa(twofa.verifyToken, code)
|
||||||
localStorage.setItem('hrm_token', data.data.token)
|
localStorage.setItem('hrm_token', data.data.token)
|
||||||
const me = await api.get('/auth/me')
|
await afterAuthenticated()
|
||||||
localStorage.setItem('hrm_user', JSON.stringify(me.data.data))
|
|
||||||
nav('/dashboard')
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('아이디 또는 비밀번호가 올바르지 않습니다.')
|
setError('인증 코드가 올바르지 않거나 만료되었습니다.')
|
||||||
} finally { setLoading(false) }
|
} finally { setLoading(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,6 +83,8 @@ export default function LoginPage() {
|
|||||||
<h1 className="text-3xl font-bold text-white">GUARDiA HRM</h1>
|
<h1 className="text-3xl font-bold text-white">GUARDiA HRM</h1>
|
||||||
<p className="text-blue-300 mt-2">AI 기반 통합 인사관리 플랫폼</p>
|
<p className="text-blue-300 mt-2">AI 기반 통합 인사관리 플랫폼</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!twofa ? (
|
||||||
<form onSubmit={login} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
|
<form onSubmit={login} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">아이디</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1">아이디</label>
|
||||||
@ -49,11 +105,132 @@ export default function LoginPage() {
|
|||||||
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||||
{loading ? '로그인 중...' : '로그인'}
|
{loading ? '로그인 중...' : '로그인'}
|
||||||
</button>
|
</button>
|
||||||
|
{/* UIWS 로그인 보조 3종 */}
|
||||||
|
<div className="flex items-center justify-center gap-3 text-xs text-slate-500">
|
||||||
|
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('signup'); setError('') }}>회원가입</button>
|
||||||
|
<span className="text-slate-300">|</span>
|
||||||
|
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('findId'); setError('') }}>아이디 찾기</button>
|
||||||
|
<span className="text-slate-300">|</span>
|
||||||
|
<button type="button" className="hover:text-blue-600" onClick={() => { setHelper('resetPw'); setError('') }}>비밀번호 초기화</button>
|
||||||
|
</div>
|
||||||
<p className="text-center text-xs text-slate-400">
|
<p className="text-center text-xs text-slate-400">
|
||||||
GUARDiA HRM v1.0 | 온프레미스 AI 인사관리
|
GUARDiA HRM v1.0 | 온프레미스 AI 인사관리
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={verify} className="bg-white rounded-2xl shadow-2xl p-8 space-y-5">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold text-slate-700">2차 인증</p>
|
||||||
|
{isSetup ? (
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
최초 로그인입니다. 아래 QR을 Authenticator 앱(Google·Microsoft)으로 스캔해
|
||||||
|
등록한 뒤 6자리 코드를 입력하세요.
|
||||||
|
</p>
|
||||||
|
) : isOtp ? (
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Authenticator 앱에 표시된 6자리 코드를 입력하세요.</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
{twofa.maskedEmail ? `${twofa.maskedEmail} 으로 발송된 인증 코드를 입력하세요.` : '이메일로 발송된 인증 코드를 입력하세요.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isSetup && twofa.qrImage && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<img src={twofa.qrImage} alt="OTP QR" width={180} height={180}
|
||||||
|
style={{ background: '#fff', padding: 8, borderRadius: 8, border: '1px solid #e2e8f0' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSetup && twofa.secret && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] text-slate-400 mb-1">QR 스캔이 안 되면 수동 입력 키</p>
|
||||||
|
<code className="block text-xs text-blue-700 bg-slate-50 border border-slate-200 rounded-md px-2 py-1.5 break-all select-all">
|
||||||
|
{twofa.secret}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input type="text" inputMode="numeric" value={code} autoFocus
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-center tracking-widest text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="6자리 코드" maxLength={6} required />
|
||||||
|
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading || code.length !== 6}
|
||||||
|
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||||
|
{loading ? '확인 중...' : isSetup ? '등록하고 로그인' : '인증 확인'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setTwofa(null); setCode(''); setError('') }}
|
||||||
|
className="w-full text-xs text-slate-400 hover:text-slate-600">← 다시 로그인</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{helper && <HelperModal kind={helper} onClose={() => setHelper(null)} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인 보조 3종 모달 — 회원가입 / 아이디찾기 / 비밀번호 초기화. */
|
||||||
|
function HelperModal({ kind, onClose }: { kind: Exclude<Helper, null>; onClose: () => void }) {
|
||||||
|
const [f, setF] = useState({ username: '', password: '', displayName: '', email: '' })
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const set = (k: string, v: string) => setF({ ...f, [k]: v })
|
||||||
|
|
||||||
|
const TITLE: Record<string, string> = { signup: '회원가입', findId: '아이디 찾기', resetPw: '비밀번호 초기화' }
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true); setMsg('')
|
||||||
|
try {
|
||||||
|
if (kind === 'signup') {
|
||||||
|
const { data } = await signup({ username: f.username, password: f.password, displayName: f.displayName, email: f.email })
|
||||||
|
setMsg(data.data?.message || '처리되었습니다.')
|
||||||
|
} else if (kind === 'findId') {
|
||||||
|
const { data } = await findId({ displayName: f.displayName, email: f.email })
|
||||||
|
setMsg(data.data?.found ? `회원님의 아이디: ${data.data.maskedUsername}` : '일치하는 계정을 찾을 수 없습니다.')
|
||||||
|
} else {
|
||||||
|
const { data } = await resetPasswordHelper({ username: f.username, email: f.email })
|
||||||
|
setMsg(data.data?.message || '처리되었습니다.')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMsg('요청 처리 중 오류가 발생했습니다.')
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = (k: string, ph: string, type = 'text') => (
|
||||||
|
<input type={type} value={(f as any)[k]} onChange={e => set(k, e.target.value)}
|
||||||
|
className="w-full border border-slate-200 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder={ph} required />
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" onClick={onClose}>
|
||||||
|
<form onClick={e => e.stopPropagation()} onSubmit={submit}
|
||||||
|
className="bg-white rounded-2xl shadow-2xl p-7 w-full max-w-sm space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold text-slate-800">{TITLE[kind]}</h2>
|
||||||
|
<button type="button" onClick={onClose} className="text-slate-400 hover:text-slate-600">✕</button>
|
||||||
|
</div>
|
||||||
|
{kind === 'signup' && <>
|
||||||
|
{input('username', '아이디')}
|
||||||
|
{input('password', '비밀번호(4자 이상)', 'password')}
|
||||||
|
{input('displayName', '이름')}
|
||||||
|
{input('email', '이메일', 'email')}
|
||||||
|
</>}
|
||||||
|
{kind === 'findId' && <>
|
||||||
|
{input('displayName', '이름')}
|
||||||
|
{input('email', '이메일', 'email')}
|
||||||
|
</>}
|
||||||
|
{kind === 'resetPw' && <>
|
||||||
|
{input('username', '아이디')}
|
||||||
|
{input('email', '이메일', 'email')}
|
||||||
|
</>}
|
||||||
|
{msg && <p className="text-sm text-blue-700 bg-blue-50 rounded-lg px-3 py-2">{msg}</p>}
|
||||||
|
<button type="submit" disabled={busy}
|
||||||
|
className="w-full py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||||
|
{busy ? '처리 중...' : '확인'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user