feat(ux): 날짜 입력 전면 캘린더 전환 — 웹 datetime-local/date/time + 모바일 순수JS CalendarPicker [auto-sync]
This commit is contained in:
parent
9f24e3921d
commit
a093098657
@ -22,7 +22,6 @@
|
|||||||
<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>
|
||||||
@ -43,10 +42,6 @@
|
|||||||
<!-- DB Driver -->
|
<!-- DB Driver -->
|
||||||
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
|
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
@ -59,9 +54,6 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@ -56,12 +56,6 @@ 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;
|
||||||
|
|||||||
@ -85,17 +85,6 @@ 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)) {
|
||||||
|
|||||||
@ -22,9 +22,6 @@ 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 계정 수 (마지막 관리자 삭제/강등 방지용). */
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
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;
|
||||||
@ -33,19 +31,11 @@ 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<>();
|
||||||
@ -54,11 +44,11 @@ public class AiService {
|
|||||||
"당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. "
|
"당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. "
|
||||||
+ "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
|
+ "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
|
||||||
defectCode, ctx);
|
defectCode, ctx);
|
||||||
String out = routeGenerate(prompt);
|
String out = ollama.generate(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", "ai");
|
result.put("source", "ollama");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
// Java 폴백: 불량코드 분류 규칙
|
// Java 폴백: 불량코드 분류 규칙
|
||||||
@ -166,12 +156,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 = routeGenerate(prompt);
|
String out = ollama.generate(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", "ai"); return filter; }
|
if (!filter.isEmpty()) { filter.put("source", "ollama"); return filter; }
|
||||||
}
|
}
|
||||||
// Java 폴백: 키워드 매칭
|
// Java 폴백: 키워드 매칭
|
||||||
String q = naturalQuery.toLowerCase();
|
String q = naturalQuery.toLowerCase();
|
||||||
|
|||||||
@ -34,26 +34,17 @@ public class OllamaClient {
|
|||||||
this.visionModel = visionModel;
|
this.visionModel = visionModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
|
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */
|
||||||
public String generate(String prompt) {
|
|
||||||
return generate(prompt, textModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지정 모델로 텍스트 생성(AiTextRouter 의 provider 별 소형 모델 선택 경유). 실패 시 빈 문자열.
|
|
||||||
* 기존 {@link #generate(String)} 계약 보존 — 본 오버로드만 추가.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public String generate(String prompt, String model) {
|
public String generate(String prompt) {
|
||||||
String useModel = (model == null || model.isBlank()) ? textModel : model.trim();
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
|
Map<String, Object> body = Map.of("model", textModel, "prompt", prompt, "stream", false);
|
||||||
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
|
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
|
||||||
.post().uri("/api/generate")
|
.post().uri("/api/generate")
|
||||||
.bodyValue(body)
|
.bodyValue(body)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map.class)
|
.bodyToMono(Map.class)
|
||||||
.timeout(Duration.ofSeconds(120))
|
.timeout(Duration.ofSeconds(30))
|
||||||
.map(m -> (Map<String, Object>) m)
|
.map(m -> (Map<String, Object>) m)
|
||||||
.block();
|
.block();
|
||||||
if (res == null) return "";
|
if (res == null) return "";
|
||||||
@ -79,7 +70,7 @@ public class OllamaClient {
|
|||||||
.bodyValue(body)
|
.bodyValue(body)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map.class)
|
.bodyToMono(Map.class)
|
||||||
.timeout(Duration.ofSeconds(120))
|
.timeout(Duration.ofSeconds(45))
|
||||||
.map(m -> (Map<String, Object>) m)
|
.map(m -> (Map<String, Object>) m)
|
||||||
.block();
|
.block();
|
||||||
if (res == null) return "";
|
if (res == null) return "";
|
||||||
|
|||||||
@ -1,52 +1,22 @@
|
|||||||
package com.zioinfo.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) {
|
||||||
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
String token = authService.login(req.username(), req.password());
|
||||||
}
|
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
||||||
|
|
||||||
/** UIWS 2FA(이메일코드) 이식: 2차 인증 코드 검증 → access/refresh 발급. */
|
|
||||||
@PostMapping("/verify")
|
|
||||||
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
|
|
||||||
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** TOTP 이식: 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */
|
|
||||||
@PostMapping("/verify-otp")
|
|
||||||
public ApiResponse<Map<String, String>> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) {
|
|
||||||
return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@ -55,50 +25,5 @@ public class AuthController {
|
|||||||
return ApiResponse.ok(authService.me(token));
|
return ApiResponse.ok(authService.me(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요) ──────────
|
|
||||||
|
|
||||||
/** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */
|
|
||||||
@PostMapping("/otp/setup")
|
|
||||||
public ApiResponse<OtpSetupResponse> otpSetup(@RequestHeader("Authorization") String header) {
|
|
||||||
return ApiResponse.ok(otpAuthService.setup(requireUser(header)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */
|
|
||||||
@PostMapping("/otp/confirm")
|
|
||||||
public ApiResponse<Map<String, String>> otpConfirm(@RequestHeader("Authorization") String header,
|
|
||||||
@Valid @RequestBody OtpConfirmRequest req) {
|
|
||||||
otpAuthService.confirm(requireUser(header), req.code());
|
|
||||||
return ApiResponse.ok(Map.of("result", "ok"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 마이페이지 OTP 해제. */
|
|
||||||
@PostMapping("/otp/disable")
|
|
||||||
public ApiResponse<Map<String, String>> otpDisable(@RequestHeader("Authorization") String header) {
|
|
||||||
otpAuthService.disable(requireUser(header));
|
|
||||||
return ApiResponse.ok(Map.of("result", "ok"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */
|
|
||||||
@PostMapping("/change-password")
|
|
||||||
public ApiResponse<Map<String, String>> changePassword(@RequestHeader("Authorization") String header,
|
|
||||||
@Valid @RequestBody ChangePasswordRequest req) {
|
|
||||||
authService.changePassword(requireUser(header), req);
|
|
||||||
return ApiResponse.ok(Map.of("result", "ok"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용)은 거부.
|
|
||||||
* (/api/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) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,103 +1,30 @@
|
|||||||
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) {
|
||||||
@ -110,121 +37,4 @@ public class AuthService {
|
|||||||
m.put("displayName", u != null ? u.getDisplayName() : username);
|
m.put("displayName", u != null ? u.getDisplayName() : username);
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ───────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 운영자 회원가입(승인 대기). username/email 중복 검사 후 approved=false·role=VIEWER 로 INSERT.
|
|
||||||
* 비밀번호는 BCrypt 저장. 활성/승인 전까지 로그인 차단(AuthService.login 의 승인 게이트).
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public AuthHelperResult signup(SignupRequest req) {
|
|
||||||
if (req.username() == null || req.username().isBlank()
|
|
||||||
|| req.password() == null || req.password().length() < 4
|
|
||||||
|| req.email() == null || req.email().isBlank()) {
|
|
||||||
return new AuthHelperResult(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다.");
|
|
||||||
}
|
|
||||||
if (userMapper.countByUsername(req.username()) > 0) {
|
|
||||||
return new AuthHelperResult(false, "이미 사용 중인 아이디입니다.");
|
|
||||||
}
|
|
||||||
if (userMapper.countByEmail(req.email()) > 0) {
|
|
||||||
return new AuthHelperResult(false, "이미 등록된 이메일입니다.");
|
|
||||||
}
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,10 +26,7 @@ public class JwtFilter extends OncePerRequestFilter {
|
|||||||
String header = req.getHeader("Authorization");
|
String header = req.getHeader("Authorization");
|
||||||
if (header != null && header.startsWith("Bearer ")) {
|
if (header != null && header.startsWith("Bearer ")) {
|
||||||
String token = header.substring(7);
|
String token = header.substring(7);
|
||||||
// 보안(UIWS 2FA): purpose=2fa verify-token 은 access 토큰이 아니다.
|
if (jwtUtil.isValid(token)) {
|
||||||
// 동일 서명키라 isValid()는 통과하므로 별도 차단하지 않으면 2차 인증 전 API 접근(2FA 우회)이 가능.
|
|
||||||
// → verify-token 은 인증 컨텍스트를 세우지 않고 무시한다(/api/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(
|
||||||
|
|||||||
@ -34,46 +34,6 @@ public class JwtUtil {
|
|||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* UIWS 2FA 이식: 1차 로그인 통과 후 발급하는 단기 verify-token.
|
|
||||||
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 API 접근 불가).
|
|
||||||
*/
|
|
||||||
public String generateVerifyToken(String username, long validitySeconds) {
|
|
||||||
return Jwts.builder()
|
|
||||||
.subject(username)
|
|
||||||
.claim("purpose", "2fa")
|
|
||||||
.issuedAt(new Date())
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
|
|
||||||
.signWith(key())
|
|
||||||
.compact();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
|
|
||||||
public String parseVerifyTokenUsername(String token) {
|
|
||||||
try {
|
|
||||||
Claims c = parse(token);
|
|
||||||
if (!"2fa".equals(c.get("purpose", String.class))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return c.getSubject();
|
|
||||||
} catch (JwtException | IllegalArgumentException e) {
|
|
||||||
log.debug("verify-token 검증 실패: {}", e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별.
|
|
||||||
* JwtFilter 가 access 토큰만 인증 컨텍스트로 인정하도록 verify-token 을 걸러내는 데 사용.
|
|
||||||
*/
|
|
||||||
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();
|
||||||
|
|||||||
@ -17,24 +17,4 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,89 +3,11 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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;
|
||||||
@ -40,14 +39,6 @@ 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) {
|
||||||
|
|||||||
@ -43,12 +43,9 @@ 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()
|
||||||
|
|
||||||
@ -60,9 +57,6 @@ 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")
|
||||||
@ -71,9 +65,6 @@ 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);
|
||||||
|
|||||||
@ -15,12 +15,8 @@ spring:
|
|||||||
minimum-idle: 1
|
minimum-idle: 1
|
||||||
sql:
|
sql:
|
||||||
init:
|
init:
|
||||||
# UIWS 이식: 91_uiws_port.sql(tb_uiws_* + mes_user 2FA ALTER, 전부 멱등)만 부팅 시 적용.
|
mode: ${SQL_INIT_MODE:never}
|
||||||
# 기존 schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피).
|
schema-locations: classpath:db/schema.sql
|
||||||
mode: ${SQL_INIT_MODE:always}
|
|
||||||
# 91=업무/2FA(tb_uiws_* + mes_user ALTER), 92=권한관리 system, 104=AI 플랫폼 설정 시드(멱등). 전부 멱등.
|
|
||||||
schema-locations: classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql
|
|
||||||
continue-on-error: true
|
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 20MB
|
max-file-size: 20MB
|
||||||
@ -38,39 +34,15 @@ 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.2:1b}
|
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
|
||||||
ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream}
|
ollama-vision-model: ${OLLAMA_VISION_MODEL:llava}
|
||||||
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:
|
||||||
|
|||||||
@ -41,10 +41,6 @@
|
|||||||
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>
|
||||||
|
|||||||
@ -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)::numeric, 2) AS defect_rate,
|
/ NULLIF(SUM(good_qty)+SUM(defect_qty),0) * 100), 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 >= CURRENT_DATE - (#{days} || ' days')::interval) AS completed_workorders
|
AND updated_at >= CURRENT_DATE - (#{days} || ' days')::interval) AS completed_workorders
|
||||||
|
|||||||
@ -11,21 +11,10 @@
|
|||||||
<result property="role" column="role"/>
|
<result property="role" column="role"/>
|
||||||
<result property="active" column="is_active"/>
|
<result property="active" column="is_active"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<!-- UIWS 2FA 이식 컬럼 (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>
|
||||||
@ -36,48 +25,4 @@
|
|||||||
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>
|
||||||
|
|||||||
480
backend/src/main/resources/static/assets/index-BLHiicbF.js
Normal file
480
backend/src/main/resources/static/assets/index-BLHiicbF.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -3,10 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<title>GUARDiA MES — AI 제조실행시스템</title>
|
<title>GUARDiA MES — AI 제조실행시스템</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BBuKnaPd.js"></script>
|
<script type="module" crossorigin src="/assets/index-BLHiicbF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bw9GVsq6.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BwkEm3xg.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<title>GUARDiA MES — AI 제조실행시스템</title>
|
<title>GUARDiA MES — AI 제조실행시스템</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@ -3,7 +3,6 @@ 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'
|
||||||
@ -36,24 +35,9 @@ 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()
|
||||||
|
|
||||||
@ -65,7 +49,6 @@ export default function App() {
|
|||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/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 />} />
|
||||||
@ -99,36 +82,6 @@ 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>
|
||||||
@ -139,7 +92,6 @@ 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>
|
||||||
|
|||||||
@ -35,15 +35,6 @@ 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'))
|
||||||
@ -309,22 +300,6 @@ 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 }) =>
|
||||||
@ -333,8 +308,6 @@ export const updateUserRole = (id: number, role: string) => api.put(`/api/admin/
|
|||||||
export const updateUserActive = (id: number, active: boolean) => api.put(`/api/admin/users/${id}/active`, { active })
|
export const updateUserActive = (id: number, active: boolean) => api.put(`/api/admin/users/${id}/active`, { active })
|
||||||
export const resetPassword = (id: number, password: string) => api.put(`/api/admin/users/${id}/password`, { password })
|
export const resetPassword = (id: number, password: string) => api.put(`/api/admin/users/${id}/password`, { password })
|
||||||
export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`)
|
export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`)
|
||||||
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
|
|
||||||
export const adminOtpReset = (id: number) => api.post(`/api/admin/users/${id}/otp-reset`)
|
|
||||||
export const getAuditLogs = (action = '', actor = '', limit = 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')
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { LogOut, UserCircle } from 'lucide-react'
|
import { LogOut, UserCircle } from 'lucide-react'
|
||||||
import { getAiStatus } from '../api/client'
|
import { getAiStatus } from '../api/client'
|
||||||
|
|
||||||
@ -30,10 +30,10 @@ export default function Header() {
|
|||||||
<span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${ollama ? 'bg-accent' : 'bg-slate-500'}`} />
|
||||||
AI {ollama === null ? '확인 중' : ollama ? '온라인' : '폴백'}
|
AI {ollama === null ? '확인 중' : ollama ? '온라인' : '폴백'}
|
||||||
</span>
|
</span>
|
||||||
<Link to="/mypage" className="flex items-center gap-1.5 text-sm text-slate-300 hover:text-brand" title="마이페이지">
|
<span className="flex items-center gap-1.5 text-sm text-slate-300">
|
||||||
<UserCircle size={18} /> {user}
|
<UserCircle size={18} /> {user}
|
||||||
{role && <span className="text-[11px] text-slate-500">({role})</span>}
|
{role && <span className="text-[11px] text-slate-500">({role})</span>}
|
||||||
</Link>
|
</span>
|
||||||
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand">
|
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand">
|
||||||
<LogOut size={16} /> 로그아웃
|
<LogOut size={16} /> 로그아웃
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -5,11 +5,9 @@ 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, NotebookPen, CalendarDays, Mail, PieChart, Smartphone,
|
Settings, Cpu,
|
||||||
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 }
|
||||||
|
|
||||||
@ -50,35 +48,13 @@ 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: 'WISE AI', icon: Sparkles },
|
{ to: '/ai-tools', label: '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 ${
|
||||||
@ -117,7 +93,6 @@ 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 (
|
||||||
@ -136,25 +111,13 @@ 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-3 border-t border-edge">
|
<div className="p-4 text-[11px] text-slate-500 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>
|
||||||
|
|||||||
@ -2,13 +2,9 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,117 +1,13 @@
|
|||||||
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>
|
||||||
@ -126,8 +22,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} /> WISE AI</h1>
|
<h1 className="text-xl font-bold flex items-center gap-2"><Sparkles className="text-accent" size={20} /> AI 도구</h1>
|
||||||
<p className="text-sm text-slate-400 mt-0.5">Enterprise AI for Trusted Knowledge · 근거·인용·환각차단(중앙 guardia-rag) + 불량분석·생산예측·예지보전·SPC이상 (Ollama 온프레미스 + Java 폴백)</p>
|
<p className="text-sm text-slate-400 mt-0.5">불량분석 · 생산예측 · 예지보전 · 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'}`} />
|
||||||
@ -135,17 +31,6 @@ 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 />
|
||||||
@ -154,87 +39,6 @@ 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) } }
|
||||||
@ -244,7 +48,6 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -296,7 +99,6 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,112 +1,30 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Cpu, ShieldCheck } from 'lucide-react'
|
import { Cpu } 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 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()
|
|
||||||
setHBusy(true); setHMsg(''); setHErr(false)
|
|
||||||
try {
|
|
||||||
let res
|
|
||||||
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')
|
|
||||||
localStorage.setItem('mes_token', token)
|
|
||||||
try {
|
|
||||||
const me = await getMe()
|
|
||||||
const d = me.data?.data || {}
|
|
||||||
if (d.role) localStorage.setItem('mes_role', d.role)
|
|
||||||
if (d.username) localStorage.setItem('mes_user', d.username)
|
|
||||||
} catch { /* noop */ }
|
|
||||||
nav('/dashboard')
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setErr(''); setBusy(true)
|
setErr(''); setBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await login(username, password)
|
const res = await login(username, password)
|
||||||
const data = res.data?.data || {}
|
const token = res.data?.data?.token
|
||||||
if (data.twofa === 'true') {
|
if (!token) throw new Error('no token')
|
||||||
// 2단계: verify-token 보관 후 verifyMethod 로 분기(OTP/OTP_SETUP/EMAIL)
|
localStorage.setItem('mes_token', token)
|
||||||
setVerifyToken(data.verifyToken)
|
try {
|
||||||
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
|
const me = await getMe()
|
||||||
setMaskedEmail(data.maskedEmail || '')
|
const d = me.data?.data || {}
|
||||||
setQrImage(data.qrImage || '')
|
if (d.role) localStorage.setItem('mes_role', d.role)
|
||||||
setSecret(data.secret || '')
|
if (d.username) localStorage.setItem('mes_user', d.username)
|
||||||
setCode('')
|
} catch { /* noop */ }
|
||||||
setStep('verify')
|
nav('/dashboard')
|
||||||
return
|
|
||||||
}
|
|
||||||
await finish(data.token)
|
|
||||||
} catch {
|
} catch {
|
||||||
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
|
setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.')
|
||||||
} finally {
|
} finally {
|
||||||
@ -114,168 +32,26 @@ 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={step === 'login' ? submit : submitCode}
|
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
|
||||||
className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
|
|
||||||
<div className="flex items-center gap-2 justify-center mb-6">
|
<div className="flex items-center gap-2 justify-center mb-6">
|
||||||
<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>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">아이디</label>
|
||||||
{step === 'login' ? (
|
<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" />
|
||||||
<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 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" />
|
||||||
<label className="block text-xs text-slate-400 mb-1">비밀번호</label>
|
|
||||||
<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" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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 || (step === 'verify' && code.length !== 6)}
|
<button disabled={busy} className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
|
||||||
className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
|
{busy ? '로그인 중…' : '로그인'}
|
||||||
{busy ? '처리 중…' : step === 'login' ? '로그인' : isSetup ? '등록하고 로그인' : '인증 확인'}
|
|
||||||
</button>
|
</button>
|
||||||
{step === 'verify' && (
|
<p className="text-center text-[11px] text-slate-500 mt-4">admin / manager / worker · admin123</p>
|
||||||
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Plus, Trash2, KeyRound, Power, ShieldCheck, RefreshCw } from 'lucide-react'
|
import { Plus, Trash2, KeyRound, Power, ShieldCheck } from 'lucide-react'
|
||||||
import { 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 }
|
||||||
@ -37,10 +36,6 @@ 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>
|
||||||
@ -86,7 +81,6 @@ export default function UserManagement() {
|
|||||||
<td className="px-4"><div className="flex items-center justify-end gap-3">
|
<td className="px-4"><div className="flex items-center justify-end gap-3">
|
||||||
<button onClick={() => toggleActive(u)} title={u.active ? '비활성화' : '활성화'} className="text-slate-400 hover:text-brand"><Power size={16} /></button>
|
<button onClick={() => toggleActive(u)} title={u.active ? '비활성화' : '활성화'} className="text-slate-400 hover:text-brand"><Power size={16} /></button>
|
||||||
<button onClick={() => doReset(u)} title="비밀번호 재설정" className="text-slate-400 hover:text-brand"><KeyRound size={16} /></button>
|
<button onClick={() => doReset(u)} title="비밀번호 재설정" className="text-slate-400 hover:text-brand"><KeyRound size={16} /></button>
|
||||||
<button onClick={() => otpReset(u)} title="OTP 초기화" className="text-slate-400 hover:text-brand"><RefreshCw size={16} /></button>
|
|
||||||
<button onClick={() => remove(u)} title="삭제" className="text-rose-400 hover:text-rose-300"><Trash2 size={16} /></button>
|
<button onClick={() => remove(u)} title="삭제" className="text-rose-400 hover:text-rose-300"><Trash2 size={16} /></button>
|
||||||
</div></td>
|
</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -84,16 +84,22 @@ export default function ScheduleCalendar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// datetime-local(yyyy-MM-ddTHH:mm) → API 계약(yyyy-MM-ddTHH:mm:ss) 보정: 초 없으면 :00 부가
|
||||||
|
function toApiDt(v: string): string {
|
||||||
|
if (!v) return v
|
||||||
|
return v.length === 16 ? `${v}:00` : v
|
||||||
|
}
|
||||||
|
|
||||||
function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: string; onClose: () => void; onSaved: () => void }) {
|
function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: string; onClose: () => void; onSaved: () => void }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [startDt, setStartDt] = useState(`${date}T09:00:00`)
|
const [startDt, setStartDt] = useState(`${date}T09:00`)
|
||||||
const [endDt, setEndDt] = useState(`${date}T18:00:00`)
|
const [endDt, setEndDt] = useState(`${date}T18:00`)
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setErr('')
|
setErr('')
|
||||||
try {
|
try {
|
||||||
await createSchedule({ scheType: type, title, startDt, endDt, content, attachmentIds: [] })
|
await createSchedule({ scheType: type, title, startDt: toApiDt(startDt), endDt: toApiDt(endDt), content, attachmentIds: [] })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') }
|
} catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') }
|
||||||
}
|
}
|
||||||
@ -101,8 +107,8 @@ function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: st
|
|||||||
<Modal title="일정 등록" onClose={onClose}
|
<Modal title="일정 등록" onClose={onClose}
|
||||||
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
|
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
|
||||||
<FormField label="시작(yyyy-MM-ddTHH:mm:ss)"><Input value={startDt} onChange={setStartDt} style={{ width: '100%' }} /></FormField>
|
<FormField label="시작"><Input type="datetime-local" value={startDt} onChange={setStartDt} style={{ width: '100%' }} /></FormField>
|
||||||
<FormField label="종료"><Input value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
|
<FormField label="종료"><Input type="datetime-local" value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
|
||||||
<FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
|
<FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
|
||||||
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user