chore(deploy): MES 클린 재동기화 + Claude provider flip

- workspace→repos 클린 재동기화(auto-sync 발산 해소, 실델타 32파일)
- AI provider flip: db/105_ai_provider_claude.sql 신규(멱등 UPSERT provider=claude)
- application.yml schema-locations 에 105 등재(104 시드 이후 실행)
- 키 미기재(ANTHROPIC_API_KEY env 단일 로드), 실패 시 Ollama 자동 폴백

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GUARDiA 2026-07-11 10:39:23 +09:00
parent a093098657
commit 13ab120c19
32 changed files with 1312 additions and 654 deletions

View File

@ -22,6 +22,7 @@
<springdoc.version>2.6.0</springdoc.version> <springdoc.version>2.6.0</springdoc.version>
<mybatis.version>3.0.3</mybatis.version> <mybatis.version>3.0.3</mybatis.version>
<postgresql.version>42.7.7</postgresql.version> <postgresql.version>42.7.7</postgresql.version>
<duckdb.version>1.1.3</duckdb.version>
</properties> </properties>
<dependencies> <dependencies>
@ -42,6 +43,10 @@
<!-- 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 학습/추론 저장소 — /opt/guardia-mes/data/mes_learning.duckdb) -->
<!-- 런타임 전용: 코드는 java.sql + 리플렉션 드라이버 로드로 컴파일 의존 0. 미가용 시 학습 저장만 비활성. -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>${duckdb.version}</version><scope>runtime</scope></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>
@ -54,6 +59,9 @@
<version>${springdoc.version}</version> <version>${springdoc.version}</version>
</dependency> </dependency>
<!-- TOTP (OTP 2차 인증 — RFC 6238, QR 생성). UIWS 좌표 동일. -->
<dependency><groupId>dev.samstevens.totp</groupId><artifactId>totp</artifactId><version>1.7.1</version></dependency>
<!-- Lombok --> <!-- Lombok -->
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency> <dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>

View File

@ -56,6 +56,12 @@ public class AdminController {
return ApiResponse.ok(userService.resetPassword(id, req.password())); return ApiResponse.ok(userService.resetPassword(id, req.password()));
} }
/** 관리자 OTP 초기화(SUPERADMIN). 대상 OTP 시크릿 폐기 → 다음 로그인 재등록. 시크릿 미노출·감사 기록. */
@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;

View File

@ -85,6 +85,17 @@ public class AdminUserService {
return UserDto.from(user); return UserDto.from(user);
} }
/**
* 관리자 OTP 초기화(SUPERADMIN). 대상 사용자의 OTP 시크릿을 폐기하고 등록을 해제한다.
* 사용자는 다음 로그인 OTP_SETUP(재등록) 플로우를 탄다. 시크릿은 응답/로그에 노출하지 않는다.
*/
public UserDto resetOtp(Long id) {
MesUser user = require(id);
mapper.clearOtp(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) {
MesUser user = require(id); MesUser user = require(id);
if (user.getUsername().equals(currentUsername)) { if (user.getUsername().equals(currentUsername)) {

View File

@ -22,6 +22,9 @@ public interface AdminUserMapper {
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash); int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false(다음 로그인 시 재등록 유도). 시크릿 미노출. */
int clearOtp(@Param("id") Long id);
int deleteById(@Param("id") Long id); int deleteById(@Param("id") Long id);
/** 활성 SUPERADMIN 계정 수 (마지막 관리자 삭제/강등 방지용). */ /** 활성 SUPERADMIN 계정 수 (마지막 관리자 삭제/강등 방지용). */

View File

@ -1,5 +1,7 @@
package com.zioinfo.mes.ai; package com.zioinfo.mes.ai;
import com.zioinfo.mes.ai.service.AiTextRouter;
import com.zioinfo.mes.common.ai.TextAiClient.GenResult;
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;
@ -31,11 +33,19 @@ import java.util.Map;
public class AiService { public class AiService {
private final OllamaClient ollama; private final OllamaClient ollama;
/** provider 라우팅(Claude→Ollama 폴백) + 로컬 학습 로그 경유. 기존 OllamaClient 직접 호출을 대체. */
private final AiTextRouter aiTextRouter;
public boolean ollamaAvailable() { public boolean ollamaAvailable() {
return ollama.available(); return ollama.available();
} }
/** 라우터 경유 텍스트 생성 — degraded 면 null(호출부가 Java 폴백 수행). */
private String routeGenerate(String prompt) {
GenResult r = aiTextRouter.generate(prompt);
return (r != null && !r.degraded()) ? r.text() : null;
}
// 1. 불량 원인 분석 (공정/설비/자재 상관) // 1. 불량 원인 분석 (공정/설비/자재 상관)
public Map<String, Object> defectRootCause(String defectCode, List<Map<String, Object>> context) { public Map<String, Object> defectRootCause(String defectCode, List<Map<String, Object>> context) {
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
@ -44,11 +54,11 @@ public class AiService {
"당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. " "당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. "
+ "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.", + "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
defectCode, ctx); defectCode, ctx);
String out = ollama.generate(prompt); String out = routeGenerate(prompt);
if (out != null && !out.isBlank()) { if (out != null && !out.isBlank()) {
result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out)); result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out));
result.put("action", extractLine(out, "ACTION:")); result.put("action", extractLine(out, "ACTION:"));
result.put("source", "ollama"); result.put("source", "ai");
return result; return result;
} }
// Java 폴백: 불량코드 분류 규칙 // Java 폴백: 불량코드 분류 규칙
@ -156,12 +166,12 @@ public class AiService {
Map<String, Object> filter = new LinkedHashMap<>(); Map<String, Object> filter = new LinkedHashMap<>();
if (naturalQuery == null || naturalQuery.isBlank()) return filter; if (naturalQuery == null || naturalQuery.isBlank()) return filter;
String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nITEM:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery; String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nITEM:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery;
String out = ollama.generate(prompt); String out = routeGenerate(prompt);
if (out != null && !out.isBlank()) { if (out != null && !out.isBlank()) {
putIf(filter, "status", extractLine(out, "STATUS:")); putIf(filter, "status", extractLine(out, "STATUS:"));
putIf(filter, "item", extractLine(out, "ITEM:")); putIf(filter, "item", extractLine(out, "ITEM:"));
putIf(filter, "date", extractLine(out, "DATE:")); putIf(filter, "date", extractLine(out, "DATE:"));
if (!filter.isEmpty()) { filter.put("source", "ollama"); return filter; } if (!filter.isEmpty()) { filter.put("source", "ai"); return filter; }
} }
// Java 폴백: 키워드 매칭 // Java 폴백: 키워드 매칭
String q = naturalQuery.toLowerCase(); String q = naturalQuery.toLowerCase();

View File

@ -34,17 +34,26 @@ public class OllamaClient {
this.visionModel = visionModel; this.visionModel = visionModel;
} }
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */ /** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
@SuppressWarnings("unchecked")
public String generate(String prompt) { public String generate(String prompt) {
return generate(prompt, textModel);
}
/**
* 지정 모델로 텍스트 생성(AiTextRouter provider 소형 모델 선택 경유). 실패 문자열.
* 기존 {@link #generate(String)} 계약 보존 오버로드만 추가.
*/
@SuppressWarnings("unchecked")
public String generate(String prompt, String model) {
String useModel = (model == null || model.isBlank()) ? textModel : model.trim();
try { try {
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false); Map<String, Object> body = Map.of("model", useModel, "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(30)) .timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m) .map(m -> (Map<String, Object>) m)
.block(); .block();
if (res == null) return ""; if (res == null) return "";
@ -70,7 +79,7 @@ public class OllamaClient {
.bodyValue(body) .bodyValue(body)
.retrieve() .retrieve()
.bodyToMono(Map.class) .bodyToMono(Map.class)
.timeout(Duration.ofSeconds(45)) .timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m) .map(m -> (Map<String, Object>) m)
.block(); .block();
if (res == null) return ""; if (res == null) return "";

View File

@ -1,22 +1,52 @@
package com.zioinfo.mes.auth; package com.zioinfo.mes.auth;
import com.zioinfo.mes.auth.dto.ChangePasswordRequest;
import com.zioinfo.mes.auth.dto.OtpConfirmRequest;
import com.zioinfo.mes.auth.dto.OtpSetupResponse;
import com.zioinfo.mes.auth.dto.OtpVerifyRequest;
import com.zioinfo.mes.common.ApiResponse; import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.uiws.auth.OtpAuthService;
import com.zioinfo.mes.uiws.auth.TwoFactorService;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.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;
/**
* MES 인증 컨트롤러.
* - /login: 2FA off { token, type, twofa:"false" }, 2FA on 이면 { twofa:"true", verifyToken, verifyMethod|step, ... }.
* - /verify: (UIWS 2FA 이식) verify-token + 이메일코드 access/refresh 발급.
* - /verify-otp: (TOTP 이식) verify-token + 6자리 access/refresh 발급(최초 로그인이면 등록 확정).
* 기존 클라이언트(2FA off) 응답 형태 token 보존 회귀 0.
*/
@RestController @RestController
@RequestMapping("/api/mes/auth") @RequestMapping("/api/mes/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")); }
/** 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")
@ -25,5 +55,50 @@ 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/mes/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) {}
} }

View File

@ -1,30 +1,103 @@
package com.zioinfo.mes.auth; package com.zioinfo.mes.auth;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.auth.dto.AuthHelperResult;
import com.zioinfo.mes.auth.dto.ChangePasswordRequest;
import com.zioinfo.mes.auth.dto.FindIdRequest;
import com.zioinfo.mes.auth.dto.FindIdResponse;
import com.zioinfo.mes.auth.dto.ResetPasswordRequest;
import com.zioinfo.mes.auth.dto.SignupRequest;
import com.zioinfo.mes.auth.mapper.UserMapper; import com.zioinfo.mes.auth.mapper.UserMapper;
import com.zioinfo.mes.uiws.auth.OtpAuthService;
import com.zioinfo.mes.uiws.auth.TwoFactorService;
import com.zioinfo.mes.uiws.common.UiwsApiException;
import com.zioinfo.mes.uiws.common.UiwsErrorCode;
import com.zioinfo.mes.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;
/**
* MES 인증 서비스.
* - 기존 단일 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: { token, type, twofa:"false" }
* 2FA on : { verifyToken, step:"EMAIL", maskedEmail, twofa:"true" }
*/
public Map<String, String> login(String username, String password) {
MesUser user = userMapper.findByUsername(username); MesUser 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);
MesUser 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);
// verifyToken/step/maskedEmail + twofa 플래그
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 +110,121 @@ 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, "이미 등록된 이메일입니다.");
}
MesUser u = new MesUser();
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, "");
}
MesUser 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, "아이디와 이메일을 모두 입력하세요.");
}
MesUser 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 MES] 임시 비밀번호 안내";
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) {
MesUser 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));
}
} }

