From 94705cf9415fe137850d3cc22f6e45c4919cc744 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Sat, 11 Jul 2026 10:59:07 +0900 Subject: [PATCH] =?UTF-8?q?chore(deploy):=20HRM=20=ED=81=B4=EB=A6=B0=20?= =?UTF-8?q?=EC=9E=AC=EB=8F=99=EA=B8=B0=ED=99=94=20+=20Claude=20provider=20?= =?UTF-8?q?flip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workspace→repos 전체 소스 클린 재동기화(부분 auto-sync 컴파일불가 해소) - db/202_ai_provider_claude.sql: hrm_settings ai.provider UPSERT claude(멱등, 104 시드 이후) - application.yml schema-locations 에 202 등재(mode=always) - 키 미설정/실패 시 AiTextRouter 가 Ollama 자동 폴백(무중단·무회귀) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/pom.xml | 6 + .../zioinfo/hrm/admin/AdminController.java | 25 ++ .../com/zioinfo/hrm/admin/AdminMapper.java | 2 + .../java/com/zioinfo/hrm/ai/AiService.java | 44 ++-- .../com/zioinfo/hrm/auth/AuthController.java | 81 +++++- .../com/zioinfo/hrm/auth/AuthService.java | 194 ++++++++++++++- .../java/com/zioinfo/hrm/auth/HrmUser.java | 16 ++ .../java/com/zioinfo/hrm/auth/JwtFilter.java | 5 +- .../java/com/zioinfo/hrm/auth/JwtUtil.java | 41 +++ .../zioinfo/hrm/auth/mapper/UserMapper.java | 114 +++++++++ .../zioinfo/hrm/config/SecurityConfig.java | 8 + backend/src/main/resources/application.yml | 36 ++- .../resources/db/202_ai_provider_claude.sql | 22 ++ .../src/main/resources/mapper/AdminMapper.xml | 4 + .../src/main/resources/mapper/UserMapper.xml | 23 +- frontend/index.html | 2 +- frontend/src/App.tsx | 63 ++++- frontend/src/api/client.ts | 27 ++ frontend/src/index.css | 42 ++++ frontend/src/pages/AdminPage.tsx | 31 ++- frontend/src/pages/LoginPage.tsx | 233 +++++++++++++++--- 21 files changed, 942 insertions(+), 77 deletions(-) create mode 100644 backend/src/main/resources/db/202_ai_provider_claude.sql diff --git a/backend/pom.xml b/backend/pom.xml index fa16c7c..808494f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -42,11 +42,17 @@ org.postgresqlpostgresql${postgresql.version} + + org.duckdbduckdb_jdbc1.1.3 + io.jsonwebtokenjjwt-api${jjwt.version} io.jsonwebtokenjjwt-impl${jjwt.version}runtime io.jsonwebtokenjjwt-jackson${jjwt.version}runtime + + dev.samstevens.totptotp1.7.1 + org.springdoc diff --git a/backend/src/main/java/com/zioinfo/hrm/admin/AdminController.java b/backend/src/main/java/com/zioinfo/hrm/admin/AdminController.java index ca94dea..60e4e71 100644 --- a/backend/src/main/java/com/zioinfo/hrm/admin/AdminController.java +++ b/backend/src/main/java/com/zioinfo/hrm/admin/AdminController.java @@ -51,6 +51,31 @@ public class AdminController { return ApiResponse.ok(null); } + /** + * 관리자 OTP 초기화(SUPERADMIN). 대상 사용자의 OTP 시크릿을 폐기하고 등록을 해제한다. + * 사용자는 다음 로그인 시 OTP_SETUP(QR 재등록) 플로우를 탄다. 시크릿은 응답/로그에 노출하지 않는다. + */ + @PostMapping("/users/{id}/otp-reset") + public ApiResponse resetOtp(@PathVariable Long id, Authentication auth) { + HrmUser user = adminMapper.findUserById(id); + if (user == null) { + throw new RuntimeException("ERR-HRM-404: 대상 사용자를 찾을 수 없습니다"); + } + adminMapper.clearOtp(id); + + Map log = new HashMap<>(); + log.put("actor", AuthSupport.actor(auth)); + log.put("action", "USER_OTP_RESET"); + log.put("targetType", "auth.otp"); + log.put("targetId", user.getUsername()); + log.put("detail", "OTP 초기화(다음 로그인 재등록)"); + log.put("ipAddr", null); + adminMapper.insertAuditLog(log); + + user.setPasswordHash(null); // 비밀번호 해시 미노출 + return ApiResponse.ok(user); + } + @GetMapping("/audit") public ApiResponse> audit( @RequestParam(required = false) String actor, diff --git a/backend/src/main/java/com/zioinfo/hrm/admin/AdminMapper.java b/backend/src/main/java/com/zioinfo/hrm/admin/AdminMapper.java index e69d8a8..1c2c54e 100644 --- a/backend/src/main/java/com/zioinfo/hrm/admin/AdminMapper.java +++ b/backend/src/main/java/com/zioinfo/hrm/admin/AdminMapper.java @@ -14,6 +14,8 @@ public interface AdminMapper { int insertUser(HrmUser user); int updateUser(HrmUser user); int updateUserActive(@Param("id") Long id, @Param("active") boolean active); + /** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false(다음 로그인 시 재등록 유도). 시크릿 미노출. */ + int clearOtp(@Param("id") Long id); List> findAuditLogs(@Param("actor") String actor, @Param("action") String action, @Param("offset") int offset, diff --git a/backend/src/main/java/com/zioinfo/hrm/ai/AiService.java b/backend/src/main/java/com/zioinfo/hrm/ai/AiService.java index cb2df98..9325dcc 100644 --- a/backend/src/main/java/com/zioinfo/hrm/ai/AiService.java +++ b/backend/src/main/java/com/zioinfo/hrm/ai/AiService.java @@ -1,37 +1,27 @@ package com.zioinfo.hrm.ai; +import com.zioinfo.hrm.ai.service.AiTextRouter; +import com.zioinfo.hrm.common.ai.TextAiClient.GenResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.client.WebClient; -import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; /** - * Ollama 온프레미스 AI 인사 분석 서비스. - * 외부 AI API 절대 금지 — localhost:11434 만 사용. - * Ollama 응답 실패 시 Java 폴백 로직으로 대체. + * AI 인사 분석 서비스. 텍스트 생성은 {@link AiTextRouter} 경유(provider 선택: Claude ↔ Ollama). + * 외부 AI API 는 소유자 승인 예외인 Claude(api.anthropic.com) 만 허용, 그 외 온프레미스 Ollama 전용. + * Claude/Ollama 응답 실패 시 Java 폴백 로직으로 대체(무회귀). */ @Slf4j @Service @RequiredArgsConstructor public class AiService { - @Value("${guardia.ollama-url:http://localhost:11434}") - private String ollamaUrl; - - @Value("${guardia.ollama-text-model:llama3}") - private String textModel; - - private WebClient ollamaClient() { - return WebClient.builder().baseUrl(ollamaUrl) - .codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024)) - .build(); - } + /** provider 라우터(Claude→Ollama 폴백 + 추론 로그). 기존 Ollama 직접호출을 대체. */ + private final AiTextRouter aiTextRouter; public Map predictTurnover(Long empId) { try { @@ -117,16 +107,12 @@ public class AiService { } private Map callOllama(String prompt) { - Map body = Map.of("model", textModel, "prompt", prompt, "stream", false); - @SuppressWarnings("unchecked") - Map resp = ollamaClient().post().uri("/api/generate") - .bodyValue(body) - .retrieve() - .bodyToMono(Map.class) - .timeout(Duration.ofSeconds(30)) - .block(); - if (resp == null) return fallbackTurnover(); - String response = (String) resp.getOrDefault("response", "{}"); + // provider 라우터 경유: provider=claude·키설정·활성 → Claude, 실패/그외 → Ollama(선택모델) 폴백. + GenResult gr = aiTextRouter.generate(prompt); + if (gr.degraded() || gr.text() == null || gr.text().isBlank()) { + return fallbackTurnover(); + } + String response = gr.text(); // JSON 추출 int start = response.indexOf('{'); int end = response.lastIndexOf('}'); @@ -134,10 +120,10 @@ public class AiService { // 간단 파싱: 실제로는 Jackson 사용 Map r = new HashMap<>(); r.put("raw", response.substring(start, end + 1)); - r.put("source", "ollama"); + r.put("source", "ai"); return r; } - return Map.of("response", response, "source", "ollama"); + return Map.of("response", response, "source", "ai"); } private Map fallbackTurnover() { diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/AuthController.java b/backend/src/main/java/com/zioinfo/hrm/auth/AuthController.java index 3345745..a32e872 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/AuthController.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/AuthController.java @@ -1,22 +1,54 @@ package com.zioinfo.hrm.auth; +import com.zioinfo.hrm.auth.dto.ChangePasswordRequest; +import com.zioinfo.hrm.auth.dto.OtpConfirmRequest; +import com.zioinfo.hrm.auth.dto.OtpSetupResponse; +import com.zioinfo.hrm.auth.dto.OtpVerifyRequest; import com.zioinfo.hrm.common.ApiResponse; +import com.zioinfo.hrm.uiws.auth.OtpAuthService; +import com.zioinfo.hrm.uiws.auth.TwoFactorService; +import com.zioinfo.hrm.uiws.common.UiwsApiException; +import com.zioinfo.hrm.uiws.common.UiwsErrorCode; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * HRM 인증 컨트롤러. + * - /login: 2FA off 면 { token, type, twofa:"false" }. + * OTP on 이면 { twofa:"true", verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }. + * 이메일 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }. + * - /verify-otp: (UIMS 방식) verify-token + 6자리 → access/refresh(최초 로그인이면 등록 확정). + * - /verify: (이메일 2FA) verify-token + 인증코드 → access/refresh. + * 기존 클라이언트(2FA off)는 응답에 token 보존 → 회귀 0. + */ @RestController @RequestMapping("/api/hrm/auth") @RequiredArgsConstructor public class AuthController { private final AuthService authService; + private final TwoFactorService twoFactorService; + private final OtpAuthService otpAuthService; + private final JwtUtil jwtUtil; @PostMapping("/login") public ApiResponse> login(@RequestBody LoginRequest req) { - String token = authService.login(req.username(), req.password()); - return ApiResponse.ok(Map.of("token", token, "type", "Bearer")); + return ApiResponse.ok(authService.login(req.username(), req.password())); + } + + /** 이메일 2FA 이식: 2차 인증 코드 검증 → access/refresh 발급. */ + @PostMapping("/verify") + public ApiResponse> verify(@RequestBody VerifyRequest req) { + return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code())); + } + + /** TOTP 이식: 로그인 2단계 6자리 검증 → access/refresh 발급(최초 로그인이면 등록 확정). */ + @PostMapping("/verify-otp") + public ApiResponse> verifyOtp(@Valid @RequestBody OtpVerifyRequest req) { + return ApiResponse.ok(otpAuthService.verifyOtp(req.verifyToken(), req.code())); } @GetMapping("/me") @@ -25,5 +57,50 @@ public class AuthController { return ApiResponse.ok(authService.me(token)); } + // ── 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요) ────────── + + /** 마이페이지 OTP 등록/재설정 시작 → { secret, otpAuthUri, qrImage }(이 응답에서만 시크릿/QR 노출). */ + @PostMapping("/otp/setup") + public ApiResponse otpSetup(@RequestHeader("Authorization") String header) { + return ApiResponse.ok(otpAuthService.setup(requireUser(header))); + } + + /** 마이페이지 OTP 확인·활성화(앱 6자리 코드). */ + @PostMapping("/otp/confirm") + public ApiResponse> 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> otpDisable(@RequestHeader("Authorization") String header) { + otpAuthService.disable(requireUser(header)); + return ApiResponse.ok(Map.of("result", "ok")); + } + + /** 마이페이지 비밀번호 변경(현재 비번 검증 + BCrypt). 새 비번은 응답/로그 미포함. */ + @PostMapping("/change-password") + public ApiResponse> changePassword(@RequestHeader("Authorization") String header, + @Valid @RequestBody ChangePasswordRequest req) { + authService.changePassword(requireUser(header), req); + return ApiResponse.ok(Map.of("result", "ok")); + } + + /** + * Authorization 헤더에서 인증 사용자를 해석한다. verify-token(2fa 단계용)은 거부. + * (/api/hrm/auth/** 는 permitAll 이라 필터 인증이 없으므로 여기서 access 토큰을 명시 검증.) + */ + private String requireUser(String header) { + String token = header == null ? "" : header.replace("Bearer ", "").trim(); + if (token.isEmpty() || jwtUtil.isVerifyToken(token) || !jwtUtil.isValid(token)) { + throw new UiwsApiException(UiwsErrorCode.UNAUTHORIZED); + } + return jwtUtil.getUsername(token); + } + record LoginRequest(String username, String password) {} + + record VerifyRequest(String verifyToken, String code) {} } diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/AuthService.java b/backend/src/main/java/com/zioinfo/hrm/auth/AuthService.java index e9a4bd1..14043d6 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/AuthService.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/AuthService.java @@ -1,13 +1,35 @@ package com.zioinfo.hrm.auth; +import com.zioinfo.hrm.auth.dto.FindIdRequest; +import com.zioinfo.hrm.auth.dto.FindIdResponse; +import com.zioinfo.hrm.auth.dto.ResetPwRequest; +import com.zioinfo.hrm.auth.dto.SignupRequest; +import com.zioinfo.hrm.auth.dto.SignupResponse; +import com.zioinfo.hrm.auth.dto.ChangePasswordRequest; import com.zioinfo.hrm.auth.mapper.UserMapper; +import com.zioinfo.hrm.uiws.auth.OtpAuthService; +import com.zioinfo.hrm.uiws.auth.TwoFactorService; +import com.zioinfo.hrm.uiws.common.UiwsApiException; +import com.zioinfo.hrm.uiws.common.UiwsErrorCode; +import com.zioinfo.hrm.uiws.common.mail.MailSender; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.security.SecureRandom; import java.util.HashMap; import java.util.Map; +/** + * HRM 인증 서비스. + * - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 시 회귀 0). + * - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 후 verify-token + 이메일코드 발급. + * 실패 누적 max-login-fail 회 시 계정 잠금. + * - UIWS 로그인 보조 3종(회원가입·아이디찾기·비밀번호 초기화) 이식. + */ +@Slf4j @Service @RequiredArgsConstructor public class AuthService { @@ -15,16 +37,66 @@ public class AuthService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; + private final TwoFactorService twoFactorService; + private final OtpAuthService otpAuthService; + private final MailSender mailSender; - public String login(String username, String password) { + private static final SecureRandom RANDOM = new SecureRandom(); + // 혼동 문자(0/O/1/l/I) 제외 — 임시비번 가독성. + private static final String TMP_PW_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; + + /** + * 1차 로그인. 2FA 활성 시 verify-token + 이메일코드 흐름으로 분기, + * 비활성 시 기존처럼 access 토큰 즉시 발급. + * + * @return 2FA off: { token, type, twofa:"false" } + * 2FA on : { verifyToken, step:"EMAIL", maskedEmail, twofa:"true" } + */ + public Map login(String username, String password) { HrmUser user = userMapper.findByUsername(username); + + // 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 — 존재 여부 누설 최소화) + if (user != null && twoFactorService.isLocked(user)) { + throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED); + } if (user == null || !user.isActive()) { throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정"); } + // 회원가입 승인 게이트(UIWS 로그인 보조 이식): 가입 신청자(approved=false)는 관리자 승인 전 로그인 차단. + // 기존 계정은 approved=true(91_uiws_port.sql 멱등 보정)라 회귀 없음. + if (Boolean.FALSE.equals(user.getApproved())) { + throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 로그인하세요."); + } if (!passwordEncoder.matches(password, user.getPasswordHash())) { + // 2FA(OTP 또는 이메일) 활성 시 실패 누적/잠금. 비활성 시 기존 동작(메시지만) 유지. + if (otpAuthService.isEnabled() || twoFactorService.isEnabled()) { + twoFactorService.recordLoginFailure(username); + HrmUser after = userMapper.findByUsername(username); + if (after != null && Boolean.TRUE.equals(after.getLocked())) { + throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED); + } + } throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치"); } - 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 step1 = twoFactorService.beginTwoFactor(user); + return Map.of( + "twofa", "true", + "verifyToken", step1.get("verifyToken"), + "step", step1.get("step"), + "maskedEmail", step1.getOrDefault("maskedEmail", "")); + } + + // 2FA 비활성 — 기존 단일 로그인 흐름(회귀 0) + userMapper.resetLoginFail(username); + String token = jwtUtil.generate(username, user.getRole()); + return Map.of("twofa", "false", "token", token, "type", "Bearer"); } public Map me(String token) { @@ -37,4 +109,122 @@ public class AuthService { m.put("displayName", u != null ? u.getDisplayName() : username); return m; } + + /** + * 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIMS changePassword 미러. + * 현재 비번 불일치 → PASSWORD_MISMATCH, 기존과 동일 → PASSWORD_SAME_AS_OLD. + * 새 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙). + */ + @Transactional + public void changePassword(String username, ChangePasswordRequest req) { + HrmUser user = userMapper.findByUsername(username); + if (user == null) { + throw new UiwsApiException(UiwsErrorCode.USER_NOT_FOUND); + } + if (!passwordEncoder.matches(req.currentPassword(), user.getPasswordHash())) { + throw new UiwsApiException(UiwsErrorCode.PASSWORD_MISMATCH); + } + if (passwordEncoder.matches(req.newPassword(), user.getPasswordHash())) { + throw new UiwsApiException(UiwsErrorCode.PASSWORD_SAME_AS_OLD); + } + userMapper.updatePasswordByUsername(username, passwordEncoder.encode(req.newPassword())); + log.info("[auth] password changed: username={}", username); + } + + // ── 로그인 보조 3종 (UIWS auth 패턴 이식) ──────────────────────────────────── + + /** + * 운영자 회원가입(승인 대기 INSERT). username/email 중복 차단, 비번 BCrypt, approved=false. + * 보안: 응답에 자격증명·임시정보 미포함, 일반 메시지만. + */ + public SignupResponse signup(SignupRequest req) { + if (req.username() == null || req.username().isBlank() + || req.password() == null || req.password().length() < 4 + || req.email() == null || req.email().isBlank()) { + return new SignupResponse(false, "아이디·비밀번호(4자 이상)·이메일은 필수입니다."); + } + if (userMapper.countByUsername(req.username()) > 0) { + return new SignupResponse(false, "이미 사용 중인 아이디입니다."); + } + if (userMapper.countByEmail(req.email()) > 0) { + return new SignupResponse(false, "이미 등록된 이메일입니다."); + } + HrmUser u = new HrmUser(); + u.setUsername(req.username()); + u.setPasswordHash(passwordEncoder.encode(req.password())); + u.setDisplayName(req.displayName() != null && !req.displayName().isBlank() + ? req.displayName() : req.username()); + u.setEmail(req.email()); + // insertSignup: role='VIEWER', approved=false, locked=false, login_fail_count=0 (XML 고정) + userMapper.insertSignup(u); + log.info("[auth-helper] signup pending approval: username={}", req.username()); + return new SignupResponse(true, "가입 신청이 접수되었습니다. 관리자 승인 후 로그인할 수 있습니다."); + } + + /** + * 아이디 찾기: 표시명+이메일 동시 일치 계정 1건 조회. username 은 부분 마스킹 후 반환. + * 미발견 시 found=false(원문 username 절대 미노출). + */ + public FindIdResponse findId(FindIdRequest req) { + if (req.displayName() == null || req.displayName().isBlank() + || req.email() == null || req.email().isBlank()) { + return new FindIdResponse(false, ""); + } + HrmUser u = userMapper.findByDisplayNameAndEmail(req.displayName(), req.email()); + if (u == null) { + return new FindIdResponse(false, ""); + } + return new FindIdResponse(true, maskUsername(u.getUsername())); + } + + /** + * 비밀번호 초기화: username+email 일치 검증 → 임시비번 생성·BCrypt 저장·잠금/실패카운트 해제·변경유도. + * 임시비번은 메일(미설정 시 LogMailSender 로그)로만 전달. API 응답·로그 메시지에 비번 미노출. + * 대상 미존재여도 success=true(계정 열거 방지). + */ + @Transactional + public SignupResponse resetPassword(ResetPwRequest req) { + final String okMsg = "임시 비밀번호를 등록된 이메일로 발송했습니다. 메일을 확인하세요."; + if (req.username() == null || req.username().isBlank() + || req.email() == null || req.email().isBlank()) { + return new SignupResponse(false, "아이디와 이메일을 모두 입력하세요."); + } + HrmUser u = userMapper.findByUsernameAndEmail(req.username(), req.email()); + if (u == null) { + // 존재 여부 누설 방지 — 동일 성공 메시지 반환(실제 발송 없음). + log.info("[auth-helper] reset-password no match (suppressed): username={}", req.username()); + return new SignupResponse(true, okMsg); + } + String tempPw = generateTempPassword(); + // applyTempPassword: BCrypt 저장 + pw_change_yn=true + locked=false + login_fail_count=0 (멱등) + userMapper.applyTempPassword(req.username(), passwordEncoder.encode(tempPw)); + + String subject = "[GUARDiA HRM] 임시 비밀번호 안내"; + String body = String.format( + "안녕하세요 %s 님,\n임시 비밀번호는 [%s] 입니다.\n로그인 후 즉시 비밀번호를 변경하세요.", + u.getDisplayName() != null ? u.getDisplayName() : u.getUsername(), tempPw); + // 메일 본문에만 임시비번 포함. mailSender 미설정 환경은 LogMailSender 폴백(서버 로그). + mailSender.send(u.getEmail(), subject, body); + log.info("[auth-helper] reset-password issued temp pw (sent via mail/log): username={}", req.username()); + return new SignupResponse(true, okMsg); + } + + private static String generateTempPassword() { + StringBuilder sb = new StringBuilder(10); + for (int i = 0; i < 10; i++) { + sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length()))); + } + return sb.toString(); + } + + /** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */ + private static String maskUsername(String username) { + if (username == null || username.isBlank()) { + return ""; + } + if (username.length() <= 2) { + return username.charAt(0) + "*"; + } + return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2)); + } } diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/HrmUser.java b/backend/src/main/java/com/zioinfo/hrm/auth/HrmUser.java index ad98500..ef06201 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/HrmUser.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/HrmUser.java @@ -17,4 +17,20 @@ public class HrmUser { private String role; private boolean active; private LocalDateTime createdAt; + + // UIWS 2FA 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql). + private String emailVerifyCode; + private LocalDateTime emailVerifyExpire; + private Integer loginFailCount; + private Boolean locked; + /** TOTP 시크릿(보류/확정 공용). API 응답·로그에 절대 미노출. (db/91_uiws_port.sql ALTER) */ + private String otpSecret; + /** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). (db/93_auth_otp.sql ALTER) */ + private Boolean otpEnabled; + + // UIWS 로그인 보조기능 이식 컬럼 (멱등 ALTER — db/91_uiws_port.sql). + // approved: 가입 신청자는 false(관리자 승인 전 로그인 차단), 기존 계정은 true 보정. + // pwChangeYn: 임시비번 발급 시 true(다음 로그인 시 변경 유도). + private Boolean approved; + private Boolean pwChangeYn; } diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/JwtFilter.java b/backend/src/main/java/com/zioinfo/hrm/auth/JwtFilter.java index 6e597ea..e60bf2c 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/JwtFilter.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/JwtFilter.java @@ -26,7 +26,10 @@ public class JwtFilter extends OncePerRequestFilter { String header = req.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); - if (jwtUtil.isValid(token)) { + // 보안(UIWS 2FA): purpose=2fa verify-token 은 access 토큰이 아니다. + // 동일 서명키라 isValid()는 통과하므로 별도 차단하지 않으면 2차 인증 전 보호 API 접근(2FA 우회)이 가능. + // → verify-token 은 인증 컨텍스트를 세우지 않고 무시한다(/api/hrm/auth/verify 에서만 사용). + if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) { String username = jwtUtil.getUsername(token); String role = jwtUtil.getRole(token); var auth = new UsernamePasswordAuthenticationToken( diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/JwtUtil.java b/backend/src/main/java/com/zioinfo/hrm/auth/JwtUtil.java index 2656647..6df8619 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/JwtUtil.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/JwtUtil.java @@ -34,6 +34,47 @@ public class JwtUtil { .compact(); } + /** + * UIWS 2FA 이식: 1차 로그인 통과 후 발급하는 단기 verify-token. + * purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가). + */ + public String generateVerifyToken(String username, long validitySeconds) { + return Jwts.builder() + .subject(username) + .claim("purpose", "2fa") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L)) + .signWith(key()) + .compact(); + } + + /** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */ + public String parseVerifyTokenUsername(String token) { + try { + Claims c = parse(token); + if (!"2fa".equals(c.get("purpose", String.class))) { + return null; + } + return c.getSubject(); + } catch (JwtException | IllegalArgumentException e) { + log.debug("verify-token 검증 실패: {}", e.getMessage()); + return null; + } + } + + /** + * 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별. + * JwtFilter 가 access 토큰만 인증 컨텍스트로 인정하도록 verify-token 을 걸러내는 데 사용 + * (2차 인증 전 verify-token 으로 보호 API 에 접근하는 2FA 우회 차단). + */ + public boolean isVerifyToken(String token) { + try { + return "2fa".equals(parse(token).get("purpose", String.class)); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + public Claims parse(String token) { return Jwts.parser().verifyWith(key()).build() .parseSignedClaims(token).getPayload(); diff --git a/backend/src/main/java/com/zioinfo/hrm/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/hrm/auth/mapper/UserMapper.java index 28bdf7c..ecb7c17 100644 --- a/backend/src/main/java/com/zioinfo/hrm/auth/mapper/UserMapper.java +++ b/backend/src/main/java/com/zioinfo/hrm/auth/mapper/UserMapper.java @@ -3,9 +3,123 @@ package com.zioinfo.hrm.auth.mapper; import com.zioinfo.hrm.auth.HrmUser; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; + +/** + * HRM 운영자 계정 매퍼. + * 기존 메서드(findByUsername/insert)에 UIWS 2FA·로그인 보조기능 이식 메서드를 추가. + * 대상 테이블 hrm_users (컬럼 active — pms_user.is_active 와 다름). + */ @Mapper public interface UserMapper { + HrmUser findByUsername(@Param("username") String username); + int insert(HrmUser user); + + /** 회원가입(승인 대기): username·password_hash·display_name·email + role='VIEWER'·approved=false. */ + int insertSignup(HrmUser user); + + // ── UIWS 로그인 보조기능 이식(회원가입·아이디찾기·비번초기화) ─────────────── + + /** 가입 중복 검사: username 존재 여부. */ + @Select("SELECT COUNT(1) FROM hrm_users WHERE username = #{username}") + int countByUsername(@Param("username") String username); + + /** 가입 중복 검사: email 존재 여부(enumeration 최소화 — 내부 사용). */ + @Select("SELECT COUNT(1) FROM hrm_users WHERE email = #{email}") + int countByEmail(@Param("email") String email); + + /** 아이디찾기: 이름(display_name)·이메일 일치 사용자(존재 시 마스킹 반환). */ + @Select(""" + SELECT id, username, password_hash, role, active, created_at, + display_name, email, + email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, + approved, pw_change_yn + FROM hrm_users + WHERE display_name = #{displayName} AND email = #{email} + ORDER BY id LIMIT 1 + """) + HrmUser findByDisplayNameAndEmail(@Param("displayName") String displayName, + @Param("email") String email); + + /** 비번초기화: username·email 동시 일치 검증용. */ + @Select(""" + SELECT id, username, password_hash, role, active, created_at, + display_name, email, + email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, + approved, pw_change_yn + FROM hrm_users + WHERE username = #{username} AND email = #{email} + """) + HrmUser findByUsernameAndEmail(@Param("username") String username, + @Param("email") String email); + + /** 비번초기화: 임시비번 적용 + 변경유도 + 잠금/실패카운트 해제(멱등 UPDATE). */ + @Update(""" + UPDATE hrm_users + SET password_hash = #{passwordHash}, pw_change_yn = true, + locked = false, login_fail_count = 0 + WHERE username = #{username} + """) + int applyTempPassword(@Param("username") String username, + @Param("passwordHash") String passwordHash); + + // ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ─────────────── + + /** 로그인 성공 시 실패 카운트 초기화. */ + @Update("UPDATE hrm_users SET login_fail_count = 0 WHERE username = #{username}") + int resetLoginFail(@Param("username") String username); + + /** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */ + @Update(""" + UPDATE hrm_users + SET login_fail_count = COALESCE(login_fail_count, 0) + 1, + locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail}) + WHERE username = #{username} + """) + int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail); + + /** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */ + @Update(""" + UPDATE hrm_users + SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0 + WHERE username = #{username} + """) + int saveEmailCode(@Param("username") String username, + @Param("code") String code, + @Param("expire") LocalDateTime expire); + + /** 2차 검증 성공 시 코드 폐기. */ + @Update("UPDATE hrm_users SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}") + int clearEmailCode(@Param("username") String username); + + /** 관리자 잠금 해제(실패 카운트/잠금 초기화). */ + @Update("UPDATE hrm_users SET locked = false, login_fail_count = 0 WHERE username = #{username}") + int unlock(@Param("username") String username); + + /** + * BCrypt 비밀번호 해시 갱신(username 기준). 평문은 절대 저장하지 않는다. + * 마이페이지 비밀번호 변경 + AdminPasswordSeeder(env 재시드) 공용. + */ + @Update("UPDATE hrm_users SET password_hash = #{passwordHash} WHERE username = #{username}") + int updatePasswordByUsername(@Param("username") String username, + @Param("passwordHash") String passwordHash); + + // ── TOTP(OTP 2차 인증) 이식 (멱등 UPDATE) ──────────────────────────────────── + + /** 보류 시크릿 저장(등록 확정 전). otp_enabled 은 건드리지 않는다(verify/confirm 에서 확정). */ + @Update("UPDATE hrm_users SET otp_secret = #{secret} WHERE username = #{username}") + int updateOtpSecret(@Param("username") String username, @Param("secret") String secret); + + /** 등록 확정: otp_enabled=true (시크릿은 유지). */ + @Update("UPDATE hrm_users SET otp_enabled = true WHERE username = #{username}") + int enableOtp(@Param("username") String username); + + /** 마이페이지 해제: 시크릿 폐기 + otp_enabled=false. */ + @Update("UPDATE hrm_users SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}") + int disableOtp(@Param("username") String username); } diff --git a/backend/src/main/java/com/zioinfo/hrm/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/hrm/config/SecurityConfig.java index 73bba0c..981f824 100644 --- a/backend/src/main/java/com/zioinfo/hrm/config/SecurityConfig.java +++ b/backend/src/main/java/com/zioinfo/hrm/config/SecurityConfig.java @@ -41,10 +41,18 @@ public class SecurityConfig { .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/hrm/auth/**").permitAll() + // UIWS 로그인 보조 3종(회원가입·아이디찾기·비번초기화) — 로그인 전 무인증 접근. + .requestMatchers("/api/auth/**").permitAll() + // UIWS system: 가입 화면 공개 조회(부서/회사 GET) — permitAll. + .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll() .requestMatchers("/actuator/health").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/hrm/docs/**", "/api/hrm/swagger/**").permitAll() .requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll() + // UIWS system(권한관리): 조회는 MANAGER 이상, 변경은 SUPERADMIN. + .requestMatchers(HttpMethod.GET, "/api/system/**").hasAnyRole("SUPERADMIN", "MANAGER") + .requestMatchers("/api/system/**").hasRole("SUPERADMIN") + .requestMatchers("/api/hrm/admin/users/**").hasRole("SUPERADMIN") .requestMatchers(HttpMethod.GET, "/api/hrm/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER") .requestMatchers("/api/hrm/admin/settings/**").hasRole("SUPERADMIN") diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 5bd7742..97037de 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -15,8 +15,15 @@ spring: minimum-idle: 1 sql: init: - mode: ${SQL_INIT_MODE:never} - schema-locations: classpath:db/schema.sql + # schema.sql(전부 IF NOT EXISTS + ON CONFLICT DO NOTHING)·UIWS 이식 파일(전부 멱등)만 재적용. + # 91_uiws_port.sql: 업무 모듈 tb_uiws_* + hrm_users 2FA/로그인보조 컬럼 ALTER(멱등). + # 92_uiws_system.sql: UIWS system(권한관리) tb_uiws_* 10테이블(부서/회사/코드/사용자/역할/메뉴/프로그램, 멱등). + mode: ${SQL_INIT_MODE:always} + # 104_seed_ai_config.sql: AI 플랫폼(provider/모델) 설정 시드 hrm_settings ai.* (멱등 ON CONFLICT DO NOTHING). + # 93_auth_otp.sql: hrm_users otp_enabled 멱등 ALTER(TOTP 2차 인증). ops_otp_reset_all.sql 은 미등재(운영 1회 수동). + # 202_ai_provider_claude.sql: AI provider 를 claude 로 flip(멱등 UPSERT). 반드시 104 시드 이후로 등재. + schema-locations: classpath:db/schema.sql,classpath:db/91_uiws_port.sql,classpath:db/92_uiws_system.sql,classpath:db/93_auth_otp.sql,classpath:db/104_seed_ai_config.sql,classpath:db/202_ai_provider_claude.sql + continue-on-error: true servlet: multipart: max-file-size: 20MB @@ -38,15 +45,38 @@ guardia: erp-url: ${ERP_URL:http://localhost:8003} itsm-url: ${ITSM_URL:http://localhost:9001} groupware-url: ${GROUPWARE_URL:http://localhost:8009} - # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지. + # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지(Claude 는 소유자 승인 예외). ollama-url: ${OLLAMA_URL:http://localhost:11434} ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3} + # 중앙 guardia-rag 연동(피드백 전달). base-url 은 서버 내부 루프백 전용 — 외부 URL 금지. + rag: + base-url: ${RAG_URL:http://127.0.0.1:8020} + enabled: ${RAG_ENABLED:true} + # 로컬 임베디드 DuckDB 학습 저장소 파일 경로(솔루션별 격리). + learning: + duckdb-path: ${HRM_LEARNING_DUCKDB:/opt/guardia-hrm/data/hrm_learning.duckdb} crypto: secret: ${HRM_CRYPTO_SECRET:guardia-hrm-aes-256-gcm-master-key-2026-zioinfo} jwt: secret: ${JWT_SECRET:guardia-hrm-jwt-secret-2026-minimum-256bit-key-zioinfo} expiration: 86400000 +# UIWS 이식 모듈 설정 (worklog·schedule·message·stats + 2FA 레이어 + 로그인 보조). +# 2FA 토글: hrm.uiws.auth.twofa-enabled=false 면 기존 단일 JWT 로그인 회귀 0. +hrm: + uiws: + auth: + twofa-enabled: ${UIWS_2FA:true} + # TOTP(OTP 2차 인증, UIMS 방식) on/off. 기본 on. 우선순위: otp → 이메일코드 → 단일. off 시 이메일/단일 경로로 폴백. + otp-enabled: ${UIWS_OTP:true} + verify-token-validity-seconds: 300 # verify-token 5분 + email-code-validity-seconds: 300 # 이메일 코드 5분 + max-login-fail: 5 # 실패 5회 잠금 + mail: + mode: ${UIWS_MAIL_MODE:log} # log 폴백(외부 API 금지). smtp 운영 시 별도 구현 + upload: + upload-dir: ${UIWS_UPLOAD_DIR:./uploads/uiws} + logging: level: com.zioinfo.hrm: DEBUG diff --git a/backend/src/main/resources/db/202_ai_provider_claude.sql b/backend/src/main/resources/db/202_ai_provider_claude.sql new file mode 100644 index 0000000..34e6ae5 --- /dev/null +++ b/backend/src/main/resources/db/202_ai_provider_claude.sql @@ -0,0 +1,22 @@ +-- ===================================================================== +-- GUARDiA HRM — 202. AI provider 를 claude 로 flip (멱등 UPSERT) +-- 대상 DB : hrm_db (테이블 hrm_settings, PK=key — schema.sql 에서 생성) +-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일을 +-- 104_seed_ai_config.sql **이후**로 등재(시드가 먼저 기본값 ollama INSERT → 본 파일이 claude 로 전환). +-- 설명 : 런타임 AI provider 를 Claude(Anthropic) 로 전환한다. UIWS db/202 대응본. +-- 키 미설정/실패 시 AiTextRouter 가 자동으로 Ollama 로 폴백하므로 무중단·무회귀. +-- 멱등 : provider 행이 없어도(UPSERT) INSERT, 있으면 value='claude' 로 UPDATE. 재실행 안전. +-- * hrm 서버 실측: hrm_settings 에 ai.provider 행 부재(코드 기본값 ollama) → INSERT 경로로 생성. +-- 보안 : Claude API 키는 DB 에 저장하지 않는다 — 서버 환경변수 ANTHROPIC_API_KEY 로만 주입. +-- 본 파일에 키/시크릿/IP/비밀번호 일절 미포함. +-- 활성화 : 실제 Claude 경로는 provider=claude · ANTHROPIC_API_KEY 설정됨 · ai.enabled=true 3조건 +-- (AiConfigService.isClaudeActive) 충족 시. 그 외에는 Ollama 폴백(정상 동작). +-- ===================================================================== + +SET client_encoding = 'UTF8'; + +INSERT INTO hrm_settings (key, value, description) VALUES + ('ai.provider', 'claude', 'AI provider(ollama/claude/qwen3/deepseek/glm)') +ON CONFLICT (key) DO UPDATE SET value = 'claude'; + +-- end 202_ai_provider_claude.sql diff --git a/backend/src/main/resources/mapper/AdminMapper.xml b/backend/src/main/resources/mapper/AdminMapper.xml index 1459b2c..08480f0 100644 --- a/backend/src/main/resources/mapper/AdminMapper.xml +++ b/backend/src/main/resources/mapper/AdminMapper.xml @@ -39,6 +39,10 @@ UPDATE hrm_users SET active=#{active} WHERE id=#{id} + + UPDATE hrm_users SET otp_secret=NULL, otp_enabled=false WHERE id=#{id} + + - SELECT id, username, password_hash, display_name, email, role, active, created_at + SELECT id, username, password_hash, display_name, email, role, active, created_at, + email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, + approved, pw_change_yn FROM hrm_users WHERE username = #{username} @@ -24,4 +36,13 @@ VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active}) + + + INSERT INTO hrm_users (username, password_hash, role, display_name, email, + active, approved, login_fail_count, locked) + VALUES (#{username}, #{passwordHash}, 'VIEWER', #{displayName}, #{email}, + true, false, 0, false) + + diff --git a/frontend/index.html b/frontend/index.html index aa320b4..76aa26c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + GUARDiA HRM — AI 인사관리 플랫폼 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ca15a15..9e98f89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,21 @@ import PerformancePage from './pages/PerformancePage' import RecruitmentPage from './pages/RecruitmentPage' import TrainingPage from './pages/TrainingPage' import AiPage from './pages/AiPage' +import WiseAiPage from './pages/WiseAiPage' +import AiPlatformSettings from './pages/AiPlatformSettings' import AdminPage from './pages/AdminPage' +import MyPage from './pages/MyPage' +import MobileApp from './pages/MobileApp' +// UIWS(UIMS) 이식 — 공통 업무협업 화면(업무일지·일정·쪽지·통계) +import WorklogList from './pages/uiws/WorklogList' +import ScheduleCalendar from './pages/uiws/ScheduleCalendar' +import MessageBox from './pages/uiws/MessageBox' +import StatsPivot from './pages/uiws/StatsPivot' +// UIWS system(권한관리) 이식 — 시스템관리(권한) 화면 셸(탭: 권한/역할-메뉴/공통코드/메뉴/부서/거래처) +import SystemPage from './pages/uiws/system/SystemPage' + +// 시스템관리(권한)는 관리자 전용. 백엔드 /api/system/** 조회=SUPERADMIN/MANAGER, 쓰기=SUPERADMIN. +const ADMIN_ROLES = ['SUPERADMIN', 'MANAGER', 'ADMIN'] const MENU = [ { path: '/dashboard', label: '대시보드', icon: '📊' }, @@ -21,13 +35,25 @@ const MENU = [ { path: '/performance', label: '성과평가', icon: '🎯' }, { path: '/recruitment', label: '채용관리', icon: '🔍' }, { path: '/training', label: '교육관리', icon: '📚' }, + // UIWS 이식 — 업무 협업 + { path: '/worklogs', label: '업무일지', icon: '📝' }, + { path: '/schedules', label: '일정관리', icon: '📅' }, + { path: '/messages', label: '쪽지함', icon: '✉️' }, + { path: '/work-stats', label: '업무통계', icon: '📈' }, { path: '/ai', label: 'AI 인사분석', icon: '🤖' }, + { path: '/wise-ai', label: 'WISE AI', icon: '✨' }, + { path: '/ai-platform', label: 'AI 플랫폼 설정', icon: '🧠', adminOnly: true }, { path: '/admin', label: '시스템관리', icon: '⚙️' }, + // UIWS system 이식 — 권한·코드·메뉴·부서·거래처 (관리자 전용) + { path: '/system', label: '시스템관리(권한)', icon: '🔐', adminOnly: true }, + { path: '/mobile-app', label: '모바일 앱', icon: '📱' }, ] function Layout({ children }: { children: React.ReactNode }) { const [collapsed, setCollapsed] = useState(false) const user = JSON.parse(localStorage.getItem('hrm_user') || '{"displayName":"관리자","role":"SUPERADMIN"}') + const isAdmin = ADMIN_ROLES.includes(user.role) + const visibleMenu = MENU.filter(m => !m.adminOnly || isAdmin) return (
@@ -45,7 +71,7 @@ function Layout({ children }: { children: React.ReactNode }) {