feat(ux): 날짜 입력 전면 캘린더 전환 — 웹 datetime-local/date/time + 모바일 순수JS CalendarPicker [auto-sync]

This commit is contained in:
GUARDiA AutoDeploy 2026-07-05 08:23:08 +09:00 committed by DESKTOP-TKLFCPR\ython
parent 6059494b5d
commit 870541448a
37 changed files with 578 additions and 1302 deletions

View File

@ -27,14 +27,10 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
<dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>${mybatis.version}</version></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- 로컬 임베디드 학습 저장소(DuckDB) — AI 피드백/추론 로그 격리 파일(/opt/guardia-mall/data). 외부 호출 없음. -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>1.1.3</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-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></dependency>
<!-- TOTP 2FA (RFC 6238 · SHA1 · 30s · 6자리) — UIWS(uiws build.gradle) 동일 좌표. QR 생성 위해 zxing 전이 포함. -->
<dependency><groupId>dev.samstevens.totp</groupId><artifactId>totp</artifactId><version>1.7.1</version></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>

View File

@ -56,12 +56,6 @@ public class AdminController {
return ApiResponse.ok(userService.resetPassword(id, req.password()));
}
/** 관리자 OTP 초기화(사용자 관리 화면 버튼) — 대상 OTP 시크릿 NULL → 다음 로그인 시 QR 재등록. */
@PostMapping("/users/{id}/otp-reset")
public ApiResponse<UserDto> otpReset(@PathVariable Long id) {
return ApiResponse.ok(userService.otpReset(id));
}
@DeleteMapping("/users/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
String currentUsername = auth != null ? auth.getName() : null;

View File

@ -83,14 +83,6 @@ public class AdminUserService {
return UserDto.from(user);
}
/** 관리자 OTP 초기화: 대상 사용자 시크릿·확정 플래그 폐기 → 다음 로그인 시 QR 재등록 유도. */
public UserDto otpReset(Long id) {
MallUser 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) {
MallUser user = require(id);
if (user.getUsername().equals(currentUsername)) {

View File

@ -22,9 +22,6 @@ public interface AdminUserMapper {
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false → 다음 로그인 시 QR 재등록 유도. */
int clearOtp(@Param("id") Long id);
int deleteById(@Param("id") Long id);
int countAdmins();

View File

@ -1,7 +1,5 @@
package com.zioinfo.mall.ai;
import com.zioinfo.mall.ai.service.AiTextRouter;
import com.zioinfo.mall.common.ai.TextAiClient.GenResult;
import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper;
import com.zioinfo.mall.product.MallProduct;
import com.zioinfo.mall.product.mapper.ProductMapper;
@ -24,20 +22,11 @@ import java.util.*;
@RequiredArgsConstructor
public class MallAiService {
private final AiTextRouter aiRouter; // provider 라우팅(ClaudeOllama) + infer 로그. 실패 아래 Java 폴백.
private final OllamaClient ollama;
private final ProductMapper productMapper;
private final ReviewMapper reviewMapper;
private final StoreInventoryMapper storeInventoryMapper;
/**
* 선택된 provider(Claude/Ollama) 텍스트 생성. degraded/빈응답이면 문자열 반환
* 기능의 기존 결정론적 Java 폴백이 그대로 동작(무회귀).
*/
private String aiGenerate(String prompt) {
GenResult r = aiRouter.generate(prompt);
return (r != null && !r.degraded() && r.text() != null) ? r.text() : "";
}
/** 1. 상품 추천 — 행사/키워드 기반. AI 실패 시 인기/평점 폴백. */
public List<MallProduct> recommend(String occasion, String keyword, int limit) {
List<MallProduct> pool = productMapper.search(null, keyword, "ON_SALE", occasion, null, null, "sales", 30, 0);
@ -48,7 +37,7 @@ public class MallAiService {
String prompt = "You are a florist recommender. From this catalog: [" + names + "]. "
+ "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion)
+ "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated.";
String ai = aiGenerate(prompt);
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) {
List<MallProduct> ordered = reorderByAi(pool, ai);
if (!ordered.isEmpty()) return ordered.subList(0, Math.min(limit, ordered.size()));
@ -69,7 +58,7 @@ public class MallAiService {
}
String prompt = "Summarize these flower bouquet reviews in 2 concise sentences (pros/cons):\n"
+ String.join("\n", contents);
String ai = aiGenerate(prompt);
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) {
out.put("summary", ai);
out.put("source", "ollama");
@ -100,7 +89,7 @@ public class MallAiService {
public String csAutoReply(String subject, String content) {
String prompt = "You are a polite flower-shop customer support agent. Write a short helpful reply (<=4 sentences) to:\n"
+ "Subject: " + subject + "\nMessage: " + content;
String ai = aiGenerate(prompt);
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) return ai;
return "Thank you for reaching out about \"" + subject + "\". We're sorry for any inconvenience. "
+ "Our team is reviewing your request and will follow up shortly. "
@ -132,7 +121,7 @@ public class MallAiService {
}
String prompt = "Compose a creative 'Daily Standard' bouquet name and 1-line description using surplus flowers: ["
+ String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION.";
String ai = aiGenerate(prompt);
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) {
out.put("bouquet", ai);
out.put("source", "ollama");
@ -176,7 +165,7 @@ public class MallAiService {
String prompt = "Write 3 short flower-card messages for occasion='" + occasion
+ "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient)
+ "'. One per line, no numbering.";
String ai = aiGenerate(prompt);
String ai = ollama.generate(prompt);
if (ai != null && !ai.isBlank()) {
List<String> lines = new ArrayList<>();
for (String l : ai.split("\n")) {

View File

@ -30,33 +30,20 @@ public class OllamaClient {
this.model = model;
}
/** 기본 모델(guardia.ollama-text-model)로 생성. 실패 시 빈 문자열. */
public String generate(String prompt) {
return generateText(prompt, model);
}
/**
* 지정 모델로 평문 생성(AiTextRouter Ollama 경로·Claude 폴백 공용 진입점).
*
* <p>모델 미지정 서버 기본(guardia.ollama-text-model). localhost Ollama 호출하며,
* 장애/오프라인/타임아웃 예외 없이 문자열 반환(호출자가 폴백 수행). [GUARDiA-MALL]
*/
@SuppressWarnings("unchecked")
public String generateText(String prompt, String reqModel) {
if (prompt == null || prompt.isBlank()) return "";
String useModel = (reqModel == null || reqModel.isBlank()) ? model : reqModel.trim();
public String generate(String prompt) {
try {
Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
Map<String, Object> body = Map.of("model", model, "prompt", prompt, "stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate").bodyValue(body)
.retrieve().bodyToMono(Map.class)
.timeout(Duration.ofSeconds(120))
.timeout(Duration.ofSeconds(30))
.map(m -> (Map<String, Object>) m).block();
if (res == null) return "";
Object r = res.get("response");
return r == null ? "" : String.valueOf(r).trim();
} catch (Exception e) {
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getClass().getSimpleName());
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage());
return "";
}
}

View File

@ -1,56 +1,28 @@
package com.zioinfo.mall.auth;
import com.zioinfo.mall.auth.dto.ChangePasswordRequest;
import com.zioinfo.mall.auth.dto.OtpConfirmRequest;
import com.zioinfo.mall.auth.dto.OtpSetupResponse;
import com.zioinfo.mall.auth.dto.OtpVerifyRequest;
import com.zioinfo.mall.common.ApiResponse;
import com.zioinfo.mall.uiws.auth.OtpAuthService;
import com.zioinfo.mall.uiws.auth.TwoFactorService;
import com.zioinfo.mall.uiws.common.UiwsApiException;
import com.zioinfo.mall.uiws.common.UiwsErrorCode;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* GUARDiA Mall 인증 컨트롤러.
* - /login: 고객(USER)·2FA off { token }. 운영(ADMIN/MANAGER)+2FA on { twofa:"true", verifyToken, step, maskedEmail }.
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 access 발급. 운영 로그인 2단계 완료용.
* 기존 고객 클라이언트(token 응답) 형태 보존 쇼핑 로그인 회귀 0.
*/
@RestController
@RequestMapping("/api/mall/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<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"));
}
@PostMapping("/register")
public ApiResponse<Map<String, String>> register(@RequestBody RegisterRequest req) {
return ApiResponse.ok(authService.register(req.username(), req.password(), req.displayName()));
}
/** UIWS 2FA 이식: 운영 로그인 2차 인증 코드 검증(이메일) → access 발급. */
@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()));
String token = authService.register(req.username(), req.password(), req.displayName());
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
}
@GetMapping("/me")
@ -59,51 +31,6 @@ public class AuthController {
return ApiResponse.ok(authService.me(token));
}
// 마이페이지: OTP 등록/재설정/해제 + 비밀번호 변경 (본인, access 토큰 필요)
// /api/mall/auth/** permitAll 이라 필터 인증이 없으므로 여기서 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/mall/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 RegisterRequest(String username, String password, String displayName) {}
record VerifyRequest(String verifyToken, String code) {}
}

View File

@ -1,121 +1,33 @@
package com.zioinfo.mall.auth;
import com.zioinfo.mall.admin.AuditService;
import com.zioinfo.mall.auth.dto.AuthHelperResult;
import com.zioinfo.mall.auth.dto.ChangePasswordRequest;
import com.zioinfo.mall.auth.dto.FindIdRequest;
import com.zioinfo.mall.auth.dto.FindIdResponse;
import com.zioinfo.mall.auth.dto.ResetPasswordRequest;
import com.zioinfo.mall.auth.dto.SignupRequest;
import com.zioinfo.mall.auth.mapper.UserMapper;
import com.zioinfo.mall.uiws.auth.OtpAuthService;
import com.zioinfo.mall.uiws.auth.TwoFactorService;
import com.zioinfo.mall.uiws.common.UiwsApiException;
import com.zioinfo.mall.uiws.common.UiwsErrorCode;
import com.zioinfo.mall.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.Map;
/**
* GUARDiA Mall 인증 서비스.
*
* <p> 고객/운영 분리: Mall {@code mall_account} 단일 테이블/단일 로그인이지만 역할로 구분된다.
* <ul>
* <li>고객(USER) 쇼핑 로그인: 2FA 미적용(기존 단일 JWT 흐름 그대로, 회귀 0).</li>
* <li>운영(ADMIN/MANAGER) 관리자/매장 로그인: UIWS 2FA 레이어 적용(verify-token + 이메일코드 + 실패잠금).</li>
* </ul>
* 2FA 전역 토글({@code mall.uiws.auth.twofa-enabled}) off 운영 로그인도 단일 JWT(회귀 0).
*/
@Slf4j
@Service
@RequiredArgsConstructor
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 PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
private final OtpAuthService otpAuthService;
private final MailSender mailSender;
private final AuditService auditService;
/** 운영(2FA 대상) 역할 여부 — 고객(USER)은 제외. */
private static boolean isOperationsRole(String role) {
return "ADMIN".equalsIgnoreCase(role) || "MANAGER".equalsIgnoreCase(role);
}
/**
* 1차 로그인.
* @return 고객 또는 2FA off: { token, type, twofa:"false" }
* 운영 + 2FA on : { twofa:"true", verifyToken, step:"EMAIL", maskedEmail }
*/
public Map<String, String> login(String username, String password) {
public String login(String username, String password) {
MallUser user = userMapper.findByUsername(username);
// 잠금 우선 차단(존재하는 운영 계정에 한해 존재 여부 누설 최소화)
if (user != null && isOperationsRole(user.getRole()) && twoFactorService.isLocked(user)) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
}
// 회원가입 승인 게이트(로그인 보조 이식): signup 으로 가입한 운영자(approved=false) 비번 일치 차단.
// approved NULL(기존 계정/고객 register 흐름)이면 게이트 미적용 회귀 0.
if (isOperationsRole(user.getRole()) && Boolean.FALSE.equals(user.getApproved())) {
throw new RuntimeException("ERR-AUTH-003: 승인 대기 중인 계정입니다. 관리자 승인 후 이용 가능합니다.");
}
boolean opsRole = isOperationsRole(user.getRole());
// 2단계 인증 대상: 운영(ADMIN/MANAGER) 로그인만. OTP(TOTP) 우선, 없으면 이메일코드.
boolean otpTarget = otpAuthService.isEnabled() && opsRole;
boolean emailTarget = twoFactorService.isEnabled() && opsRole;
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 운영 + 2FA(OTP 또는 이메일) 활성 실패 누적/잠금. 고객/비활성 기존 동작(메시지만) 유지.
if (otpTarget || emailTarget) {
twoFactorService.recordLoginFailure(username);
MallUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
// 비밀번호 검증 통과 2단계 우선순위: OTP > 이메일코드 > 단일 로그인
if (otpTarget) {
// { twofa:true, verifyToken, verifyMethod:(OTP|OTP_SETUP), secret?, otpAuthUri?, qrImage? }
return otpAuthService.beginOtp(user);
}
if (emailTarget) {
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
return Map.of(
"twofa", "true",
"verifyToken", step1.get("verifyToken"),
"step", step1.get("step"),
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
}
// 고객(USER) 또는 2FA 비활성 기존 단일 로그인 흐름(회귀 0)
if (opsRole) {
userMapper.resetLoginFail(username);
}
String token = jwtUtil.generate(username, user.getRole());
return Map.of("twofa", "false", "token", token, "type", "Bearer");
return jwtUtil.generate(username, user.getRole());
}
/** 고객 셀프 회원가입 — 항상 USER 역할로 생성(2FA 미적용 대상). */
public Map<String, String> register(String username, String password, String displayName) {
/** 고객 셀프 회원가입 — 항상 USER 역할로 생성. */
public String register(String username, String password, String displayName) {
if (username == null || username.isBlank() || password == null || password.isBlank()) {
throw new IllegalArgumentException("ERR-AUTH-400: username/password 필수");
}
@ -129,8 +41,7 @@ public class AuthService {
user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
user.setActive(true);
userMapper.insert(user);
String token = jwtUtil.generate(username, "USER");
return Map.of("twofa", "false", "token", token, "type", "Bearer");
return jwtUtil.generate(username, "USER");
}
public Map<String, String> me(String token) {
@ -138,122 +49,4 @@ public class AuthService {
String role = jwtUtil.getRole(token);
return Map.of("username", username, "role", role);
}
/**
* 마이페이지 비밀번호 변경(현재 비밀번호 검증 + BCrypt 저장). UIWS changePassword 미러.
* 현재 비번 불일치 PASSWORD_MISMATCH, 기존과 동일 PASSWORD_SAME_AS_OLD.
* 비밀번호 평문은 로그/응답/감사에 절대 기록하지 않는다(보안 불변규칙).
*/
@Transactional
public void changePassword(String username, ChangePasswordRequest req) {
MallUser 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()));
auditService.log(username, "PASSWORD_CHANGE", username, "본인 비밀번호 변경");
}
// 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화
// 대상: Mall 관리자/운영자 계정(mall_account, ADMIN/MANAGER). 고객(USER) register 흐름과 분리.
/**
* 운영자 회원가입(승인 대기). username/email 중복 검사 approved=false·role=MANAGER INSERT.
* 비밀번호는 BCrypt 저장. 승인 전까지 로그인 차단(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, "이미 등록된 이메일입니다.");
}
MallUser u = new MallUser();
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, "");
}
MallUser 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, "아이디와 이메일을 모두 입력하세요.");
}
MallUser 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 Mall] 임시 비밀번호 안내";
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);
}
private static String generateTempPassword() {
StringBuilder sb = new StringBuilder(10);
for (int i = 0; i < 10; i++) {
sb.append(TMP_PW_CHARS.charAt(RANDOM.nextInt(TMP_PW_CHARS.length())));
}
return sb.toString();
}
/** username 부분 마스킹: 앞 2자만 노출(예: admin → ad***). 2자 이하는 첫 글자만. */
private static String maskUsername(String username) {
if (username == null || username.isBlank()) {
return "";
}
if (username.length() <= 2) {
return username.charAt(0) + "*";
}
return username.substring(0, 2) + "*".repeat(Math.max(1, username.length() - 2));
}
}

View File

@ -26,10 +26,7 @@ public class JwtFilter extends OncePerRequestFilter {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
// 보안(UIWS 2FA): purpose=2fa verify-token access 토큰이 아니다.
// 동일 서명키라 isValid() 통과하므로 차단하지 않으면 2차 인증 보호 API 접근(2FA 우회) 가능.
// verify-token 인증 컨텍스트를 세우지 않고 무시한다(/api/mall/auth/verify 에서만 사용).
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
if (jwtUtil.isValid(token)) {
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken(

View File

@ -34,47 +34,6 @@ 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 걸러내는 사용.
* verify-token access 동일 서명키라 isValid() 통과 반드시 별도 차단(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();

View File

@ -3,7 +3,7 @@ package com.zioinfo.mall.auth;
import lombok.Data;
import java.time.LocalDateTime;
/** 계정 (mall_account). 역할: ADMIN/MANAGER(운영) · USER(고객). */
/** 계정 (mall_account). 역할: ADMIN/MANAGER/USER(고객). */
@Data
public class MallUser {
private Long id;
@ -13,28 +13,4 @@ public class MallUser {
private String displayName;
private boolean active;
private LocalDateTime createdAt;
// UIWS 2FA 이식 컬럼 (mall_account ALTER, db/91_uiws_port.sql)
// 2FA는 운영(ADMIN/MANAGER) 로그인에만 적용 고객(USER) 쇼핑 로그인은 회귀 0.
/** 2FA 발송 대상 이메일(원본 mall_account 미보유 → 91_uiws_port.sql 에서 추가). */
private String email;
/** 이메일 인증코드(6자리). 발급 후 verify 단계에서 검증. API 응답에는 절대 미포함. */
private String emailVerifyCode;
/** 인증코드 만료시각. */
private LocalDateTime emailVerifyExpire;
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */
private Boolean locked;
/** TOTP 시크릿(UIWS OTP 경로). 등록 확정 전 보류 시크릿도 여기 저장. API 응답에 절대 미포함. */
private String otpSecret;
/** OTP 등록 확정 여부(기본 false). 최초 로그인 verify 성공 시 true 로 확정(멱등). */
private Boolean otpEnabled;
// 로그인 보조 이식 컬럼 (mall_account ALTER, db/91_uiws_port.sql)
/**
* 회원가입 승인 게이트. signup 으로 가입한 운영자 계정은 false 승인 로그인 차단.
* 기존 계정/고객(USER) NULL login 게이트는 FALSE(명시적 미승인) 차단(회귀 0).
*/
private Boolean approved;
}

View File

@ -3,14 +3,7 @@ package com.zioinfo.mall.auth.mapper;
import com.zioinfo.mall.auth.MallUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
/**
* 계정 매퍼. findByUsername/insert/countByUsername UserMapper.xml 정의(2FA 컬럼 포함 resultMap).
* UIWS 2FA 이식 UPDATE 5종은 어노테이션으로 추가 XML 중복 정의 없음( 등록 충돌 회피).
*/
@Mapper
public interface UserMapper {
@ -19,81 +12,4 @@ public interface UserMapper {
int insert(MallUser user);
int countByUsername(@Param("username") String username);
// UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE)
/** 로그인 성공 시 실패 카운트 초기화. */
@Update("UPDATE mall_account SET login_fail_count = 0 WHERE username = #{username}")
int resetLoginFail(@Param("username") String username);
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
@Update("""
UPDATE mall_account
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 mall_account
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 mall_account SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}")
int clearEmailCode(@Param("username") String username);
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
@Update("UPDATE mall_account SET locked = false, login_fail_count = 0 WHERE username = #{username}")
int unlock(@Param("username") String username);
// admin 재시드 / 마이페이지 비밀번호 변경: password_hash 갱신(부수효과 없음)
/** username 기준 BCrypt 해시 갱신(잠금/실패카운트 무영향). AdminPasswordSeeder·changePassword 공용. */
@Update("UPDATE mall_account 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 mall_account SET otp_secret = #{secret} WHERE username = #{username}")
int updateOtpSecret(@Param("username") String username, @Param("secret") String secret);
/** 등록 확정: otp_enabled=true (시크릿은 유지). */
@Update("UPDATE mall_account SET otp_enabled = true WHERE username = #{username}")
int enableOtp(@Param("username") String username);
/** 해제/초기화: 시크릿 폐기 + otp_enabled=false. (마이페이지 해제) */
@Update("UPDATE mall_account SET otp_secret = NULL, otp_enabled = false WHERE username = #{username}")
int disableOtp(@Param("username") String username);
// 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 (UserMapper.xml)
/** email 존재 여부(회원가입 중복 검사 — 운영자 계정 한정). */
int countByEmail(@Param("email") String email);
/**
* 운영자 회원가입(승인 대기). role=MANAGER·is_active=true·approved=false 고정.
* 관리자 화면에서 승인 전까지 로그인 차단.
*/
int signup(MallUser user);
/** 아이디찾기: 표시명(display_name)+이메일 일치 운영자 1건. */
MallUser findByDisplayNameAndEmail(@Param("displayName") String displayName,
@Param("email") String email);
/** 비밀번호 초기화 대상 검증: username+email 동시 일치 운영자 1건. */
MallUser findByUsernameAndEmail(@Param("username") String username,
@Param("email") String email);
/** 임시 비밀번호 적용 + 잠금/실패카운트 해제(초기화 시). */
int updatePasswordHash(@Param("username") String username,
@Param("passwordHash") String passwordHash);
}

View File

@ -71,10 +71,6 @@ public class SecurityConfig {
.requestMatchers("/api/admin/audit").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/api/admin/settings/**").hasRole("ADMIN")
// AI 플랫폼(LLM provider) 설정 ADMIN 전용(조회/갱신/연결테스트)
.requestMatchers("/api/admin/ai-config/**", "/api/admin/ai-config").hasRole("ADMIN")
// AI 답변 피드백 수집(로컬 DuckDB + 중앙 rag 전달) 인증 사용자
.requestMatchers(HttpMethod.POST, "/api/ai/feedback").authenticated()
// 운영 분석 MANAGER 이상
.requestMatchers("/api/mall/analytics/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/api/mall/settlement/**").hasAnyRole("ADMIN", "MANAGER")
@ -85,14 +81,8 @@ public class SecurityConfig {
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.DELETE, "/api/mall/product/**", "/api/mall/category/**",
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
// 최신 AI 기법(중앙 guardia-rag) 운영 의사결정·토글 변경은 MANAGER+ (추천/피드백/토글조회는 인증 사용자)
.requestMatchers(HttpMethod.POST, "/api/mall/rag/demand-plan").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.PUT, "/api/mall/rag/toggles/**").hasAnyRole("ADMIN", "MANAGER")
// mall API (장바구니·주문·결제·배송··CS·회원·AI·RAG 추천/피드백/토글조회) 인증 사용자
// mall API (장바구니·주문·결제·배송··CS·회원·AI) 인증 사용자
.requestMatchers("/api/mall/**").authenticated()
// UIWS system(권한관리) 이식: 공개 룩업(부서/거래처 트리) 무인증, 시스템관리 API 운영자(ADMIN/MANAGER)
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/system/**").hasAnyRole("ADMIN", "MANAGER")
// 나머지 모든 API/WS/Actuator는 인증 (아래 SPA permit 보다 먼저 API 노출 방지)
.requestMatchers("/api/**", "/ws/**", "/actuator/**").authenticated()
// 스토어프론트 SPA 딥링크(/app·/cart·/events·/category·/product·/checkout·/mypage·/orders·/search )

View File

@ -5,12 +5,10 @@ import com.zioinfo.mall.integration.CrmClient;
import com.zioinfo.mall.integration.ItsmSecuritySanitizer;
import com.zioinfo.mall.member.mapper.MemberMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** 회원 API — /api/mall/member. 본인 프로필 + CRM 인사이트 연계(새니타이즈). */
@ -34,27 +32,6 @@ public class MemberController {
return ApiResponse.ok(mapper.findByUsername(auth.getName()));
}
/**
* 관리자 회원 목록/검색 MANAGER+ 전용.
*
* <p>보안 불변: 이메일·전화번호는 매퍼에서 마스킹된 값만, 상세 주소는 비포함(MallMemberSummary).
* 주문수·누적결제액 집계 동반. 키워드(아이디/이름)·등급 필터 지원.
*/
@GetMapping("/admin")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ApiResponse<Map<String, Object>> adminList(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String tier,
@RequestParam(defaultValue = "100") int limit) {
int safeLimit = (limit <= 0 || limit > 500) ? 100 : limit;
List<MallMemberSummary> items = mapper.adminList(keyword, tier, safeLimit);
int total = mapper.countAdminList(keyword, tier);
Map<String, Object> out = new LinkedHashMap<>();
out.put("items", items);
out.put("total", total);
return ApiResponse.ok(out);
}
/** CRM 고객 인사이트(구매 성향) — 응답은 ItsmSecuritySanitizer로 정제. */
@GetMapping("/me/insight")
public ApiResponse<Map<String, Object>> insight(Authentication auth) {

View File

@ -1,21 +1,11 @@
package com.zioinfo.mall.member.mapper;
import com.zioinfo.mall.member.MallMember;
import com.zioinfo.mall.member.MallMemberSummary;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MemberMapper {
MallMember findByUsername(@Param("username") String username);
int upsert(MallMember m);
/** 관리자 회원 목록(검색·등급 필터). 주문수/매출 집계 포함, PII 비노출. */
List<MallMemberSummary> adminList(@Param("keyword") String keyword,
@Param("tier") String tier,
@Param("limit") int limit);
int countAdminList(@Param("keyword") String keyword, @Param("tier") String tier);
}

View File

@ -47,33 +47,11 @@ public class SubscriptionController {
if (s == null || !s.getOwner().equals(auth.getName())) {
throw new RuntimeException("ERR-SUB-403: 본인 구독만 변경할 수 있습니다");
}
mapper.updateStatus(id, normalizeStatus(req.get("status")));
String to = req.getOrDefault("status", "ACTIVE").toUpperCase();
mapper.updateStatus(id, to);
return ApiResponse.ok(mapper.findById(id));
}
/**
* 관리자 구독 상태 변경(일시정지/재개/취소) MANAGER+ 전용. 소유자 제한 없음.
*/
@PutMapping("/admin/{id}/status")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ApiResponse<MallSubscription> adminStatus(@PathVariable Long id, @RequestBody Map<String, String> req) {
MallSubscription s = mapper.findById(id);
if (s == null) {
throw new RuntimeException("ERR-SUB-404: 구독을 찾을 수 없습니다");
}
mapper.updateStatus(id, normalizeStatus(req.get("status")));
return ApiResponse.ok(mapper.findById(id));
}
/** 허용 상태(ACTIVE/PAUSED/CANCELLED)만 통과. */
private String normalizeStatus(String raw) {
String to = raw == null ? "ACTIVE" : raw.toUpperCase();
if (!to.equals("ACTIVE") && !to.equals("PAUSED") && !to.equals("CANCELLED")) {
throw new IllegalArgumentException("ERR-SUB-400: 허용되지 않는 상태입니다");
}
return to;
}
private LocalDate nextDate(String freq) {
LocalDate base = LocalDate.now();
if ("MONTHLY".equalsIgnoreCase(freq)) return base.plusMonths(1);

View File

@ -8,20 +8,12 @@ spring:
username: ${DB_USER:mall_user}
password: ${DB_PASS:mall_pass2026}
driver-class-name: org.postgresql.Driver
# UIWS 이식: 부팅 시 91_uiws_port.sql(업무 9테이블 + mall_account 2FA ALTER) 멱등 적용.
# 전부 IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / ON CONFLICT DO NOTHING → mode:always 재실행 안전.
# schema.sql 은 deploy_server 가 psql 로 별도 적용(비멱등 시드 충돌 회피 위해 여기 미포함).
sql:
init:
mode: ${SQL_INIT_MODE:always}
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:
multipart:
max-file-size: 20MB
max-request-size: 20MB
mybatis:
mapper-locations: classpath:mapper/**/*.xml # ** : 하위 mapper/uiws/*.xml(UIWS 이식) 포함
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
@ -42,34 +34,13 @@ mall:
provider: ${MALL_SMS_PROVIDER:mock} # mock | twilio
email:
provider: ${MALL_EMAIL_PROVIDER:mock} # mock | sendgrid
# ── UIWS 이식: 2FA(운영 로그인) + 첨부 업로드 설정 (mall.uiws.*) ──────────────
uiws:
auth:
twofa-enabled: ${UIWS_2FA:true} # off=운영 로그인도 단일 JWT(회귀 0). 고객(USER)은 항상 미적용.
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}
guardia:
itsm-url: ${ITSM_URL:http://localhost:9001}
erp-url: ${ERP_URL:http://localhost:8003}
crm-url: ${CRM_URL:http://localhost:8004}
ocr-url: ${OCR_URL:http://localhost:8005}
ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
# 로컬 임베디드 DuckDB 학습 저장소 파일(솔루션 격리). 경로 미가용/드라이버 부재 시 자동 비활성(no-op).
mall:
learning:
duckdb-path: ${MALL_LEARNING_DUCKDB:/opt/guardia-mall/data/mall_learning.duckdb}
# 중앙 guardia-rag(온프레미스 전용) — 최신 AI 기법 경유. 미가용 시 Mall 로컬 폴백(degraded)
rag:
base-url: ${RAG_URL:http://127.0.0.1:8020}
timeout-ms: ${RAG_TIMEOUT_MS:120000}
enabled: ${RAG_ENABLED:true}
solution: mall
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
crypto:
secret: ${CRYPTO_SECRET:guardia-mall-aes-256-gcm-master-key-2026-zioinfo}
jwt:

View File

@ -47,20 +47,7 @@ INSERT INTO mall_setting (key, value) VALUES
('hours_saturday','Sat 9:00 AM - 4:00 PM'),
('hours_sunday','Sun 9:00 AM - 12:00 PM'),
('payment_provider','mock'),('tax_provider','mock'),('address_provider','mock'),
('sms_provider','mock'),('email_provider','mock'),
-- 최신 AI 기법(중앙 guardia-rag) 토글 — 무거운 기법(graphrag·rerank·tool_use·stream)은 서버 RAM 제약상 기본 off
('rag.enabled','true'),
('rag.retrieval_mode','vector'),
('rag.rerank','false'),
('rag.graphrag','false'),
('rag.tool_use','false'),
('rag.structured','true'),
('rag.stream','false'),
('rag.top_k','6'),
('rag.agent_max_steps','4'),
('rag.faithfulness_threshold','0.5'),
('rag.temperature','0.2'),
('rag.generation_model','llama3.2:1b')
('sms_provider','mock'),('email_provider','mock')
ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS mall_ai_result (

View File

@ -32,7 +32,6 @@
<update id="updateRole">UPDATE mall_account SET role = #{role} WHERE id = #{id}</update>
<update id="updateActive">UPDATE mall_account SET is_active = #{active} WHERE id = #{id}</update>
<update id="updatePassword">UPDATE mall_account SET password_hash = #{passwordHash} WHERE id = #{id}</update>
<update id="clearOtp">UPDATE mall_account SET otp_secret = NULL, otp_enabled = false WHERE id = #{id}</update>
<delete id="deleteById">DELETE FROM mall_account WHERE id = #{id}</delete>
<select id="countAdmins" resultType="int">

View File

@ -11,44 +11,4 @@
display_name = EXCLUDED.display_name, email = EXCLUDED.email, phone = EXCLUDED.phone,
default_zip = EXCLUDED.default_zip, default_address = EXCLUDED.default_address
</insert>
<!--
관리자 회원 목록 — PII 비노출.
email/phone 은 SQL 레벨에서 마스킹(앞 2자 + *** + 도메인 / 끝 4자리만). 상세 주소는 미선택.
주문수/누적결제액은 mall_order 를 owner(=username) 로 LEFT JOIN 집계.
-->
<sql id="adminWhere">
<where>
<if test="keyword != null and keyword != ''">
AND (m.username ILIKE '%' || #{keyword} || '%' OR m.display_name ILIKE '%' || #{keyword} || '%')
</if>
<if test="tier != null and tier != ''">AND m.tier = #{tier}</if>
</where>
</sql>
<select id="adminList" resultType="com.zioinfo.mall.member.MallMemberSummary">
SELECT
m.id, m.username, m.display_name AS displayName,
CASE WHEN m.email IS NULL OR m.email = '' THEN NULL
WHEN POSITION('@' IN m.email) > 2
THEN SUBSTRING(m.email FROM 1 FOR 2) || '***' || SUBSTRING(m.email FROM POSITION('@' IN m.email))
ELSE '***' || SUBSTRING(m.email FROM POSITION('@' IN m.email)) END AS emailMasked,
CASE WHEN m.phone IS NULL OR LENGTH(m.phone) &lt; 4 THEN NULL
ELSE '***-****-' || SUBSTRING(m.phone FROM LENGTH(m.phone) - 3) END AS phoneMasked,
m.default_zip AS defaultZip, m.tier, m.created_at AS createdAt,
COALESCE(o.order_count, 0) AS orderCount,
COALESCE(o.total_spent, 0) AS totalSpent
FROM mall_member m
LEFT JOIN (
SELECT owner, COUNT(*) AS order_count, SUM(COALESCE(pay_amount, total_amount, 0)) AS total_spent
FROM mall_order WHERE status NOT IN ('CANCELLED','REFUNDED','FAILED') GROUP BY owner
) o ON o.owner = m.username
<include refid="adminWhere"/>
ORDER BY m.created_at DESC
LIMIT #{limit}
</select>
<select id="countAdminList" resultType="int">
SELECT COUNT(*) FROM mall_member m <include refid="adminWhere"/>
</select>
</mapper>

View File

@ -11,21 +11,10 @@
<result property="displayName" column="display_name"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
<!-- UIWS 2FA 이식 컬럼 (mall_account 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>
<select id="findByUsername" resultMap="userMap">
SELECT id, username, password_hash, role, display_name, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
SELECT id, username, password_hash, role, display_name, is_active, created_at
FROM mall_account
WHERE username = #{username}
</select>
@ -40,42 +29,4 @@
SELECT COUNT(*) FROM mall_account WHERE username = #{username}
</select>
<!-- ── 로그인 보조 이식: 회원가입 / 아이디찾기 / 비밀번호 초기화 ─────────────── -->
<select id="countByEmail" resultType="int">
SELECT COUNT(*) FROM mall_account WHERE email = #{email}
</select>
<!-- 회원가입(승인 대기): 운영자 신청 → role=MANAGER·is_active=true·approved=false·실패카운트0·미잠금 고정 -->
<insert id="signup" parameterType="com.zioinfo.mall.auth.MallUser"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO mall_account (username, password_hash, display_name, role, email,
is_active, approved, login_fail_count, locked)
VALUES (#{username}, #{passwordHash}, #{displayName}, 'MANAGER', #{email},
true, false, 0, false)
</insert>
<select id="findByDisplayNameAndEmail" resultMap="userMap">
SELECT id, username, password_hash, role, display_name, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM mall_account
WHERE display_name = #{displayName} AND email = #{email}
ORDER BY id
LIMIT 1
</select>
<select id="findByUsernameAndEmail" resultMap="userMap">
SELECT id, username, password_hash, role, display_name, is_active, created_at,
email, email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret, otp_enabled, approved
FROM mall_account
WHERE username = #{username} AND email = #{email}
</select>
<!-- 임시비번 적용 + 잠금/실패카운트 해제(초기화 시) -->
<update id="updatePasswordHash">
UPDATE mall_account
SET password_hash = #{passwordHash}, locked = false, login_fail_count = 0
WHERE username = #{username}
</update>
</mapper>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -4,7 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fdfaf5" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@ -16,8 +15,8 @@
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Playfair+Display:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/index-Cc_He-EQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DGX5ektI.css">
<script type="module" crossorigin src="/assets/index-CUN395kM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BzL8NSpt.css">
</head>
<body>
<div id="root"></div>

View File

@ -4,7 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#fdfaf5" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -41,24 +41,6 @@ import UserManagement from './admin/UserManagement'
import AuditLog from './admin/AuditLog'
import Settings from './admin/Settings'
import AdminApp from './admin/AdminApp'
import AiTechniques from './admin/AiTechniques'
import AiPlatformSettings from './admin/AiPlatformSettings'
import MyPage from './admin/MyPage'
import WiseAiPage from './pages/WiseAiPage'
// UIWS 이식 — 업무 모듈(관리자 영역 병합, 고객 쇼핑 화면 무영향)
import UiwsLayout from './pages/uiws/UiwsLayout'
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 시스템관리(권한 RBAC) — 관리자 영역 (ADMIN 전용)
import UiwsRole from './pages/uiws/system/RoleManagement'
import UiwsRoleMenu from './pages/uiws/system/RoleMenuPermission'
import UiwsCode from './pages/uiws/system/CodeManagement'
import UiwsMenu from './pages/uiws/system/MenuManagement'
import UiwsDept from './pages/uiws/system/DeptManagement'
import UiwsCompany from './pages/uiws/system/CompanyManagement'
const qc = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 } },
@ -98,7 +80,6 @@ export default function App() {
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="mypage" element={<MyPage />} />
<Route path="stores" element={<Stores />} />
<Route path="products" element={<Products />} />
<Route path="inventory" element={<Inventory />} />
@ -113,22 +94,7 @@ export default function App() {
<Route path="users" element={<UserManagement />} />
<Route path="audit" element={<AuditLog />} />
<Route path="settings" element={<Settings />} />
<Route path="wise-ai" element={<WiseAiPage />} />
<Route path="ai-techniques" element={<AiTechniques />} />
<Route path="ai-platform" element={<AiPlatformSettings />} />
<Route path="app" element={<AdminApp />} />
{/* UIWS 이식 업무 모듈 (관리자 영역 병합) */}
<Route path="uiws/worklog" element={<UiwsLayout><UiwsWorklog /></UiwsLayout>} />
<Route path="uiws/schedule" element={<UiwsLayout><UiwsSchedule /></UiwsLayout>} />
<Route path="uiws/message" element={<UiwsLayout><UiwsMessage /></UiwsLayout>} />
<Route path="uiws/stats" element={<UiwsLayout><UiwsStats /></UiwsLayout>} />
{/* UIWS 시스템관리(권한 RBAC) — ADMIN 전용(사이드바 가드 + 백엔드 권한) */}
<Route path="uiws/system/roles" element={<UiwsLayout><UiwsRole /></UiwsLayout>} />
<Route path="uiws/system/role-menus" element={<UiwsLayout><UiwsRoleMenu /></UiwsLayout>} />
<Route path="uiws/system/codes" element={<UiwsLayout><UiwsCode /></UiwsLayout>} />
<Route path="uiws/system/menus" element={<UiwsLayout><UiwsMenu /></UiwsLayout>} />
<Route path="uiws/system/depts" element={<UiwsLayout><UiwsDept /></UiwsLayout>} />
<Route path="uiws/system/companies" element={<UiwsLayout><UiwsCompany /></UiwsLayout>} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />

View File

@ -1,10 +1,8 @@
import { useEffect, useState } from 'react'
import { Outlet, Navigate, NavLink, Link, useNavigate } from 'react-router-dom'
import { Outlet, Navigate, NavLink, useNavigate } from 'react-router-dom'
import {
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone,
ClipboardList, CalendarDays, Mail, PieChart, Sparkles, Cpu,
ShieldCheck, KeySquare, ListTree, Menu as MenuIcon, Building2, Briefcase, BrainCircuit,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { getMe } from '../api/client'
@ -30,28 +28,6 @@ const adminLinks = [
{ to: '/admin/settings', key: 'settings', icon: Settings, roles: ['ADMIN'] },
{ to: '/admin/app', key: 'appInstall', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] },
]
// 최신 AI 기법(중앙 guardia-rag) 토글 — 라벨 i18n 미의존(고정 표기), 변경은 MANAGER+(USER 차단)
const aiLinks = [
{ to: '/admin/wise-ai', label: 'WISE AI', icon: BrainCircuit, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/ai-techniques', label: 'AI Techniques', icon: Sparkles, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/ai-platform', label: 'AI 플랫폼 설정', icon: Cpu, roles: ['ADMIN'] },
]
// UIWS 이식 — 업무 모듈(관리자 영역 병합). 라벨은 i18n 미의존(고정 한/영 안전 표기).
const uiwsLinks = [
{ to: '/admin/uiws/worklog', label: '업무일지 Worklog', icon: ClipboardList, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/uiws/schedule', label: '일정 Schedule', icon: CalendarDays, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/uiws/message', label: '쪽지 Message', icon: Mail, roles: ['ADMIN', 'MANAGER'] },
{ to: '/admin/uiws/stats', label: '업무통계 Stats', icon: PieChart, roles: ['ADMIN', 'MANAGER'] },
]
// UIWS 시스템관리(권한 RBAC) — SuperAdmin(ADMIN) 전용. 고객앱/기존 admin 보존, 백엔드 권한과 이중 가드.
const sysLinks = [
{ to: '/admin/uiws/system/roles', label: '권한 관리', icon: ShieldCheck, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/role-menus', label: '역할-메뉴 권한', icon: KeySquare, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/codes', label: '공통코드', icon: ListTree, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/menus', label: '메뉴 관리', icon: MenuIcon, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/depts', label: '부서 관리', icon: Building2, roles: ['ADMIN'] },
{ to: '/admin/uiws/system/companies', label: '거래처 관리', icon: Briefcase, roles: ['ADMIN'] },
]
const linkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${isActive ? 'bg-card text-brand border-r-2 border-brand' : 'text-slate-300 hover:bg-card/60'}`
@ -81,9 +57,6 @@ export default function AdminLayout() {
const visible = links.filter(l => !role || l.roles.includes(role))
const visibleAdmin = adminLinks.filter(l => l.roles.includes(role))
const visibleAi = aiLinks.filter(l => !role || l.roles.includes(role))
const visibleUiws = uiwsLinks.filter(l => !role || l.roles.includes(role))
const visibleSys = sysLinks.filter(l => l.roles.includes(role)) // ADMIN 전용(role 비어있으면 비표시)
const logout = () => {
localStorage.removeItem('mall_admin_token'); localStorage.removeItem('mall_role'); localStorage.removeItem('mall_admin_user')
@ -103,18 +76,6 @@ export default function AdminLayout() {
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">{t('admin.system')}</div>
{visibleAdmin.map(({ to, key, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{t(`admin.nav.${key}`)}</NavLink>)}
</>)}
{visibleAi.length > 0 && (<>
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3">AI</div>
{visibleAi.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
</>)}
{visibleUiws.length > 0 && (<>
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3"> (UIWS)</div>
{visibleUiws.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
</>)}
{visibleSys.length > 0 && (<>
<div className="px-5 pt-4 pb-1.5 text-[10px] font-semibold tracking-wider text-slate-500 uppercase border-t border-edge mt-3"> ()</div>
{visibleSys.map(({ to, label, icon: Icon }) => <NavLink key={to} to={to} className={linkClass}><Icon size={18} />{label}</NavLink>)}
</>)}
</nav>
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">{t('admin.onPremiseTag')}</div>
</aside>
@ -123,7 +84,7 @@ export default function AdminLayout() {
<div className="text-sm text-slate-400 truncate">{t('admin.header')}</div>
<div className="flex items-center gap-4">
<LanguageSwitcher variant="admin" />
<Link to="/admin/mypage" className="flex items-center gap-1.5 text-sm text-slate-300 hover:text-brand" title="마이페이지 / My Page"><UserCircle size={18} /> {who || 'admin'} <span className="text-[10px] text-brand">{role}</span></Link>
<span className="flex items-center gap-1.5 text-sm text-slate-300"><UserCircle size={18} /> {who || 'admin'} <span className="text-[10px] text-brand">{role}</span></span>
<button onClick={logout} className="flex items-center gap-1.5 text-sm text-slate-400 hover:text-brand"><LogOut size={16} /> {t('admin.signOut')}</button>
</div>
</header>

View File

@ -2,269 +2,57 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { login, getMe } from '../api/client'
import { verify2fa, verifyOtp, authSignup, authFindId, authResetPassword } from '../api/uiws'
import LanguageSwitcher from '../i18n/LanguageSwitcher'
/** 로그인 보조 모달 종류(UIWS auth 패턴 이식). */
type HelperKind = 'signup' | 'findId' | 'resetPw'
/** 2차 인증 방식 — OTP: Authenticator 등록됨, OTP_SETUP: 최초 QR 등록, EMAIL: 이메일 코드(하위호환). */
type VerifyMethod = 'OTP' | 'OTP_SETUP' | 'EMAIL'
/**
* /() .
* - UIWS 2FA 이식: 운영 (ADMIN/MANAGER) + twofa-enabled 1 verify-token + .
* { twofa:"false", token } (2FA off off 0).
* { twofa:"true", verifyToken, maskedEmail } /api/mall/auth/verify token.
* - / ( ). (admin email ) .
*/
export default function AdminLogin() {
const { t } = useTranslation()
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [err, setErr] = useState('')
// 2FA 단계
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('')
// 로그인 보조 모달(UIWS auth 패턴 이식): 회원가입 / 아이디찾기 / 비밀번호 초기화
const [helper, setHelper] = useState<HelperKind | null>(null)
const nav = useNavigate()
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
const isSetup = verifyMethod === 'OTP_SETUP'
useEffect(() => {
document.documentElement.classList.add('admin-shell')
return () => document.documentElement.classList.remove('admin-shell')
}, [])
/** 토큰 저장 + 역할 확인 후 진입(공통). */
const finishLogin = async (token: string) => {
localStorage.setItem('mall_admin_token', token)
const me: any = await getMe().catch(() => null)
if (me?.role) localStorage.setItem('mall_role', me.role)
if (me?.username) localStorage.setItem('mall_admin_user', me.username)
if (me?.role === 'USER') { setErr(t('admin.login.errNoPriv')); localStorage.removeItem('mall_admin_token'); return }
nav('/admin/dashboard')
}
const submitLogin = async (e: React.FormEvent) => {
const submit = async (e: React.FormEvent) => {
e.preventDefault(); setErr('')
try {
const res = await login(username, password)
const data = res.data?.data || {}
if (data.twofa === 'true') {
// 운영 계정 2FA — verifyMethod 로 분기(OTP/OTP_SETUP: Authenticator, 그 외: 이메일 코드)
setVerifyToken(data.verifyToken || '')
setVerifyMethod((data.verifyMethod as VerifyMethod) || 'EMAIL')
setMaskedEmail(data.maskedEmail || '')
setQrImage(data.qrImage || '')
setSecret(data.secret || '')
setCode('')
setStep('verify')
return
}
const token = data.token
if (!token) throw new Error('no token')
await finishLogin(token)
} catch {
setErr(t('admin.login.errFailed'))
}
}
const submitVerify = async (e: React.FormEvent) => {
e.preventDefault(); setErr('')
try {
// OTP·OTP_SETUP 은 /verify-otp, 이메일은 /verify(하위호환)
const res = isOtp
? await verifyOtp(verifyToken, code.trim())
: await verify2fa(verifyToken, code.trim())
const token = res.data?.data?.token
if (!token) throw new Error('no token')
// 시크릿 잔존 방지
setQrImage(''); setSecret('')
await finishLogin(token)
// Store the admin token under a key separate from the customer session (mall_token) — used by the interceptor in the /admin area
localStorage.setItem('mall_admin_token', token)
const me: any = await getMe().catch(() => null)
if (me?.role) localStorage.setItem('mall_role', me.role)
if (me?.username) localStorage.setItem('mall_admin_user', me.username)
if (me?.role === 'USER') { setErr(t('admin.login.errNoPriv')); localStorage.removeItem('mall_admin_token'); return }
nav('/admin/dashboard')
} catch {
setErr('인증 코드가 올바르지 않거나 만료되었습니다. (Invalid or expired code)')
setErr(t('admin.login.errFailed'))
}
}
return (
<div className="admin-shell min-h-screen flex items-center justify-center bg-ink text-[#e6edf6]">
<div className="absolute top-5 right-5"><LanguageSwitcher variant="admin" /></div>
{step === 'login' ? (
<form onSubmit={submitLogin} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<div className="flex items-center gap-2 justify-center mb-6">
<img src="/login.png" alt="GUARDiA" style={{height:36,width:'auto'}} onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
<span className="text-xl font-bold">{t('admin.login.title')}</span>
</div>
<p className="text-center text-sm text-slate-400 mb-6">{t('admin.login.subtitle')}</p>
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.username')}</label>
<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">{t('admin.login.password')}</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" />
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">{t('admin.login.signIn')}</button>
{/* 로그인 보조 3종(UIWS auth 이식): 운영자 회원가입 / 아이디 찾기 / 비밀번호 초기화 */}
<div className="flex items-center justify-center gap-2 text-[11px] text-slate-400 mt-4">
<button type="button" onClick={() => setHelper('signup')} className="hover:text-brand"> </button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => setHelper('findId')} className="hover:text-brand"> </button>
<span className="text-slate-600">|</span>
<button type="button" onClick={() => setHelper('resetPw')} className="hover:text-brand"> </button>
</div>
<p className="text-center text-[11px] text-slate-500 mt-3">{t('admin.login.storefrontHere')} <a href="/" className="text-brand">{t('admin.login.here')}</a></p>
</form>
) : (
<form onSubmit={submitVerify} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<div className="flex items-center gap-2 justify-center mb-4">
<span className="text-xl font-bold">2 / 2FA</span>
</div>
<p className="text-center text-sm text-slate-400 mb-2"> 2 .</p>
{isOtp ? (
isSetup ? (
<>
<p className="text-center text-[12px] text-slate-500 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-[12px] text-slate-500 mb-6">
Authenticator 6 .
</p>
)
) : (
<p className="text-center text-[12px] text-slate-500 mb-6">
{maskedEmail || '등록된 이메일'} .
</p>
)}
<label className="block text-xs text-slate-400 mb-1"> (6)</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
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm tracking-[0.4em] text-center focus:border-brand outline-none" />
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">{isSetup ? '등록하고 로그인 / Register' : '확인 / Verify'}</button>
<button type="button" onClick={() => { setStep('login'); setCode(''); setErr(''); setQrImage(''); setSecret('') }}
className="w-full py-2 mt-2 rounded-lg text-slate-400 text-xs hover:text-brand"> / Back</button>
</form>
)}
{helper && <AuthHelperModal kind={helper} onClose={() => setHelper(null)} />}
</div>
)
}
/**
* (UIWS auth ) / / .
* (bg-panel/bg-card/border-edge/bg-brand/text-ink) .
* 보안: 서버가 · .
*/
function AuthHelperModal({ kind, onClose }: { kind: HelperKind; onClose: () => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [ok, setOk] = useState(false)
const title = kind === 'signup' ? '운영자 가입 (승인 대기)'
: kind === 'findId' ? '아이디 찾기' : '비밀번호 초기화'
const submit = async (e: React.FormEvent) => {
e.preventDefault(); setBusy(true); setMsg(''); setOk(false)
try {
if (kind === 'signup') {
const res = await authSignup({ username, password, displayName, email })
const d = res.data?.data || {}
setOk(!!d.success); setMsg(d.message || '처리되었습니다.')
} else if (kind === 'findId') {
const res = await authFindId({ displayName, email })
const d = res.data?.data || {}
setOk(!!d.found)
setMsg(d.found ? `회원님의 아이디는 [ ${d.maskedUsername} ] 입니다.` : '일치하는 계정을 찾을 수 없습니다.')
} else {
const res = await authResetPassword({ username, email })
const d = res.data?.data || {}
setOk(!!d.success); setMsg(d.message || '처리되었습니다.')
}
} catch {
setMsg('요청을 처리하지 못했습니다. 잠시 후 다시 시도하세요.')
} finally {
setBusy(false)
}
}
const field = 'w-full mb-3 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="w-[360px] bg-panel border border-edge rounded-2xl p-7 text-[#e6edf6]" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<span className="text-lg font-bold">{title}</span>
<button type="button" onClick={onClose} className="text-slate-400 hover:text-brand text-xl leading-none">×</button>
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
<div className="flex items-center gap-2 justify-center mb-6">
<img src="/login.png" alt="GUARDiA" style={{height:36,width:'auto'}} onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
<span className="text-xl font-bold">{t('admin.login.title')}</span>
</div>
<form onSubmit={submit}>
{(kind === 'signup' || kind === 'resetPw') && (
<>
<label className="block text-xs text-slate-400 mb-1"></label>
<input value={username} onChange={e => setUsername(e.target.value)} className={field} autoFocus />
</>
)}
{kind === 'signup' && (
<>
<label className="block text-xs text-slate-400 mb-1"> (4 )</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={field} />
</>
)}
{(kind === 'signup' || kind === 'findId') && (
<>
<label className="block text-xs text-slate-400 mb-1"> ()</label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className={field} autoFocus={kind === 'findId'} />
</>
)}
<label className="block text-xs text-slate-400 mb-1"></label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} className={field} />
{msg && <p className={`text-xs mb-3 ${ok ? 'text-emerald-400' : 'text-amber-400'}`}>{msg}</p>}
<button disabled={busy}
className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90 disabled:opacity-60">
{busy ? '처리 중…' : (kind === 'signup' ? '가입 신청' : kind === 'findId' ? '아이디 찾기' : '임시 비밀번호 발송')}
</button>
</form>
{kind === 'signup' && (
<p className="text-[11px] text-slate-500 mt-3"> .</p>
)}
{kind === 'resetPw' && (
<p className="text-[11px] text-slate-500 mt-3"> .</p>
)}
</div>
<p className="text-center text-sm text-slate-400 mb-6">{t('admin.login.subtitle')}</p>
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.username')}</label>
<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">{t('admin.login.password')}</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" />
{err && <p className="text-rose-400 text-xs mb-3">{err}</p>}
<button className="w-full py-2.5 rounded-lg bg-brand text-ink font-semibold hover:bg-brand/90">{t('admin.login.signIn')}</button>
<p className="text-center text-[11px] text-slate-500 mt-4">{t('admin.login.storefrontHere')} <a href="/" className="text-brand">{t('admin.login.here')}</a></p>
</form>
</div>
)
}

View File

@ -1,23 +1,16 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Users, RefreshCw, Search } from 'lucide-react'
import { getLoyaltyByTier, recalcAllLoyalty, getAdminMembers } from '../api/client'
import { Users, RefreshCw } from 'lucide-react'
import { getLoyaltyByTier, recalcAllLoyalty } from '../api/client'
import Chart from '../components/Chart'
import StatusBadge from '../components/StatusBadge'
import { money } from '../store/shop'
const TIERS = ['', 'STANDARD', 'BASIC', 'SILVER', 'GOLD', 'VIP']
export default function Members() {
const qc = useQueryClient()
const [kw, setKw] = useState('')
const [tier, setTier] = useState('')
const { data: byTier } = useQuery({ queryKey: ['by-tier'], queryFn: () => getLoyaltyByTier(30) })
const { data: members } = useQuery({ queryKey: ['admin-members', kw, tier], queryFn: () => getAdminMembers(kw, tier, 200) })
const recalc = async () => { await recalcAllLoyalty().catch(() => {}); qc.invalidateQueries({ queryKey: ['by-tier'] }) }
const rows = (byTier || []).map((t: any) => ({ name: t.tier, customers: t.customers, sales: t.sales }))
const list = members?.items || []
return (
<div>
@ -37,8 +30,7 @@ export default function Members() {
</div>
</div>
<div className="bg-card border border-edge rounded-xl p-5 mb-5">
<h2 className="text-sm font-semibold mb-3">Tier Summary</h2>
<div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">Tier</th><th className="text-right">Customers</th><th className="text-right">Orders</th><th className="text-right">Sales</th>
@ -56,46 +48,6 @@ export default function Members() {
</tbody>
</table>
</div>
{/* 회원 목록/검색 — PII(이메일·전화)는 마스킹된 값만 표시 */}
<div className="bg-card border border-edge rounded-xl overflow-hidden">
<div className="flex items-center justify-between p-4 border-b border-edge">
<h2 className="text-sm font-semibold">Member List <span className="text-slate-500 font-normal">({members?.total ?? 0})</span></h2>
<div className="flex items-center gap-2">
<select value={tier} onChange={e => setTier(e.target.value)} className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm">
{TIERS.map(t => <option key={t} value={t}>{t || 'All Tiers'}</option>)}
</select>
<div className="flex items-center gap-2 bg-panel border border-edge rounded-lg px-3 py-2">
<Search size={15} className="text-slate-500" />
<input value={kw} onChange={e => setKw(e.target.value)} placeholder="Search by name or ID" className="bg-transparent text-sm outline-none w-48" />
</div>
</div>
</div>
<table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">Customer</th><th className="text-left">Email</th><th className="text-left">Phone</th>
<th className="text-left">ZIP</th><th className="text-center">Tier</th><th className="text-right">Orders</th><th className="text-right">Lifetime Value</th><th className="text-left">Joined</th>
</tr></thead>
<tbody>
{list.map((m: any) => (
<tr key={m.id} className="border-b border-edge/50 hover:bg-panel/50">
<td className="p-3">
<div className="font-medium">{m.displayName || m.username}</div>
<div className="text-xs text-slate-500 font-mono">@{m.username}</div>
</td>
<td className="text-slate-400">{m.emailMasked || '-'}</td>
<td className="text-slate-400">{m.phoneMasked || '-'}</td>
<td className="text-slate-400">{m.defaultZip || '-'}</td>
<td className="text-center"><StatusBadge status={m.tier} /></td>
<td className="text-right text-slate-400">{m.orderCount}</td>
<td className="text-right">{money(m.totalSpent)}</td>
<td className="text-slate-400 text-xs">{m.createdAt ? String(m.createdAt).slice(0, 10) : '-'}</td>
</tr>
))}
{!list.length && <tr><td colSpan={8} className="text-center text-slate-500 py-8">No members found.</td></tr>}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -1,184 +1,35 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Flower2, Search, Plus, Pencil, Trash2, X } from 'lucide-react'
import {
getProducts, getProduct, getCategories, createProduct, updateProduct, deleteProduct, setProductStatus,
} from '../api/client'
import { Flower2, Search } from 'lucide-react'
import { getProducts, setProductStatus } from '../api/client'
import StatusBadge from '../components/StatusBadge'
import { money } from '../store/shop'
const STATUSES = ['ON_SALE', 'SOLD_OUT', 'HIDDEN']
const OCCASIONS = ['', 'BIRTHDAY', 'ANNIVERSARY', 'SYMPATHY', 'LOVE', 'CONGRATS', 'GET_WELL', 'THANK_YOU']
const FLOWERS = ['', 'ROSES', 'TULIPS', 'LILIES', 'ORCHIDS', 'SUNFLOWERS', 'MIXED']
type SizeRow = { sizeCode: string; label: string; price: string; stemCount: string }
const EMPTY = {
id: 0, categoryId: '', sku: '', name: '', brand: '', description: '',
price: '', salePrice: '', status: 'ON_SALE', stock: '0', thumbnail: '',
occasion: '', flowerType: '', shelfLifeDays: '5',
sizes: [] as SizeRow[],
}
export default function Products() {
const qc = useQueryClient()
const [kw, setKw] = useState('')
const [open, setOpen] = useState(false)
const [form, setForm] = useState<any>({ ...EMPTY })
const [err, setErr] = useState('')
const [saving, setSaving] = useState(false)
const { data } = useQuery({ queryKey: ['admin-products', kw], queryFn: () => getProducts({ keyword: kw, size: 100, status: '' }) })
const { data: cats } = useQuery({ queryKey: ['admin-cats'], queryFn: getCategories })
const items = data?.items || []
const categories = cats || []
const refresh = () => qc.invalidateQueries({ queryKey: ['admin-products'] })
const cycle = async (id: number, status: string) => {
const next = status === 'ON_SALE' ? 'HIDDEN' : 'ON_SALE'
await setProductStatus(id, next).catch(() => {}); refresh()
const next = status === 'ACTIVE' ? 'HIDDEN' : 'ACTIVE'
await setProductStatus(id, next).catch(() => {}); qc.invalidateQueries({ queryKey: ['admin-products'] })
}
const openNew = () => { setForm({ ...EMPTY }); setErr(''); setOpen(true) }
const openEdit = async (id: number) => {
setErr('')
const p = await getProduct(id).catch(() => null)
if (!p) return
setForm({
id: p.id, categoryId: p.categoryId ?? '', sku: p.sku ?? '', name: p.name ?? '', brand: p.brand ?? '',
description: p.description ?? '', price: String(p.price ?? ''), salePrice: p.salePrice != null ? String(p.salePrice) : '',
status: p.status || 'ON_SALE', stock: String(p.stock ?? 0), thumbnail: p.thumbnail ?? '',
occasion: p.occasion ?? '', flowerType: p.flowerType ?? '', shelfLifeDays: String(p.shelfLifeDays ?? 5),
sizes: (p.sizes || []).map((s: any) => ({ sizeCode: s.sizeCode || '', label: s.label || '', price: String(s.price ?? ''), stemCount: String(s.stemCount ?? '') })),
})
setOpen(true)
}
const addSize = () => setForm((f: any) => ({ ...f, sizes: [...f.sizes, { sizeCode: 'ORIGINAL', label: '', price: '', stemCount: '' }] }))
const setSize = (i: number, k: string, v: string) => setForm((f: any) => ({ ...f, sizes: f.sizes.map((s: SizeRow, idx: number) => idx === i ? { ...s, [k]: v } : s) }))
const delSize = (i: number) => setForm((f: any) => ({ ...f, sizes: f.sizes.filter((_: SizeRow, idx: number) => idx !== i) }))
const save = async () => {
if (!form.name.trim()) { setErr('Product name is required.'); return }
if (form.price === '' || isNaN(Number(form.price))) { setErr('A valid price is required.'); return }
setSaving(true); setErr('')
const payload: any = {
categoryId: form.categoryId === '' ? null : Number(form.categoryId),
sku: form.sku.trim() || null, name: form.name.trim(), brand: form.brand.trim() || null,
description: form.description.trim() || null, price: Number(form.price),
salePrice: form.salePrice === '' ? null : Number(form.salePrice),
status: form.status, stock: form.stock === '' ? 0 : Number(form.stock),
thumbnail: form.thumbnail.trim() || null, occasion: form.occasion || null,
flowerType: form.flowerType || null, shelfLifeDays: form.shelfLifeDays === '' ? 5 : Number(form.shelfLifeDays),
sizes: form.sizes
.filter((s: SizeRow) => s.sizeCode && s.price !== '')
.map((s: SizeRow, i: number) => ({
sizeCode: s.sizeCode, label: s.label || s.sizeCode, price: Number(s.price),
stemCount: s.stemCount === '' ? null : Number(s.stemCount), sortOrder: i,
})),
}
try {
if (form.id) await updateProduct(form.id, payload)
else await createProduct(payload)
setOpen(false); refresh()
} catch {
setErr('Failed to save the product. Please check the inputs.')
} finally { setSaving(false) }
}
const remove = async (id: number, name: string) => {
if (!window.confirm(`Delete product "${name}"? This cannot be undone.`)) return
await deleteProduct(id).catch(() => {}); refresh()
}
const fld = 'bg-panel border border-edge rounded-lg px-3 py-2 text-sm'
return (
<div>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2"><Flower2 className="text-brand" size={22} /><h1 className="text-xl font-bold"> </h1></div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 bg-card border border-edge rounded-lg px-3 py-2">
<Search size={15} className="text-slate-500" />
<input value={kw} onChange={e => setKw(e.target.value)} placeholder="상품 검색" className="bg-transparent text-sm outline-none w-48" />
</div>
<button onClick={openNew} className="flex items-center gap-1.5 bg-brand text-ink text-sm font-semibold px-3 py-2 rounded-lg"><Plus size={15} /> New Product</button>
<div className="flex items-center gap-2 bg-card border border-edge rounded-lg px-3 py-2">
<Search size={15} className="text-slate-500" />
<input value={kw} onChange={e => setKw(e.target.value)} placeholder="상품 검색" className="bg-transparent text-sm outline-none w-48" />
</div>
</div>
{open && (
<div className="fixed inset-0 z-50 bg-black/60 flex items-start justify-center overflow-y-auto p-4" onClick={() => !saving && setOpen(false)}>
<div className="bg-card border border-edge rounded-xl p-5 w-full max-w-2xl mt-8 space-y-3" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold">{form.id ? 'Edit Product' : 'New Product'}</h2>
<button onClick={() => setOpen(false)} className="text-slate-400 hover:text-slate-200"><X size={18} /></button>
</div>
{err && <div className="text-xs text-rose-400 bg-rose-500/10 border border-rose-500/30 rounded-lg px-3 py-2">{err}</div>}
<div className="grid grid-cols-3 gap-3">
<input value={form.sku} onChange={e => setForm({ ...form, sku: e.target.value })} placeholder="SKU" className={fld} />
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Product Name *" className={`${fld} col-span-2`} />
</div>
<div className="grid grid-cols-3 gap-3">
<select value={form.categoryId} onChange={e => setForm({ ...form, categoryId: e.target.value })} className={fld}>
<option value="">Category</option>
{categories.map((c: any) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<input value={form.brand} onChange={e => setForm({ ...form, brand: e.target.value })} placeholder="Brand" className={fld} />
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })} className={fld}>
{STATUSES.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="Description" rows={2} className={`${fld} w-full`} />
<div className="grid grid-cols-4 gap-3">
<input type="number" value={form.price} onChange={e => setForm({ ...form, price: e.target.value })} placeholder="Price *" className={fld} />
<input type="number" value={form.salePrice} onChange={e => setForm({ ...form, salePrice: e.target.value })} placeholder="Sale Price" className={fld} />
<input type="number" value={form.stock} onChange={e => setForm({ ...form, stock: e.target.value })} placeholder="Stock" className={fld} />
<input type="number" value={form.shelfLifeDays} onChange={e => setForm({ ...form, shelfLifeDays: e.target.value })} placeholder="Shelf Life (days)" className={fld} />
</div>
<div className="grid grid-cols-3 gap-3">
<select value={form.occasion} onChange={e => setForm({ ...form, occasion: e.target.value })} className={fld}>
{OCCASIONS.map(o => <option key={o} value={o}>{o || 'Occasion'}</option>)}
</select>
<select value={form.flowerType} onChange={e => setForm({ ...form, flowerType: e.target.value })} className={fld}>
{FLOWERS.map(o => <option key={o} value={o}>{o || 'Flower Type'}</option>)}
</select>
<input value={form.thumbnail} onChange={e => setForm({ ...form, thumbnail: e.target.value })} placeholder="Thumbnail URL" className={fld} />
</div>
<div className="border border-edge rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-300">Sizes (Original / Deluxe / Grand)</span>
<button onClick={addSize} className="text-xs flex items-center gap-1 text-brand"><Plus size={12} /> Add Size</button>
</div>
{form.sizes.length === 0 && <p className="text-xs text-slate-500">No size variants. Base price is used.</p>}
{form.sizes.map((s: SizeRow, i: number) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<select value={s.sizeCode} onChange={e => setSize(i, 'sizeCode', e.target.value)} className={`${fld} col-span-3`}>
{['ORIGINAL', 'DELUXE', 'GRAND'].map(c => <option key={c}>{c}</option>)}
</select>
<input value={s.label} onChange={e => setSize(i, 'label', e.target.value)} placeholder="Label" className={`${fld} col-span-3`} />
<input type="number" value={s.price} onChange={e => setSize(i, 'price', e.target.value)} placeholder="Price" className={`${fld} col-span-3`} />
<input type="number" value={s.stemCount} onChange={e => setSize(i, 'stemCount', e.target.value)} placeholder="Stems" className={`${fld} col-span-2`} />
<button onClick={() => delSize(i)} className="col-span-1 text-rose-400 flex justify-center"><Trash2 size={14} /></button>
</div>
))}
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button onClick={() => setOpen(false)} disabled={saving} className="border border-edge text-slate-400 text-sm px-4 py-2 rounded-lg">Cancel</button>
<button onClick={save} disabled={saving} className="bg-brand text-ink font-semibold text-sm px-5 py-2 rounded-lg disabled:opacity-50">{saving ? 'Saving…' : (form.id ? 'Update' : 'Create')}</button>
</div>
</div>
</div>
)}
<div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">SKU</th><th className="text-left"></th><th className="text-left"></th>
<th className="text-right"></th><th className="text-right"></th><th className="text-right"></th><th className="text-right"></th><th className="text-center"></th><th className="text-center"></th>
<th className="text-right"></th><th className="text-right"></th><th className="text-right"></th><th className="text-right"></th><th className="text-center"></th>
</tr></thead>
<tbody>
{items.map((p: any) => (
@ -191,15 +42,9 @@ export default function Products() {
<td className="text-right text-slate-400">{p.salesCount}</td>
<td className="text-right text-amber-400">{p.ratingAvg?.toFixed(1) || '-'}</td>
<td className="text-center"><button onClick={() => cycle(p.id, p.status)}><StatusBadge status={p.status} /></button></td>
<td className="text-center">
<div className="inline-flex gap-1">
<button onClick={() => openEdit(p.id)} title="Edit" className="text-xs bg-panel border border-edge text-slate-300 px-2 py-1 rounded inline-flex items-center gap-1"><Pencil size={12} /></button>
<button onClick={() => remove(p.id, p.name)} title="Delete" className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded inline-flex items-center gap-1"><Trash2 size={12} /></button>
</div>
</td>
</tr>
))}
{!items.length && <tr><td colSpan={9} className="text-center text-slate-500 py-8"> .</td></tr>}
{!items.length && <tr><td colSpan={8} className="text-center text-slate-500 py-8"> .</td></tr>}
</tbody>
</table>
</div>

View File

@ -84,9 +84,9 @@ export default function Stores() {
<input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} placeholder="Phone" className={fld} />
</div>
<div className="grid grid-cols-4 gap-3">
<input value={form.openTime} onChange={e => setForm({ ...form, openTime: e.target.value })} placeholder="Open (09:00)" className={fld} />
<input value={form.closeTime} onChange={e => setForm({ ...form, closeTime: e.target.value })} placeholder="Close (18:00)" className={fld} />
<input value={form.sameDayCutoff} onChange={e => setForm({ ...form, sameDayCutoff: e.target.value })} placeholder="Same-Day Cutoff" className={fld} />
<input type="time" value={form.openTime} onChange={e => setForm({ ...form, openTime: e.target.value })} placeholder="Open (09:00)" className={fld} />
<input type="time" value={form.closeTime} onChange={e => setForm({ ...form, closeTime: e.target.value })} placeholder="Close (18:00)" className={fld} />
<input type="time" value={form.sameDayCutoff} onChange={e => setForm({ ...form, sameDayCutoff: e.target.value })} placeholder="Same-Day Cutoff" className={fld} />
<input value={form.timezone} onChange={e => setForm({ ...form, timezone: e.target.value })} placeholder="Timezone" className={fld} />
</div>
<div className="grid grid-cols-2 gap-3">

View File

@ -1,25 +1,20 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Repeat, Pause, Play, Ban } from 'lucide-react'
import { getAdminSubscriptions, setAdminSubscriptionStatus } from '../api/client'
import { useQuery } from '@tanstack/react-query'
import { Repeat } from 'lucide-react'
import { getAdminSubscriptions } from '../api/client'
import StatusBadge from '../components/StatusBadge'
import { money } from '../store/shop'
export default function Subscriptions() {
const qc = useQueryClient()
const { data: subs } = useQuery({ queryKey: ['admin-subs'], queryFn: getAdminSubscriptions })
const list = subs || []
const refresh = () => qc.invalidateQueries({ queryKey: ['admin-subs'] })
const change = async (id: number, status: string) => { await setAdminSubscriptionStatus(id, status).catch(() => {}); refresh() }
const cancel = async (id: number) => { if (window.confirm('Cancel this subscription? It cannot be resumed.')) await change(id, 'CANCELLED') }
return (
<div>
<div className="flex items-center gap-2 mb-5"><Repeat className="text-brand" size={22} /><h1 className="text-xl font-bold">Subscriptions</h1></div>
<div className="bg-card border border-edge rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
<th className="text-left p-3">Customer</th><th className="text-left">Product</th><th className="text-left">Frequency</th><th className="text-left">Next Delivery</th><th className="text-right">Amount</th><th className="text-center">Status</th><th className="text-center">Actions</th>
<th className="text-left p-3">Customer</th><th className="text-left">Product</th><th className="text-left">Frequency</th><th className="text-left">Next Delivery</th><th className="text-right">Amount</th><th className="text-center">Status</th>
</tr></thead>
<tbody>
{list.map((s: any) => (
@ -30,23 +25,9 @@ export default function Subscriptions() {
<td className="text-slate-400">{s.nextDeliveryDate || '-'}</td>
<td className="text-right">{money(s.price)}</td>
<td className="text-center"><StatusBadge status={s.status} /></td>
<td className="text-center">
<div className="inline-flex gap-1">
{s.status === 'ACTIVE' && (
<button onClick={() => change(s.id, 'PAUSED')} className="text-xs bg-amber-500/15 text-amber-400 px-2 py-1 rounded inline-flex items-center gap-1"><Pause size={12} /> Pause</button>
)}
{s.status === 'PAUSED' && (
<button onClick={() => change(s.id, 'ACTIVE')} className="text-xs bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded inline-flex items-center gap-1"><Play size={12} /> Resume</button>
)}
{s.status !== 'CANCELLED' && (
<button onClick={() => cancel(s.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded inline-flex items-center gap-1"><Ban size={12} /> Cancel</button>
)}
{s.status === 'CANCELLED' && <span className="text-xs text-slate-500"></span>}
</div>
</td>
</tr>
))}
{!list.length && <tr><td colSpan={7} className="text-center text-slate-500 py-8">No subscriptions found.</td></tr>}
{!list.length && <tr><td colSpan={6} className="text-center text-slate-500 py-8">No subscriptions found.</td></tr>}
</tbody>
</table>
</div>

View File

@ -1,7 +1,7 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { UserCog, Plus } from 'lucide-react'
import { getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser, adminOtpReset } from '../api/client'
import { getUsers, createUser, updateUserRole, updateUserActive, resetPassword, deleteUser } from '../api/client'
const ROLES = ['USER', 'MANAGER', 'ADMIN']
@ -17,8 +17,6 @@ export default function UserManagement() {
const toggleActive = async (id: number, active: boolean) => { await updateUserActive(id, !active).catch(() => {}); refresh() }
const reset = async (id: number) => { const pw = prompt('New password'); if (pw) { await resetPassword(id, pw).catch(() => {}); alert('Password updated') } }
const del = async (id: number) => { if (confirm('Are you sure you want to delete?')) { await deleteUser(id).catch(() => {}); refresh() } }
// OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
const otpReset = async (u: any) => { if (confirm(`'${u.username}' 사용자의 OTP를 초기화하시겠습니까?\n초기화하면 다음 로그인 시 재등록해야 합니다.`)) { await adminOtpReset(u.id).catch(() => {}); alert('OTP를 초기화했습니다.'); refresh() } }
return (
<div>
@ -55,7 +53,6 @@ export default function UserManagement() {
<td className="text-center">
<div className="inline-flex gap-1">
<button onClick={() => reset(u.id)} className="text-xs bg-panel border border-edge px-2 py-1 rounded text-slate-300">Password</button>
<button onClick={() => otpReset(u)} className="text-xs bg-panel border border-edge px-2 py-1 rounded text-slate-300" title="OTP 초기화">OTP Reset</button>
<button onClick={() => del(u.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">Delete</button>
</div>
</td>

View File

@ -36,14 +36,6 @@ export const register = (username: string, password: string, displayName: string
api.post('/api/mall/auth/register', { username, password, displayName })
export const getMe = () => u(api.get('/api/mall/auth/me'))
/* : OTP 2 (//) + (, access )
* 불변: otpSetup (secret/qrImage/otpAuthUri) / . */
export const otpSetup = () => u<{ secret: string; otpAuthUri: string; qrImage: string }>(api.post('/api/mall/auth/otp/setup', {}))
export const otpConfirm = (code: string) => u(api.post('/api/mall/auth/otp/confirm', { code }))
export const otpDisable = () => u(api.post('/api/mall/auth/otp/disable', {}))
export const changePassword = (currentPassword: string, newPassword: string) =>
u(api.post('/api/mall/auth/change-password', { currentPassword, newPassword }))
/* ───────────── 1. 매장 Store ───────────── */
export const getStores = (activeOnly = true) => u(api.get(`/api/mall/store?activeOnly=${activeOnly}`))
export const getStore = (id: number) => u(api.get(`/api/mall/store/${id}`))
@ -120,8 +112,6 @@ export const getSubscriptions = () => u(api.get('/api/mall/subscription'))
export const createSubscription = (d: object) => u(api.post('/api/mall/subscription', d))
export const setSubscriptionStatus = (id: number, status: string) => u(api.put(`/api/mall/subscription/${id}/status`, { status }))
export const getAdminSubscriptions = () => u(api.get('/api/mall/subscription/admin'))
// 관리자(MANAGER+) 구독 상태 변경 — 소유자 제한 없음
export const setAdminSubscriptionStatus = (id: number, status: string) => u(api.put(`/api/mall/subscription/admin/${id}/status`, { status }))
/* ───────────── 10. 매장간 재고 이양 ───────────── */
export const getTransfers = (status = '', storeId = '') => {
@ -149,11 +139,6 @@ export const deleteReview = (id: number) => api.delete(`/api/mall/review/${id}`)
export const getMember = () => u(api.get('/api/mall/member/me'))
export const updateMember = (d: object) => u(api.put('/api/mall/member/me', d))
export const getMemberInsight = () => u(api.get('/api/mall/member/me/insight'))
// 관리자 회원 목록/검색 (PII 마스킹 응답). items/total 래핑.
export const getAdminMembers = (keyword = '', tier = '', limit = 100) => {
const p = new URLSearchParams(); if (keyword) p.set('keyword', keyword); if (tier) p.set('tier', tier); p.set('limit', String(limit))
return u(api.get(`/api/mall/member/admin?${p}`))
}
/* ───────────── 14. CS 문의 ───────────── */
export const getMyCs = () => u(api.get('/api/mall/cs'))
@ -204,8 +189,6 @@ export const updateUserRole = (id: number, role: string) => u(api.put(`/api/admi
export const updateUserActive = (id: number, active: boolean) => u(api.put(`/api/admin/users/${id}/active`, { active }))
export const resetPassword = (id: number, password: string) => u(api.put(`/api/admin/users/${id}/password`, { password }))
export const deleteUser = (id: number) => api.delete(`/api/admin/users/${id}`)
// 관리자 OTP 초기화 — 대상 사용자 OTP 해제(다음 로그인 시 재등록). 시크릿 미조회.
export const adminOtpReset = (id: number) => u(api.post(`/api/admin/users/${id}/otp-reset`, {}))
export const getAuditLogs = (action = '', actor = '', limit = 100) => {
const p = new URLSearchParams(); if (action) p.set('action', action); if (actor) p.set('actor', actor); p.set('limit', String(limit))
return u(api.get(`/api/admin/audit?${p}`))
@ -251,20 +234,3 @@ export const deleteEvent = (id: number) => api.delete(`/api/mall/event/${id}`)
export const getEventPerformance = (id: number) => u(api.get(`/api/mall/event/${id}/performance`))
export const aiEventCopy = (eventType: string, theme: string, tone: string) =>
u(api.post('/api/mall/event/ai/copy', { eventType, theme, tone }))
/* ───────────── 23. 최신 AI 기법(중앙 guardia-rag) ───────────── */
export interface RagToggles {
ragEnabled: boolean; ragAvailable: boolean; retrievalMode: string
rerank: boolean; graphrag: boolean; toolUse: boolean; structured: boolean; stream: boolean
topK: number; agentMaxSteps: number; faithfulnessThreshold: number; temperature: number; generationModel: string
}
export const getRagToggles = () => u<RagToggles>(api.get('/api/mall/rag/toggles'))
export const updateRagToggle = (key: string, value: string | number | boolean) =>
u<RagToggles>(api.put(`/api/mall/rag/toggles/${key}`, { value }))
// 추천·자연어 검색(/answer hybrid + /structured, 근거+보류) — 인증 사용자
export const ragRecommend = (req: object) => u(api.post('/api/mall/rag/recommend', req))
// WISE AI 일반 지식 질의(Q&A) — /rag/answer 근거·인용·보류. 인증 사용자
export const ragAsk = (query: string) => u(api.post('/api/mall/rag/ask', { query }))
// 수요예측·재고이양 추천(/agent tool-use, 승인 게이트) — MANAGER+
export const ragDemandPlan = (req: object) => u(api.post('/api/mall/rag/demand-plan', req))
export const ragFeedback = (req: object) => u(api.post('/api/mall/rag/feedback', req))

View File

@ -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 }) {
const [title, setTitle] = useState('')
const [startDt, setStartDt] = useState(`${date}T09:00:00`)
const [endDt, setEndDt] = useState(`${date}T18:00:00`)
const [startDt, setStartDt] = useState(`${date}T09:00`)
const [endDt, setEndDt] = useState(`${date}T18:00`)
const [content, setContent] = useState('')
const [err, setErr] = useState('')
const save = async () => {
setErr('')
try {
await createSchedule({ scheType: type, title, startDt, endDt, content, attachmentIds: [] })
await createSchedule({ scheType: type, title, startDt: toApiDt(startDt), endDt: toApiDt(endDt), content, attachmentIds: [] })
onSaved()
} 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}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<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 value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
<FormField label="시작"><Input type="datetime-local" value={startDt} onChange={setStartDt} 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>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>