View File

@ -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/mes/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(

View File

@ -34,6 +34,46 @@ 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 걸러내는 사용.
*/
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();

View File

@ -17,4 +17,24 @@ public class MesUser {
private String role; private String role;
private boolean active; private boolean active;
private LocalDateTime createdAt; private LocalDateTime createdAt;
// UIWS 2FA 이식 (mes_user ALTER, db/91_uiws_port.sql)
/** 2FA 발송 대상 이메일. mes_user 원본 미보유 → 91_uiws_port.sql 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 시크릿(보류/확정 공용). API 응답·로그 미노출. */
private String otpSecret;
/** OTP 등록 확정 여부(최초 로그인 verify 성공 시 true). db/93_auth_otp.sql ALTER. */
private Boolean otpEnabled;
// 로그인 보조 이식(회원가입 승인 게이트) mes_user.approved
/** 회원가입 승인 여부(기본 true). 신규 가입자는 false → SUPERADMIN 승인 전 로그인 차단. */
private Boolean approved;
} }

View File

@ -3,11 +3,89 @@ package com.zioinfo.mes.auth.mapper;
import com.zioinfo.mes.auth.MesUser; import com.zioinfo.mes.auth.MesUser;
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 {
// findByUsername / insert mapper/UserMapper.xml 정의(2FA 컬럼 포함 resultMap).
// 어노테이션 중복 정의 "statement already contains value" 크래시 XML 유지.
MesUser findByUsername(@Param("username") String username); MesUser findByUsername(@Param("username") String username);
int insert(MesUser user); int insert(MesUser user);
// UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE)
/** 로그인 성공 시 실패 카운트 초기화. */
@Update("UPDATE mes_user SET login_fail_count = 0 WHERE username = #{username}")
int resetLoginFail(@Param("username") String username);
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
@Update("""
UPDATE mes_user
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 mes_user
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 mes_user SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}")
int clearEmailCode(@Param("username") String username);
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
@Update("UPDATE mes_user SET locked = false, login_fail_count = 0 WHERE username = #{username}")
int unlock(@Param("username") String username);
// TOTP(OTP 2차 인증) 이식 (멱등 UPDATE)
/** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */
@Update("UPDATE mes_user SET otp_secret = #{secret} WHERE username = #{username}")
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
@Update("UPDATE mes_user SET otp_enabled = true WHERE username = #{username}")
int enableOtp(@Param("username") String username);
/** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */
@Update("UPDATE mes_user SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
int disableOtp(@Param("username") String username);
// 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 (XML 정의)
/** username 존재 여부(회원가입 중복 검사). */
int countByUsername(@Param("username") String username);
/** email 존재 여부(회원가입 중복 검사 — 운영자 계정 한정). */
int countByEmail(@Param("email") String email);
/**
* 회원가입(승인 대기). approved=false·is_active=true·role=VIEWER 고정.
* 관리자 화면에서 활성/승인 전까지 로그인 차단.
*/
int signup(MesUser user);
/** 아이디찾기: 표시명(display_name)+이메일 일치 운영자 1건. */
MesUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
@Param("email") String email);
/** 비밀번호 초기화 대상 검증: username+email 동시 일치 운영자 1건. */
MesUser findByUsernameAndEmail(@Param("username") String username,
@Param("email") String email);
/** 임시 비밀번호 적용 + 잠금/실패카운트 해제(초기화 시). */
int updatePasswordHash(@Param("username") String username,
@Param("passwordHash") String passwordHash);
} }

View File

@ -1,6 +1,7 @@
package com.zioinfo.mes.common; package com.zioinfo.mes.common;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
@ -39,6 +40,14 @@ public class GlobalExceptionHandler {
return ApiResponse.fail(e.getMessage()); return ApiResponse.fail(e.getMessage());
} }
@ExceptionHandler(DataAccessException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleDataAccess(DataAccessException e) {
// 보안 불변 규칙: SQL/테이블/쿼리/스택 상세 절대 미노출 내부 로그만 남기고 일반 메시지 반환
log.error("DB 오류", e);
return ApiResponse.fail("ERR-MES-DB: 데이터 처리 중 오류가 발생했습니다");
}
@ExceptionHandler(RuntimeException.class) @ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleRuntime(RuntimeException e) { public ApiResponse<Void> handleRuntime(RuntimeException e) {

View File

@ -43,9 +43,12 @@ public class SecurityConfig {
.cors(cors -> {}) .cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// 로그인 + 로그인 보조 3종(signup/find-id/reset-password) + 2FA verify 모두 /api/mes/auth/** 하위 permitAll
.requestMatchers("/api/mes/auth/**").permitAll() .requestMatchers("/api/mes/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll() .requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mes/docs/**", "/api/mes/swagger/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mes/docs/**", "/api/mes/swagger/**").permitAll()
// UIWS system 이식: 무인증 공개 조회(회사/부서 룩업) 회원가입 화면 등에서 사용
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
// 정적 프론트 번들 // 정적 프론트 번들
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll() .requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
@ -57,6 +60,9 @@ public class SecurityConfig {
.requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER") .requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER")
.requestMatchers("/api/admin/**").hasRole("SUPERADMIN") .requestMatchers("/api/admin/**").hasRole("SUPERADMIN")
// RAG 기법 토글 변경 MANAGER 이상(운영자 전용). 분석 트리거(POST) 아래 WORKER+ 규칙 적용.
.requestMatchers(HttpMethod.PUT, "/api/mes/rag/toggles/**").hasAnyRole("SUPERADMIN", "MANAGER")
// 변경(실적·검사·입출고·재고이동) WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드) // 변경(실적·검사·입출고·재고이동) WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드)
.requestMatchers(HttpMethod.POST, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") .requestMatchers(HttpMethod.POST, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
.requestMatchers(HttpMethod.PUT, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") .requestMatchers(HttpMethod.PUT, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
@ -65,6 +71,9 @@ public class SecurityConfig {
// 조회 인증 사용자 전체(Viewer+) // 조회 인증 사용자 전체(Viewer+)
.requestMatchers(HttpMethod.GET, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER", "VIEWER") .requestMatchers(HttpMethod.GET, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER", "VIEWER")
// UIWS system(권한관리) 이식 사용자/역할/메뉴/부서/거래처/코드 관리는 SUPERADMIN 전용(RBAC 게이트)
.requestMatchers("/api/system/**").hasRole("SUPERADMIN")
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

View File

@ -15,8 +15,12 @@ spring:
minimum-idle: 1 minimum-idle: 1
sql: sql:
init: init:
mode: ${SQL_INIT_MODE:never} # UIWS 이식: 91_uiws_port.sql(tb_uiws_* + mes_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
schema-locations: classpath:db/schema.sql # 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피).
mode: ${SQL_INIT_MODE:always}
# 91=업무/2FA(tb_uiws_* + mes_user ALTER), 92=권한관리 system, 104=AI 플랫폼 설정 시드(멱등), 105=AI provider claude flip(멱등 UPSERT, 104 이후). 전부 멱등.
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,classpath:db/105_ai_provider_claude.sql
continue-on-error: true
servlet: servlet:
multipart: multipart:
max-file-size: 20MB max-file-size: 20MB
@ -34,15 +38,39 @@ springdoc:
swagger-ui: swagger-ui:
path: /api/mes/swagger path: /api/mes/swagger
# ── UIWS 이식 모듈 설정 (worklog/schedule/message/stats + 2FA 레이어) ──────────
mes:
uiws:
auth:
twofa-enabled: ${UIWS_2FA:true} # off=기존 단일로그인 회귀 0
verify-token-validity-seconds: 300 # 1차 통과 후 verify-token 5분
email-code-validity-seconds: 300 # 이메일 인증코드 5분
max-login-fail: 5 # 실패 5회 누적 시 계정 잠금
mail:
mode: ${UIWS_MAIL_MODE:log} # LogMailSender 폴백(외부 API 0). 운영 smtp 시 별도 빈
upload:
upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws}
# ── AI 로컬 학습 저장소(임베디드 DuckDB) — 피드백/추론 로그. 미가용 시 조용히 비활성(AI 기능 무영향) ──
ai:
duckdb-path: ${MES_DUCKDB_PATH:/opt/guardia-mes/data/mes_learning.duckdb}
guardia: 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}
ocr-url: ${OCR_URL:http://localhost:8005} ocr-url: ${OCR_URL:http://localhost:8005}
bi-url: ${BI_URL:http://localhost:8006} bi-url: ${BI_URL:http://localhost:8006}
# 중앙 guardia-rag (최신 AI 기법: 하이브리드/그래프/리랭크 검색·에이전틱 tool-use·구조화·스트리밍)
# 보안 불변: 온프레미스 루프백 전용. 미가용/타임아웃 시 MES 결정론 로컬 폴백(degraded:true).
rag:
base-url: ${RAG_URL:http://127.0.0.1:8020}
timeout-ms: ${RAG_TIMEOUT_MS:120000}
enabled: ${RAG_ENABLED:true}
solution: mes
# 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지. # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지.
# (예외: Claude 는 소유자 승인으로 api.anthropic.com 단일 경로 허용 — 키는 ANTHROPIC_API_KEY env 로만 주입)
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.2:1b}
ollama-vision-model: ${OLLAMA_VISION_MODEL:llava} ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream}
crypto: crypto:
secret: ${MES_CRYPTO_SECRET:guardia-mes-aes-256-gcm-master-key-2026-zioinfo} secret: ${MES_CRYPTO_SECRET:guardia-mes-aes-256-gcm-master-key-2026-zioinfo}
jwt: jwt:

View File

@ -0,0 +1,30 @@
-- =====================================================================
-- GUARDiA MES — 105. AI provider flip: ollama → claude (멱등 UPSERT)
-- 대상 DB : mes_db (테이블 mes_setting, PK=key)
-- 적용 : application.yml spring.sql.init.schema-locations 에 104 시드 '이후' 등재.
-- mode:always + continue-on-error 재실행 안전.
-- 설명 : 104_seed 는 ai.provider=ollama 를 ON CONFLICT DO NOTHING 으로 시드하므로,
-- 이미 시드된 운영 DB 에서는 provider 가 ollama 로 고정된다.
-- 본 파일이 104 '이후' 실행되어 provider 를 claude 로 flip(UPSERT)한다.
-- 행 부재 시 INSERT, 존재 시 UPDATE → 신규/기존 DB 모두 결정적으로 claude.
-- 실효 : AiConfigService.isClaudeActive() = provider=claude · ANTHROPIC_API_KEY(env) SET · ai.enabled.
-- 키 미설정/Claude 실패 시 AiTextRouter 가 Ollama 자동 폴백(무중단·무회귀).
-- 보안 : 키/시크릿/IP/비밀번호 일절 미포함. 키는 서버 env(ANTHROPIC_API_KEY)로만 주입.
-- 멱등 : INSERT ... ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value — 재실행 안전.
-- =====================================================================
SET client_encoding = 'UTF8';
-- mes_setting 이 (104 미적용 환경에서도) 존재하도록 멱등 보장.
CREATE TABLE IF NOT EXISTS mes_setting (
key VARCHAR(200) PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
-- provider 를 claude 로 flip (신규=INSERT, 기존=UPDATE). 결정적 멱등.
INSERT INTO mes_setting (key, value, updated_at) VALUES
('ai.provider', 'claude', NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW();
-- end 105_ai_provider_claude.sql

View File

@ -41,6 +41,10 @@
UPDATE mes_user SET password_hash = #{passwordHash} WHERE id = #{id} UPDATE mes_user SET password_hash = #{passwordHash} WHERE id = #{id}
</update> </update>
<update id="clearOtp">
UPDATE mes_user SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}
</update>
<delete id="deleteById"> <delete id="deleteById">
DELETE FROM mes_user WHERE id = #{id} DELETE FROM mes_user WHERE id = #{id}
</delete> </delete>

View File

@ -8,7 +8,7 @@
COALESCE(SUM(good_qty),0) AS total_good, COALESCE(SUM(good_qty),0) AS total_good,
COALESCE(SUM(defect_qty),0) AS total_defect, COALESCE(SUM(defect_qty),0) AS total_defect,
ROUND( (COALESCE(SUM(defect_qty),0)::numeric ROUND( (COALESCE(SUM(defect_qty),0)::numeric
/ NULLIF(SUM(good_qty)+SUM(defect_qty),0) * 100), 2) AS defect_rate, / NULLIF(SUM(good_qty)+SUM(defect_qty),0) * 100)::numeric, 2) AS defect_rate,
(SELECT COUNT(*) FROM mes_workorder (SELECT COUNT(*) FROM mes_workorder
WHERE status IN ('DONE','CLOSED') WHERE status IN ('DONE','CLOSED')
AND updated_at &gt;= CURRENT_DATE - (#{days} || ' days')::interval) AS completed_workorders AND updated_at &gt;= CURRENT_DATE - (#{days} || ' days')::interval) AS completed_workorders

View File

@ -11,10 +11,21 @@
<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 이식 컬럼 (mes_user ALTER, db/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 mes_user FROM mes_user
WHERE username = #{username} WHERE username = #{username}
</select> </select>
@ -25,4 +36,48 @@
VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active}) VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active})
</insert> </insert>
<!-- 2FA 실패카운트/잠금/인증코드 갱신은 UserMapper.java 의 @Update 어노테이션에 정의(중복 금지). -->
<!-- ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ─────────────── -->
<select id="countByUsername" resultType="int">
SELECT COUNT(*) FROM mes_user WHERE username = #{username}
</select>
<select id="countByEmail" resultType="int">
SELECT COUNT(*) FROM mes_user WHERE email = #{email}
</select>
<!-- 회원가입(승인 대기): role=VIEWER·is_active=true·approved=false·실패카운트0·미잠금 고정 -->
<insert id="signup" parameterType="com.zioinfo.mes.auth.MesUser"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO mes_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 mes_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 mes_user
WHERE username = #{username} AND email = #{email}
</select>
<!-- 임시비번 적용 + 잠금/실패카운트 해제(초기화 시) -->
<update id="updatePasswordHash">
UPDATE mes_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

File diff suppressed because one or more lines are too long

View File

@ -3,9 +3,10 @@
<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 MES — AI 제조실행시스템</title> <title>GUARDiA MES — AI 제조실행시스템</title>
<script type="module" crossorigin src="/assets/index-BLHiicbF.js"></script> <script type="module" crossorigin src="/assets/index-CesirnPf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwkEm3xg.css"> <link rel="stylesheet" crossorigin href="/assets/index-Bw9GVsq6.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -3,6 +3,7 @@
<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 MES — AI 제조실행시스템</title> <title>GUARDiA MES — AI 제조실행시스템</title>
</head> </head>
<body> <body>

View File

@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Layout from './components/Layout' import Layout from './components/Layout'
import ProtectedRoute from './components/ProtectedRoute' import ProtectedRoute from './components/ProtectedRoute'
import Login from './pages/Login' import Login from './pages/Login'
import MyPage from './pages/MyPage'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
// MES // MES
import Jobs from './pages/Jobs' import Jobs from './pages/Jobs'
@ -35,9 +36,24 @@ import Warehouses from './pages/Warehouses'
// 공통 // 공통
import Analytics from './pages/Analytics' import Analytics from './pages/Analytics'
import AiTools from './pages/AiTools' import AiTools from './pages/AiTools'
import RagSettings from './pages/RagSettings'
import AiPlatformSettings from './pages/AiPlatformSettings'
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 MobileApp from './pages/MobileApp'
// 업무 (UIWS 이식) — 업무일지/일정/쪽지/업무통계
import UiwsWorklog from './pages/uiws/WorklogList'
import UiwsSchedule from './pages/uiws/ScheduleCalendar'
import UiwsMessage from './pages/uiws/MessageBox'
import UiwsStats from './pages/uiws/StatsPivot'
// 시스템관리(권한) (UIWS 이식) — 역할/역할-메뉴권한/공통코드/메뉴/부서/거래처
import SysRoles from './pages/uiws/system/RoleManagement'
import SysRoleMenu from './pages/uiws/system/RoleMenuPermission'
import SysCodes from './pages/uiws/system/CodeManagement'
import SysMenus from './pages/uiws/system/MenuManagement'
import SysDepts from './pages/uiws/system/DeptManagement'
import SysCompanies from './pages/uiws/system/CompanyManagement'
const qc = new QueryClient() const qc = new QueryClient()
@ -49,6 +65,7 @@ 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="/mypage" element={<MyPage />} />
{/* MES */} {/* MES */}
<Route path="/jobs" element={<Jobs />} /> <Route path="/jobs" element={<Jobs />} />
<Route path="/progress" element={<Progress />} /> <Route path="/progress" element={<Progress />} />
@ -82,6 +99,36 @@ export default function App() {
{/* 공통 */} {/* 공통 */}
<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={
<ProtectedRoute min="MANAGER"><RagSettings /></ProtectedRoute>
} />
<Route path="/admin/ai-platform" element={
<ProtectedRoute min="SUPERADMIN"><AiPlatformSettings /></ProtectedRoute>
} />
{/* 업무 (UIWS 이식) — 기존 라우트 보존, 추가만 */}
<Route path="/uiws/worklog" element={<UiwsWorklog />} />
<Route path="/uiws/schedule" element={<UiwsSchedule />} />
<Route path="/uiws/message" element={<UiwsMessage />} />
<Route path="/uiws/stats" element={<UiwsStats />} />
{/* 시스템관리(권한) (UIWS 이식) — SUPERADMIN 전용, 기존 라우트 보존·추가만 */}
<Route path="/uiws/system/roles" element={
<ProtectedRoute min="SUPERADMIN"><SysRoles /></ProtectedRoute>
} />
<Route path="/uiws/system/role-menus" element={
<ProtectedRoute min="SUPERADMIN"><SysRoleMenu /></ProtectedRoute>
} />
<Route path="/uiws/system/codes" element={
<ProtectedRoute min="SUPERADMIN"><SysCodes /></ProtectedRoute>
} />
<Route path="/uiws/system/menus" element={
<ProtectedRoute min="SUPERADMIN"><SysMenus /></ProtectedRoute>
} />
<Route path="/uiws/system/depts" element={
<ProtectedRoute min="SUPERADMIN"><SysDepts /></ProtectedRoute>
} />
<Route path="/uiws/system/companies" element={
<ProtectedRoute min="SUPERADMIN"><SysCompanies /></ProtectedRoute>
} />
{/* 관리자 */} {/* 관리자 */}
<Route path="/admin/users" element={ <Route path="/admin/users" element={
<ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute> <ProtectedRoute min="SUPERADMIN"><UserManagement /></ProtectedRoute>
@ -92,6 +139,7 @@ export default function App() {
<Route path="/admin/settings" element={ <Route path="/admin/settings" element={
<ProtectedRoute min="MANAGER"><SystemSettings /></ProtectedRoute> <ProtectedRoute min="MANAGER"><SystemSettings /></ProtectedRoute>
} /> } />
<Route path="/admin/mobile-app" element={<MobileApp />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>

View File

@ -35,6 +35,15 @@ export const login = (username: string, password: string) =>
api.post('/api/mes/auth/login', { username, password }) api.post('/api/mes/auth/login', { username, password })
export const getMe = () => api.get('/api/mes/auth/me') export const getMe = () => api.get('/api/mes/auth/me')
// ── 2FA / OTP / 계정 보안 (access 토큰 필요, prefix /api/mes/auth) ──────
// 마이페이지 OTP 등록/재설정/해제 + 비밀번호 변경.
// 보안 불변: setup 응답(secret/qrImage)은 화면 표시용만 — 로그/저장 금지.
export const otpSetup = () => api.post('/api/mes/auth/otp/setup')
export const otpConfirm = (code: string) => api.post('/api/mes/auth/otp/confirm', { code })
export const otpDisable = () => api.post('/api/mes/auth/otp/disable')
export const changePassword = (currentPassword: string, newPassword: string) =>
api.post('/api/mes/auth/change-password', { currentPassword, newPassword })
// ── Dashboard ───────────────────────────────────────────────────────── // ── Dashboard ─────────────────────────────────────────────────────────
export const getDashboardSummary = () => unwrap(api.get('/api/mes/dashboard/summary')) export const getDashboardSummary = () => unwrap(api.get('/api/mes/dashboard/summary'))
export const getWorkorderStatus = () => unwrap(api.get('/api/mes/dashboard/workorder-status')) export const getWorkorderStatus = () => unwrap(api.get('/api/mes/dashboard/workorder-status'))
@ -300,6 +309,22 @@ export const aiInspectionJudge = (d: object) => unwrap(api.post('/api/mes/ai/ins
export const aiSafetyStock = (d: object) => unwrap(api.post('/api/mes/ai/safety-stock', d)) export const aiSafetyStock = (d: object) => unwrap(api.post('/api/mes/ai/safety-stock', d))
export const aiScheduleOptimize = (d: object) => unwrap(api.post('/api/mes/ai/schedule-optimize', d)) export const aiScheduleOptimize = (d: object) => unwrap(api.post('/api/mes/ai/schedule-optimize', d))
// ── RAG 최신 AI 기법 (중앙 guardia-rag 경유, 별개 레이어 — 항상 200, 폴백 degraded) ──
// 불량 RCA + SPC 이상감지: SPC 수치는 결정론, 원인 서술은 /rag/agent(+structured)
export const ragDefectAnalysis = (d: object) => unwrap(api.post('/api/mes/rag/defect-analysis', d))
// 설비 예지보전 + 수요/생산 예측: 수치는 결정론 베이스라인, 해석은 /rag/agent
export const ragPredictAnalysis = (d: object) => unwrap(api.post('/api/mes/rag/predict-analysis', d))
// 👍/👎 피드백 (solution=mes 격리)
export const ragFeedback = (d: object) => unwrap(api.post('/api/mes/rag/feedback', d))
// 기법 토글 스냅샷 / 변경(MANAGER+)
export const getRagToggles = () => unwrap(api.get('/api/mes/rag/toggles'))
export const updateRagToggle = (key: string, value: any) =>
unwrap(api.put(`/api/mes/rag/toggles/${key}`, { value }))
// ── AI 피드백 (로컬 DuckDB 학습저장소 + 중앙 rag(8020) 전달, 인증 사용자 전체) ──
export const postAiFeedback = (d: { feature: string; question?: string; answer?: string; verdict: 'up' | 'down'; correction?: string }) =>
unwrap(api.post('/api/ai/feedback', d))
// ── Admin (SUPERADMIN / 감사로그·설정 GET 은 MANAGER+) ──────────────── // ── Admin (SUPERADMIN / 감사로그·설정 GET 은 MANAGER+) ────────────────
export const getUsers = () => api.get('/api/admin/users') export const getUsers = () => api.get('/api/admin/users')
export const createUser = (data: { username: string; password: string; displayName?: string; role?: string }) => export const createUser = (data: { username: string; password: string; displayName?: string; role?: string }) =>
@ -308,6 +333,8 @@ 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 = 200) => export const getAuditLogs = (action = '', actor = '', limit = 200) =>
api.get(`/api/admin/audit${qs({ action, actor, limit })}`) api.get(`/api/admin/audit${qs({ action, actor, limit })}`)
export const getSettings = () => api.get('/api/admin/settings') export const getSettings = () => api.get('/api/admin/settings')

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, Link } 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>
<span className="flex items-center gap-1.5 text-sm text-slate-300"> <Link to="/mypage" className="flex items-center gap-1.5 text-sm text-slate-300 hover:text-brand" title="마이페이지">
<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>}
</span> </Link>
<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>

View File

@ -5,9 +5,11 @@ import {
Gauge, Network, CalendarRange, PackagePlus, Truck, Boxes, ClipboardCheck, Barcode, Gauge, Network, CalendarRange, PackagePlus, Truck, Boxes, ClipboardCheck, Barcode,
ScanSearch, Ruler, AlertTriangle, ListChecks, LineChart, FileBadge, Package, ListTree, ScanSearch, Ruler, AlertTriangle, ListChecks, LineChart, FileBadge, Package, ListTree,
Route as RouteIcon, Building2, Warehouse, BarChart3, Sparkles, ShieldCheck, ScrollText, Route as RouteIcon, Building2, Warehouse, BarChart3, Sparkles, ShieldCheck, ScrollText,
Settings, Cpu, Settings, Cpu, NotebookPen, CalendarDays, Mail, PieChart, Smartphone,
KeyRound, ShieldHalf, ListTodo, Menu as MenuIcon, Users2, Contact,
} 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 }
@ -48,13 +50,35 @@ const masterLinks: Link[] = [
] ]
const commonLinks: Link[] = [ const commonLinks: Link[] = [
{ to: '/analytics', label: '분석 / KPI', icon: BarChart3 }, { to: '/analytics', label: '분석 / KPI', icon: BarChart3 },
{ to: '/ai-tools', label: 'AI 도구', icon: Sparkles }, { to: '/ai-tools', label: 'WISE AI', icon: Sparkles },
{ to: '/ai-techniques', label: 'AI 기법 설정', icon: Cpu, min: 'MANAGER' },
]
// 업무 (UIWS 이식) — 업무일지/일정/쪽지/업무통계
const uiwsLinks: Link[] = [
{ to: '/uiws/worklog', label: '업무일지', icon: NotebookPen },
{ to: '/uiws/schedule', label: '일정', icon: CalendarDays },
{ to: '/uiws/message', label: '쪽지', icon: Mail },
{ to: '/uiws/stats', label: '업무통계', icon: PieChart },
]
// 시스템관리(권한) (UIWS 이식) — SUPERADMIN 전용
const sysLinks: Link[] = [
{ to: '/uiws/system/roles', label: '권한 관리', icon: KeyRound, min: 'SUPERADMIN' },
{ to: '/uiws/system/role-menus', label: '역할-메뉴 권한', icon: ShieldHalf, min: 'SUPERADMIN' },
{ to: '/uiws/system/codes', label: '공통코드', icon: ListTodo, min: 'SUPERADMIN' },
{ to: '/uiws/system/menus', label: '메뉴 관리', icon: MenuIcon, min: 'SUPERADMIN' },
{ to: '/uiws/system/depts', label: '부서 관리', icon: Users2, min: 'SUPERADMIN' },
{ to: '/uiws/system/companies', label: '거래처 관리', icon: Contact, min: 'SUPERADMIN' },
] ]
const adminLinks: Link[] = [ const adminLinks: Link[] = [
{ to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' }, { to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' },
{ to: '/admin/ai-platform', label: 'AI 플랫폼 설정', icon: Cpu, min: 'SUPERADMIN' },
{ to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'MANAGER' }, { to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'MANAGER' },
{ to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'MANAGER' }, { to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'MANAGER' },
] ]
// 통합 메신저 앱(읽기전용 QR 설치) — 인증 사용자 전체
const mobileLinks: Link[] = [
{ to: '/admin/mobile-app', label: '모바일 앱 설치', icon: Smartphone },
]
const linkClass = ({ isActive }: { isActive: boolean }) => const linkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 px-5 py-1.5 text-sm transition-colors ${ `flex items-center gap-3 px-5 py-1.5 text-sm transition-colors ${
@ -93,6 +117,7 @@ export default function Sidebar() {
return () => { window.clearInterval(t); window.removeEventListener('storage', sync) } return () => { window.clearInterval(t); window.removeEventListener('storage', sync) }
}, []) }, [])
const visibleSys = sysLinks.filter(l => hasRole(l.min || 'VIEWER', role))
const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role)) const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role))
return ( return (
@ -111,13 +136,25 @@ export default function Sidebar() {
<Section title="QMS 품질관리" links={qmsLinks} role={role} /> <Section title="QMS 품질관리" links={qmsLinks} role={role} />
<Section title="기준정보" links={masterLinks} role={role} /> <Section title="기준정보" links={masterLinks} role={role} />
<Section title="공통" links={commonLinks} role={role} /> <Section title="공통" links={commonLinks} role={role} />
<Section title="업무 (UIWS)" links={uiwsLinks} role={role} />
<div className="border-t border-edge mt-3">
<Section title="모바일" links={mobileLinks} role={role} />
</div>
{visibleSys.length > 0 && (
<div className="border-t border-edge mt-3">
<Section title="시스템관리(권한)" links={visibleSys} role={role} />
</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} role={role} /> <Section title="관리자" links={visibleAdmin} role={role} />
</div> </div>
)} )}
</nav> </nav>
<div className="p-4 text-[11px] text-slate-500 border-t border-edge"> <div className="p-3 border-t border-edge">
<ThemeToggle />
</div>
<div className="px-4 pb-4 text-[11px] text-slate-500">
Ollama · WMS·MES·QMS Ollama · WMS·MES·QMS
</div> </div>
</aside> </aside>

View File

@ -2,9 +2,13 @@ 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'
import './theme/theme.css'
import { ThemeProvider } from './theme/ThemeContext'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider>
<App /> <App />
</ThemeProvider>
</React.StrictMode> </React.StrictMode>
) )

View File

@ -1,13 +1,117 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { import {
Sparkles, AlertTriangle, TrendingUp, Wrench, LineChart, Search, Boxes, CalendarClock, Sparkles, AlertTriangle, TrendingUp, Wrench, LineChart, Search, Boxes, CalendarClock,
Cpu, ThumbsUp, ThumbsDown, Quote,
} from 'lucide-react' } from 'lucide-react'
import { Card, Btn, Field } from '../components/ui' import { Card, Btn, Field } from '../components/ui'
import { import {
getAiStatus, aiDefectRootCause, aiForecast, aiPredictiveMaintenance, getAiStatus, aiDefectRootCause, aiForecast, aiPredictiveMaintenance,
aiSpcAnomaly, aiParseQuery, aiSafetyStock, aiScheduleOptimize, aiSpcAnomaly, aiParseQuery, aiSafetyStock, aiScheduleOptimize,
ragDefectAnalysis, ragPredictAnalysis, ragFeedback, postAiFeedback,
} from '../api/client' } from '../api/client'
/** 적용된 기법 배지 — 응답의 applied metadata 를 시각화(토글 effect 관측). */
function AppliedBadge({ data }: { data: any }) {
const a = data?.applied
if (!a) return null
const chips: string[] = [`mode:${a.retrievalMode}`, `기법:${a.technique}`]
if (a.rerank) chips.push('rerank')
if (a.graphrag) chips.push('graphrag')
if (a.hybrid) chips.push('hybrid')
if (a.toolUse) chips.push(`agent(${a.maxSteps})`)
if (a.structured) chips.push('structured')
if (a.stream) chips.push('stream')
if (data?.degraded) chips.push('⚠ degraded(폴백)')
return (
<div className="mt-2 flex flex-wrap gap-1">
{chips.map((c, i) => (
<span key={i} className={`text-[10px] px-1.5 py-0.5 rounded border ${c.startsWith('⚠') ? 'bg-amber-500/15 text-amber-300 border-amber-500/30' : 'bg-brand/10 text-brand border-brand/30'}`}>{c}</span>
))}
</div>
)
}
/** 👍/👎 피드백 (중앙 /rag/feedback, solution=mes 격리). answerId 가 있을 때만 노출. */
function FeedbackBar({ answerId, query }: { answerId?: string; query?: string }) {
const [done, setDone] = useState('')
if (!answerId) return null
const send = async (verdict: 'up' | 'down') => {
try { await ragFeedback({ answerId, query, verdict }); setDone(verdict === 'up' ? '👍 반영됨' : '👎 반영됨') } catch { setDone('전송 실패') }
}
return (
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
<span> ?</span>
<button onClick={() => send('up')} className="p-1 rounded hover:bg-brand/10 hover:text-brand"><ThumbsUp size={13} /></button>
<button onClick={() => send('down')} className="p-1 rounded hover:bg-rose-500/10 hover:text-rose-300"><ThumbsDown size={13} /></button>
{done && <span className="text-brand">{done}</span>}
</div>
)
}
/** 👍/👎 로컬 학습 피드백 (POST /api/ai/feedback → 로컬 DuckDB + 중앙 rag). 결과가 있을 때만 노출. */
function LocalFeedbackBar({ feature, question, answer }: { feature: string; question?: string; answer?: any }) {
const [done, setDone] = useState('')
if (answer == null) return null
const ans = typeof answer === 'string' ? answer : JSON.stringify(answer)
const send = async (verdict: 'up' | 'down') => {
try { await postAiFeedback({ feature, question, answer: ans, verdict }); setDone(verdict === 'up' ? '👍 반영됨' : '👎 반영됨') }
catch { setDone('전송 실패') }
}
return (
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
<span> ?</span>
<button onClick={() => send('up')} className="p-1 rounded hover:bg-brand/10 hover:text-brand"><ThumbsUp size={13} /></button>
<button onClick={() => send('down')} className="p-1 rounded hover:bg-rose-500/10 hover:text-rose-300"><ThumbsDown size={13} /></button>
{done && <span className="text-brand">{done}</span>}
</div>
)
}
/** 인용 라벨 — 문서명·위치·근거지지도. citation/source 객체 형태 방어적 처리. */
function citeLabel(c: any): string {
if (c == null) return '문서'
if (typeof c === 'string') return c
const src = c.source || c.document || c.doc || c.title || c.chunk_id || c.id || '문서'
const loc = c.page != null ? ` p.${c.page}` : (c.location ? ` ${c.location}` : '')
const sup = c.support != null ? ` · ${Math.round(Number(c.support) * 100)}%` : ''
return `${src}${loc}${sup}`
}
/**
* WISE UX (sources/citations) + (abstained) .
* node = defect / interpretation . · .
*/
function WiseEvidence({ node }: { node: any }) {
if (node == null) return null
const citations: any[] = Array.isArray(node.citations) ? node.citations : []
const sources: any[] = Array.isArray(node.sources) ? node.sources : []
const items = citations.length ? citations : sources
const abstained = node.abstained === true
if (!abstained && items.length === 0) return null
return (
<div className="mt-3">
{abstained && (
<div className="mb-2 flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-300">
<AlertTriangle size={13} /> ( · )
</div>
)}
{items.length > 0 ? (
<>
<div className="text-[11px] text-slate-400 mb-1.5 flex items-center gap-1"><Quote size={12} /> ({items.length})</div>
<div className="flex flex-wrap gap-1.5">
{items.map((c, i) => (
<span key={i} className="text-[10px] px-2 py-1 rounded-md bg-card border border-edge text-slate-300">{citeLabel(c)}</span>
))}
</div>
</>
) : (
<div className="text-[11px] text-slate-500"> </div>
)}
</div>
)
}
function Output({ data }: { data: any }) { function Output({ data }: { data: any }) {
if (data == null) return null if (data == null) return null
if (typeof data === 'string') return <pre className="mt-3 text-xs text-slate-300 bg-ink border border-edge rounded p-3 whitespace-pre-wrap max-h-56 overflow-auto">{data}</pre> if (typeof data === 'string') return <pre className="mt-3 text-xs text-slate-300 bg-ink border border-edge rounded p-3 whitespace-pre-wrap max-h-56 overflow-auto">{data}</pre>
@ -22,8 +126,8 @@ export default function AiTools() {
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
<h1 className="text-xl font-bold flex items-center gap-2"><Sparkles className="text-accent" size={20} /> AI </h1> <h1 className="text-xl font-bold flex items-center gap-2"><Sparkles className="text-accent" size={20} /> WISE AI</h1>
<p className="text-sm text-slate-400 mt-0.5"> · · · SPC이상 · · · (Ollama + Java )</p> <p className="text-sm text-slate-400 mt-0.5">Enterprise AI for Trusted Knowledge · ··( guardia-rag) + ···SPC이상 (Ollama + Java )</p>
</div> </div>
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${ollama ? 'text-accent border-accent/30 bg-accent/10' : 'text-slate-400 border-edge bg-card'}`}> <span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border ${ollama ? 'text-accent border-accent/30 bg-accent/10' : 'text-slate-400 border-edge bg-card'}`}>
<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'}`} />
@ -31,6 +135,17 @@ export default function AiTools() {
</span> </span>
</div> </div>
{/* ── 최신 기법 (중앙 guardia-rag 경유) — 대표 2개 기능 ───────────── */}
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold flex items-center gap-2 text-brand"><Cpu size={15} /> WISE AI · (RAG · · )</h2>
<Link to="/ai-techniques" className="text-xs text-slate-400 hover:text-brand underline"> </Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 mb-8">
<RagDefectTool /><RagPredictTool />
</div>
{/* ── 기존 AI 도구 (불변, Ollama + Java 폴백) ───────────────────── */}
<h2 className="text-sm font-semibold text-slate-300 mb-3"> AI </h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<DefectTool /><ForecastTool /><MaintenanceTool /><SpcTool /> <DefectTool /><ForecastTool /><MaintenanceTool /><SpcTool />
<QueryTool /><SafetyStockTool /><ScheduleTool /> <QueryTool /><SafetyStockTool /><ScheduleTool />
@ -39,6 +154,87 @@ export default function AiTools() {
) )
} }
/** 대표 1: 불량 원인분석 + SPC 이상감지 (SPC 수치=결정론, 원인서술=/rag/agent+structured). */
function RagDefectTool() {
const [code, setCode] = useState(''); const [ctx, setCtx] = useState('')
const [values, setValues] = useState(''); const [ucl, setUcl] = useState(''); const [lcl, setLcl] = useState(''); const [cl, setCl] = useState('')
const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
const run = async () => {
setBusy(true)
try {
const vals = values.split(',').map(s => s.trim()).filter(Boolean).map(Number)
const req: any = { defectCode: code, context: ctx ? [{ note: ctx }] : [] }
if (vals.length) { req.values = vals; req.ucl = Number(ucl) || 0; req.lcl = Number(lcl) || 0; req.cl = Number(cl) || 0 }
setOut(await ragDefectAnalysis(req))
} finally { setBusy(false) }
}
return (
<Card title="불량 원인분석 + SPC 이상감지 (RAG)" icon={<AlertTriangle size={15} />}>
<Field label="불량 코드"><input className="inp" value={code} onChange={e => setCode(e.target.value)} placeholder="예: D-DIM-01" /></Field>
<Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field>
<Field label="SPC 표본값(쉼표구분, 선택)"><input className="inp" value={values} onChange={e => setValues(e.target.value)} placeholder="예: 10.1, 10.3, 9.8, 10.5" /></Field>
<div className="grid grid-cols-3 gap-2">
<Field label="UCL"><input className="inp" value={ucl} onChange={e => setUcl(e.target.value)} /></Field>
<Field label="LCL"><input className="inp" value={lcl} onChange={e => setLcl(e.target.value)} /></Field>
<Field label="CL"><input className="inp" value={cl} onChange={e => setCl(e.target.value)} /></Field>
</div>
<Btn onClick={run} disabled={busy} size="sm"><Cpu size={13} /> {busy ? '분석 중…' : 'RCA + SPC 분석'}</Btn>
<AppliedBadge data={out} />
<Output data={out} />
<WiseEvidence node={out?.defect} />
<FeedbackBar answerId={out?.defect?.answerId} query={code} />
</Card>
)
}
/** 대표 2: 설비 예지보전 / 수요·생산 예측 (수치=결정론 베이스라인, 해석=/rag/agent). */
function RagPredictTool() {
const [kind, setKind] = useState<'pdm' | 'forecast'>('forecast')
const [eq, setEq] = useState(''); const [avail, setAvail] = useState(''); const [dt, setDt] = useState(''); const [mtbf, setMtbf] = useState('')
const [series, setSeries] = useState(''); const [horizon, setHorizon] = useState(7)
const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
const run = async () => {
setBusy(true)
try {
const req: any = { kind }
if (kind === 'pdm') { req.equipmentCode = eq; req.availability = Number(avail) || 0; req.downtimeCount = Number(dt) || 0; req.mtbfHours = Number(mtbf) || 0 }
else { req.series = series.split(',').map(s => s.trim()).filter(Boolean).map(Number); req.horizon = horizon }
setOut(await ragPredictAnalysis(req))
} finally { setBusy(false) }
}
return (
<Card title="예지보전 / 수요·생산 예측 (RAG)" icon={<TrendingUp size={15} />}>
<div className="flex gap-2 mb-2">
{(['forecast', 'pdm'] as const).map(k => (
<button key={k} onClick={() => setKind(k)} className={`px-3 py-1.5 text-xs rounded-lg border ${kind === k ? 'bg-brand text-ink border-brand' : 'bg-card border-edge text-slate-300'}`}>
{k === 'forecast' ? '수요/생산 예측' : '설비 예지보전'}
</button>
))}
</div>
{kind === 'pdm' ? (
<>
<Field label="설비 코드"><input className="inp" value={eq} onChange={e => setEq(e.target.value)} placeholder="예: EQ-CNC-01" /></Field>
<div className="grid grid-cols-3 gap-2">
<Field label="가동률(0~1)"><input className="inp" value={avail} onChange={e => setAvail(e.target.value)} /></Field>
<Field label="비가동 횟수"><input className="inp" value={dt} onChange={e => setDt(e.target.value)} /></Field>
<Field label="MTBF(시간)"><input className="inp" value={mtbf} onChange={e => setMtbf(e.target.value)} /></Field>
</div>
</>
) : (
<>
<Field label="시계열(쉼표구분)"><input className="inp" value={series} onChange={e => setSeries(e.target.value)} placeholder="예: 100, 120, 95, 130" /></Field>
<Field label="예측 기간(일)"><input type="number" className="inp" value={horizon} onChange={e => setHorizon(Number(e.target.value))} /></Field>
</>
)}
<Btn onClick={run} disabled={busy} size="sm"><Cpu size={13} /> {busy ? '분석 중…' : '예측 + 해석'}</Btn>
<AppliedBadge data={out} />
<Output data={out} />
<WiseEvidence node={out?.interpretation} />
<FeedbackBar answerId={out?.interpretation?.answerId} query={kind} />
</Card>
)
}
function DefectTool() { function DefectTool() {
const [code, setCode] = useState(''); const [ctx, setCtx] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false) const [code, setCode] = useState(''); const [ctx, setCtx] = useState(''); const [out, setOut] = useState<any>(null); const [busy, setBusy] = useState(false)
const run = async () => { setBusy(true); try { setOut(await aiDefectRootCause({ defectCode: code, context: ctx })) } finally { setBusy(false) } } const run = async () => { setBusy(true); try { setOut(await aiDefectRootCause({ defectCode: code, context: ctx })) } finally { setBusy(false) } }
@ -48,6 +244,7 @@ function DefectTool() {
<Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field> <Field label="컨텍스트(공정/설비/자재)"><textarea className="inp" value={ctx} onChange={e => setCtx(e.target.value)} /></Field>
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '분석 중…' : '원인 분석'}</Btn> <Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '분석 중…' : '원인 분석'}</Btn>
<Output data={out} /> <Output data={out} />
<LocalFeedbackBar feature="defect-root-cause" question={code} answer={out} />
</Card> </Card>
) )
} }
@ -99,6 +296,7 @@ function QueryTool() {
<Field label="자연어 질의"><input className="inp" value={q} onChange={e => setQ(e.target.value)} placeholder="예: 지난주 지연된 작업지시" onKeyDown={e => e.key === 'Enter' && run()} /></Field> <Field label="자연어 질의"><input className="inp" value={q} onChange={e => setQ(e.target.value)} placeholder="예: 지난주 지연된 작업지시" onKeyDown={e => e.key === 'Enter' && run()} /></Field>
<Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '해석 중…' : '질의 해석'}</Btn> <Btn onClick={run} disabled={busy} size="sm"><Sparkles size={13} /> {busy ? '해석 중…' : '질의 해석'}</Btn>
<Output data={out} /> <Output data={out} />
<LocalFeedbackBar feature="parse-query" question={q} answer={out} />
</Card> </Card>
) )
} }

View File

@ -1,21 +1,83 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Cpu } from 'lucide-react' import { Cpu, ShieldCheck } from 'lucide-react'
import { login, getMe } from '../api/client' import { login, getMe } from '../api/client'
import { verify2fa, verifyOtp, signup, findId, resetPassword } from '../api/uiws'
type HelperMode = 'signup' | 'find-id' | 'reset-password'
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
/**
* GUARDiA MES UIWS 2FA + Authenticator(OTP) .
* 1 login twofa="true" verifyMethod 2 (verifyToken ):
* · OTP : Authenticator 6 /api/mes/auth/verify-otp
* · OTP_SETUP : QR(qrImage)+(secret) 6 /verify-otp ( )
* · EMAIL(): 6 /api/mes/auth/verify ()
* twofa!="true"(off) ( 0). ·릿·QR / .
*/
export default function Login() { export default function Login() {
const [username, setUsername] = useState('admin') const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [step, setStep] = useState<'login' | 'verify'>('login')
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 [code, setCode] = useState('')
const nav = useNavigate() const nav = useNavigate()
const submit = async (e: React.FormEvent) => { const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
const isSetup = verifyMethod === 'OTP_SETUP'
// ── 로그인 보조 3종(회원가입/아이디찾기/비밀번호초기화) 모달 상태 ──────────────
const [helper, setHelper] = useState<HelperMode | null>(null)
const [hForm, setHForm] = useState({ username: '', password: '', displayName: '', email: '' })
const [hBusy, setHBusy] = useState(false)
const [hMsg, setHMsg] = useState('') // 결과 안내(성공/실패 공통)
const [hErr, setHErr] = useState(false) // 메시지 톤(에러 여부)
const openHelper = (mode: HelperMode) => {
setHelper(mode); setHForm({ username: '', password: '', displayName: '', email: '' })
setHMsg(''); setHErr(false)
}
const closeHelper = () => { setHelper(null); setHMsg(''); setHErr(false) }
// 보조 기능 제출 — 응답 봉투 {success,message,data}. 임시비번/존재여부는 서버가 메시지로만 안내.
const submitHelper = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setErr(''); setBusy(true) setHBusy(true); setHMsg(''); setHErr(false)
try { try {
const res = await login(username, password) let res
const token = res.data?.data?.token if (helper === 'signup') {
res = await signup({ username: hForm.username.trim(), password: hForm.password,
displayName: hForm.displayName.trim() || undefined, email: hForm.email.trim() })
const d = res.data?.data
setHErr(!d?.success); setHMsg(d?.message || '처리되었습니다.')
} else if (helper === 'find-id') {
res = await findId({ displayName: hForm.displayName.trim(), email: hForm.email.trim() })
const d = res.data?.data
if (d?.found) { setHErr(false); setHMsg(`회원님의 아이디는 [${d.maskedUsername}] 입니다.`) }
else { setHErr(true); setHMsg('일치하는 계정을 찾을 수 없습니다. 이름과 이메일을 확인하세요.') }
} else if (helper === 'reset-password') {
res = await resetPassword({ username: hForm.username.trim(), email: hForm.email.trim() })
const d = res.data?.data
setHErr(!d?.success); setHMsg(d?.message || '요청이 처리되었습니다.')
}
} catch {
setHErr(true); setHMsg('요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도하세요.')
} finally {
setHBusy(false)
}
}
const helperTitle = helper === 'signup' ? '회원가입(승인 대기)'
: helper === 'find-id' ? '아이디 찾기' : '비밀번호 초기화'
// access 토큰 저장 + getMe 로 role/username 보관(Sidebar RBAC 가드 의존) → 대시보드 이동
const finish = async (token?: string) => {
if (!token) throw new Error('no token') if (!token) throw new Error('no token')
localStorage.setItem('mes_token', token) localStorage.setItem('mes_token', token)
try { try {
@ -25,6 +87,26 @@ export default function Login() {
if (d.username) localStorage.setItem('mes_user', d.username) if (d.username) localStorage.setItem('mes_user', d.username)
} catch { /* noop */ } } catch { /* noop */ }
nav('/dashboard') nav('/dashboard')
}
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setErr(''); setBusy(true)
try {
const res = await login(username, password)
const data = res.data?.data || {}
if (data.twofa === 'true') {
// 2단계: verify-token 보관 후 verifyMethod 로 분기(OTP/OTP_SETUP/EMAIL)
setVerifyToken(data.verifyToken)
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
setMaskedEmail(data.maskedEmail || '')
setQrImage(data.qrImage || '')
setSecret(data.secret || '')
setCode('')
setStep('verify')
return
}
await finish(data.token)
} catch { } catch {
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.') setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
} finally { } finally {
@ -32,26 +114,168 @@ export default function Login() {
} }
} }
const submitCode = 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())
await finish(res.data?.data?.token)
} catch {
setErr('인증 코드가 올바르지 않거나 만료되었습니다.')
} finally {
setBusy(false)
}
}
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={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8"> <form onSubmit={step === 'login' ? submit : submitCode}
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">
<Cpu className="text-brand" size={32} /> <Cpu className="text-brand" size={32} />
<span className="text-xl font-bold">GUARDiA MES</span> <span className="text-xl font-bold">GUARDiA MES</span>
</div> </div>
<p className="text-center text-sm text-slate-400 mb-6">AI · WMS · MES · QMS</p> <p className="text-center text-sm text-slate-400 mb-6">AI · WMS · MES · QMS</p>
{step === 'login' ? (
<>
<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)} <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)} <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" />
</>
) : (
<>
<div className="flex items-center gap-2 justify-center mb-3">
<ShieldCheck size={18} className="text-brand" />
<span className="text-sm font-semibold">2 </span>
</div>
{isOtp ? (
isSetup ? (
<>
<p className="text-center text-xs 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-xs text-slate-400 mb-4">
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 focus:border-brand outline-none tracking-[0.4em] text-center" />
</>
)}
{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 || (step === 'verify' && code.length !== 6)}
{busy ? '로그인 중…' : '로그인'} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{busy ? '처리 중…' : step === 'login' ? '로그인' : isSetup ? '등록하고 로그인' : '인증 확인'}
</button> </button>
<p className="text-center text-[11px] text-slate-500 mt-4">admin / manager / worker · admin123</p> {step === 'verify' && (
<button type="button"
onClick={() => { setStep('login'); setCode(''); setErr(''); setQrImage(''); setSecret('') }}
className="w-full mt-2 py-2 rounded-lg text-xs text-slate-400 hover:text-slate-200">
</button>
)}
{step === 'login' && (
<>
{/* 로그인 보조 3종 링크 (UIWS auth 패턴 이식) */}
<div className="flex items-center justify-center gap-2 text-[11px] text-slate-400 mt-4">
<button type="button" onClick={() => openHelper('signup')} className="hover:text-brand"></button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => openHelper('find-id')} className="hover:text-brand"> </button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => openHelper('reset-password')} className="hover:text-brand"> </button>
</div>
<p className="text-center text-[11px] text-slate-500 mt-3">admin / manager / worker · admin123</p>
</>
)}
</form> </form>
{/* 로그인 보조 모달 (MES 다크 테마 토큰 재사용 — 하드코딩 색상 없음) */}
{helper && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onClick={closeHelper}>
<form onClick={e => e.stopPropagation()} onSubmit={submitHelper}
className="w-[360px] bg-panel border border-edge rounded-2xl p-7">
<div className="flex items-center justify-between mb-5">
<span className="text-base font-bold">{helperTitle}</span>
<button type="button" onClick={closeHelper}
className="text-slate-400 hover:text-slate-200 text-lg leading-none">×</button>
</div>
{/* 회원가입: 아이디·비번·이름·이메일 / 아이디찾기: 이름·이메일 / 비번초기화: 아이디·이메일 */}
{(helper === 'signup' || helper === 'reset-password') && (
<>
<label className="block text-xs text-slate-400 mb-1"></label>
<input value={hForm.username} onChange={e => setHForm({ ...hForm, username: e.target.value })}
className="w-full mb-3 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
</>
)}
{helper === 'signup' && (
<>
<label className="block text-xs text-slate-400 mb-1"> (4 )</label>
<input type="password" value={hForm.password} onChange={e => setHForm({ ...hForm, password: e.target.value })}
className="w-full mb-3 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
</>
)}
{(helper === 'signup' || helper === 'find-id') && (
<>
<label className="block text-xs text-slate-400 mb-1"></label>
<input value={hForm.displayName} onChange={e => setHForm({ ...hForm, displayName: e.target.value })}
className="w-full mb-3 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>
<input type="email" value={hForm.email} onChange={e => setHForm({ ...hForm, email: 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" />
{hMsg && (
<p className={`text-xs mb-3 ${hErr ? 'text-rose-400' : 'text-emerald-400'}`}>{hMsg}</p>
)}
<button disabled={hBusy}
className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{hBusy ? '처리 중…' : (helper === 'signup' ? '가입 신청' : helper === 'find-id' ? '아이디 찾기' : '초기화 요청')}
</button>
{helper === 'signup' && (
<p className="text-[11px] text-slate-500 mt-3"> .</p>
)}
</form>
</div>
)}
</div> </div>
) )
} }

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Plus, Trash2, KeyRound, Power, ShieldCheck } from 'lucide-react' import { Plus, Trash2, KeyRound, Power, ShieldCheck, RefreshCw } from 'lucide-react'
import { MES_ROLES } from '../components/rbac' import { MES_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,6 +37,10 @@ export default function UserManagement() {
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 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)) }
const otpReset = (u: User) => {
if (!window.confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`)) return
wrap(() => adminOtpReset(u.id))
}
return ( return (
<div> <div>
@ -81,6 +86,7 @@ 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>