feat(ux): 날짜 입력 전면 캘린더 전환 — 웹 datetime-local/date/time + 모바일 순수JS CalendarPicker [auto-sync]
This commit is contained in:
parent
6059494b5d
commit
870541448a
@ -27,14 +27,10 @@
|
|||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
|
<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.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>
|
<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-api</artifactId><version>${jjwt.version}</version></dependency>
|
||||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||||
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
|
||||||
<dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>${springdoc.version}</version></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.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-webflux</artifactId></dependency>
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
|
||||||
|
|||||||
@ -56,12 +56,6 @@ public class AdminController {
|
|||||||
return ApiResponse.ok(userService.resetPassword(id, req.password()));
|
return ApiResponse.ok(userService.resetPassword(id, req.password()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 관리자 OTP 초기화(사용자 관리 화면 버튼) — 대상 OTP 시크릿 NULL → 다음 로그인 시 QR 재등록. */
|
|
||||||
@PostMapping("/users/{id}/otp-reset")
|
|
||||||
public ApiResponse<UserDto> otpReset(@PathVariable Long id) {
|
|
||||||
return ApiResponse.ok(userService.otpReset(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/users/{id}")
|
@DeleteMapping("/users/{id}")
|
||||||
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
|
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
|
||||||
String currentUsername = auth != null ? auth.getName() : null;
|
String currentUsername = auth != null ? auth.getName() : null;
|
||||||
|
|||||||
@ -83,14 +83,6 @@ public class AdminUserService {
|
|||||||
return UserDto.from(user);
|
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) {
|
public void delete(Long id, String currentUsername) {
|
||||||
MallUser user = require(id);
|
MallUser user = require(id);
|
||||||
if (user.getUsername().equals(currentUsername)) {
|
if (user.getUsername().equals(currentUsername)) {
|
||||||
|
|||||||
@ -22,9 +22,6 @@ public interface AdminUserMapper {
|
|||||||
|
|
||||||
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
|
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
|
||||||
|
|
||||||
/** 관리자 OTP 초기화: 시크릿 폐기 + otp_enabled=false → 다음 로그인 시 QR 재등록 유도. */
|
|
||||||
int clearOtp(@Param("id") Long id);
|
|
||||||
|
|
||||||
int deleteById(@Param("id") Long id);
|
int deleteById(@Param("id") Long id);
|
||||||
|
|
||||||
int countAdmins();
|
int countAdmins();
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package com.zioinfo.mall.ai;
|
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.inventory.mapper.StoreInventoryMapper;
|
||||||
import com.zioinfo.mall.product.MallProduct;
|
import com.zioinfo.mall.product.MallProduct;
|
||||||
import com.zioinfo.mall.product.mapper.ProductMapper;
|
import com.zioinfo.mall.product.mapper.ProductMapper;
|
||||||
@ -24,20 +22,11 @@ import java.util.*;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MallAiService {
|
public class MallAiService {
|
||||||
|
|
||||||
private final AiTextRouter aiRouter; // provider 라우팅(Claude↔Ollama) + infer 로그. 실패 시 아래 Java 폴백.
|
private final OllamaClient ollama;
|
||||||
private final ProductMapper productMapper;
|
private final ProductMapper productMapper;
|
||||||
private final ReviewMapper reviewMapper;
|
private final ReviewMapper reviewMapper;
|
||||||
private final StoreInventoryMapper storeInventoryMapper;
|
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 실패 시 인기/평점 폴백. */
|
/** 1. 상품 추천 — 행사/키워드 기반. AI 실패 시 인기/평점 폴백. */
|
||||||
public List<MallProduct> recommend(String occasion, String keyword, int limit) {
|
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);
|
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 + "]. "
|
String prompt = "You are a florist recommender. From this catalog: [" + names + "]. "
|
||||||
+ "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion)
|
+ "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion)
|
||||||
+ "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated.";
|
+ "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated.";
|
||||||
String ai = aiGenerate(prompt);
|
String ai = ollama.generate(prompt);
|
||||||
if (ai != null && !ai.isBlank()) {
|
if (ai != null && !ai.isBlank()) {
|
||||||
List<MallProduct> ordered = reorderByAi(pool, ai);
|
List<MallProduct> ordered = reorderByAi(pool, ai);
|
||||||
if (!ordered.isEmpty()) return ordered.subList(0, Math.min(limit, ordered.size()));
|
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 prompt = "Summarize these flower bouquet reviews in 2 concise sentences (pros/cons):\n"
|
||||||
+ String.join("\n", contents);
|
+ String.join("\n", contents);
|
||||||
String ai = aiGenerate(prompt);
|
String ai = ollama.generate(prompt);
|
||||||
if (ai != null && !ai.isBlank()) {
|
if (ai != null && !ai.isBlank()) {
|
||||||
out.put("summary", ai);
|
out.put("summary", ai);
|
||||||
out.put("source", "ollama");
|
out.put("source", "ollama");
|
||||||
@ -100,7 +89,7 @@ public class MallAiService {
|
|||||||
public String csAutoReply(String subject, String content) {
|
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"
|
String prompt = "You are a polite flower-shop customer support agent. Write a short helpful reply (<=4 sentences) to:\n"
|
||||||
+ "Subject: " + subject + "\nMessage: " + content;
|
+ "Subject: " + subject + "\nMessage: " + content;
|
||||||
String ai = aiGenerate(prompt);
|
String ai = ollama.generate(prompt);
|
||||||
if (ai != null && !ai.isBlank()) return ai;
|
if (ai != null && !ai.isBlank()) return ai;
|
||||||
return "Thank you for reaching out about \"" + subject + "\". We're sorry for any inconvenience. "
|
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. "
|
+ "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 prompt = "Compose a creative 'Daily Standard' bouquet name and 1-line description using surplus flowers: ["
|
||||||
+ String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION.";
|
+ String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION.";
|
||||||
String ai = aiGenerate(prompt);
|
String ai = ollama.generate(prompt);
|
||||||
if (ai != null && !ai.isBlank()) {
|
if (ai != null && !ai.isBlank()) {
|
||||||
out.put("bouquet", ai);
|
out.put("bouquet", ai);
|
||||||
out.put("source", "ollama");
|
out.put("source", "ollama");
|
||||||
@ -176,7 +165,7 @@ public class MallAiService {
|
|||||||
String prompt = "Write 3 short flower-card messages for occasion='" + occasion
|
String prompt = "Write 3 short flower-card messages for occasion='" + occasion
|
||||||
+ "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient)
|
+ "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient)
|
||||||
+ "'. One per line, no numbering.";
|
+ "'. One per line, no numbering.";
|
||||||
String ai = aiGenerate(prompt);
|
String ai = ollama.generate(prompt);
|
||||||
if (ai != null && !ai.isBlank()) {
|
if (ai != null && !ai.isBlank()) {
|
||||||
List<String> lines = new ArrayList<>();
|
List<String> lines = new ArrayList<>();
|
||||||
for (String l : ai.split("\n")) {
|
for (String l : ai.split("\n")) {
|
||||||
|
|||||||
@ -30,33 +30,20 @@ public class OllamaClient {
|
|||||||
this.model = model;
|
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")
|
@SuppressWarnings("unchecked")
|
||||||
public String generateText(String prompt, String reqModel) {
|
public String generate(String prompt) {
|
||||||
if (prompt == null || prompt.isBlank()) return "";
|
|
||||||
String useModel = (reqModel == null || reqModel.isBlank()) ? model : reqModel.trim();
|
|
||||||
try {
|
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()
|
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
|
||||||
.post().uri("/api/generate").bodyValue(body)
|
.post().uri("/api/generate").bodyValue(body)
|
||||||
.retrieve().bodyToMono(Map.class)
|
.retrieve().bodyToMono(Map.class)
|
||||||
.timeout(Duration.ofSeconds(120))
|
.timeout(Duration.ofSeconds(30))
|
||||||
.map(m -> (Map<String, Object>) m).block();
|
.map(m -> (Map<String, Object>) m).block();
|
||||||
if (res == null) return "";
|
if (res == null) return "";
|
||||||
Object r = res.get("response");
|
Object r = res.get("response");
|
||||||
return r == null ? "" : String.valueOf(r).trim();
|
return r == null ? "" : String.valueOf(r).trim();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getClass().getSimpleName());
|
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage());
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,56 +1,28 @@
|
|||||||
package com.zioinfo.mall.auth;
|
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.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 lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
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
|
@RestController
|
||||||
@RequestMapping("/api/mall/auth")
|
@RequestMapping("/api/mall/auth")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final TwoFactorService twoFactorService;
|
|
||||||
private final OtpAuthService otpAuthService;
|
|
||||||
private final JwtUtil jwtUtil;
|
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
|
||||||
return ApiResponse.ok(authService.login(req.username(), req.password()));
|
String token = authService.login(req.username(), req.password());
|
||||||
|
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ApiResponse<Map<String, String>> register(@RequestBody RegisterRequest req) {
|
public ApiResponse<Map<String, String>> register(@RequestBody RegisterRequest req) {
|
||||||
return ApiResponse.ok(authService.register(req.username(), req.password(), req.displayName()));
|
String token = authService.register(req.username(), req.password(), req.displayName());
|
||||||
}
|
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
|
||||||
|
|
||||||
/** 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()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@ -59,51 +31,6 @@ public class AuthController {
|
|||||||
return ApiResponse.ok(authService.me(token));
|
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 LoginRequest(String username, String password) {}
|
||||||
record RegisterRequest(String username, String password, String displayName) {}
|
record RegisterRequest(String username, String password, String displayName) {}
|
||||||
record VerifyRequest(String verifyToken, String code) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,121 +1,33 @@
|
|||||||
package com.zioinfo.mall.auth;
|
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.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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.util.Map;
|
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
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
|
||||||
/** 임시 비밀번호 문자셋(혼동 문자 0/O/1/l/I 제외). */
|
|
||||||
private static final String TMP_PW_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789@#$%";
|
|
||||||
|
|
||||||
private final UserMapper userMapper;
|
private final UserMapper userMapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
private final TwoFactorService twoFactorService;
|
|
||||||
private final OtpAuthService otpAuthService;
|
|
||||||
private final MailSender mailSender;
|
|
||||||
private final AuditService auditService;
|
|
||||||
|
|
||||||
/** 운영(2FA 대상) 역할 여부 — 고객(USER)은 제외. */
|
public String login(String username, String password) {
|
||||||
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) {
|
|
||||||
MallUser user = userMapper.findByUsername(username);
|
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()) {
|
if (user == null || !user.isActive()) {
|
||||||
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
|
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())) {
|
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: 비밀번호 불일치");
|
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
|
||||||
}
|
}
|
||||||
|
return jwtUtil.generate(username, user.getRole());
|
||||||
// 비밀번호 검증 통과 — 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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 고객 셀프 회원가입 — 항상 USER 역할로 생성(2FA 미적용 대상). */
|
/** 고객 셀프 회원가입 — 항상 USER 역할로 생성. */
|
||||||
public Map<String, String> register(String username, String password, String displayName) {
|
public String register(String username, String password, String displayName) {
|
||||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||||
throw new IllegalArgumentException("ERR-AUTH-400: username/password 필수");
|
throw new IllegalArgumentException("ERR-AUTH-400: username/password 필수");
|
||||||
}
|
}
|
||||||
@ -129,8 +41,7 @@ public class AuthService {
|
|||||||
user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
|
user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
|
||||||
user.setActive(true);
|
user.setActive(true);
|
||||||
userMapper.insert(user);
|
userMapper.insert(user);
|
||||||
String token = jwtUtil.generate(username, "USER");
|
return jwtUtil.generate(username, "USER");
|
||||||
return Map.of("twofa", "false", "token", token, "type", "Bearer");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, String> me(String token) {
|
public Map<String, String> me(String token) {
|
||||||
@ -138,122 +49,4 @@ public class AuthService {
|
|||||||
String role = jwtUtil.getRole(token);
|
String role = jwtUtil.getRole(token);
|
||||||
return Map.of("username", username, "role", role);
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,10 +26,7 @@ public class JwtFilter extends OncePerRequestFilter {
|
|||||||
String header = req.getHeader("Authorization");
|
String header = req.getHeader("Authorization");
|
||||||
if (header != null && header.startsWith("Bearer ")) {
|
if (header != null && header.startsWith("Bearer ")) {
|
||||||
String token = header.substring(7);
|
String token = header.substring(7);
|
||||||
// 보안(UIWS 2FA): purpose=2fa verify-token 은 access 토큰이 아니다.
|
if (jwtUtil.isValid(token)) {
|
||||||
// 동일 서명키라 isValid()는 통과하므로 차단하지 않으면 2차 인증 전 보호 API 접근(2FA 우회)이 가능.
|
|
||||||
// → verify-token 은 인증 컨텍스트를 세우지 않고 무시한다(/api/mall/auth/verify 에서만 사용).
|
|
||||||
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
|
|
||||||
String username = jwtUtil.getUsername(token);
|
String username = jwtUtil.getUsername(token);
|
||||||
String role = jwtUtil.getRole(token);
|
String role = jwtUtil.getRole(token);
|
||||||
var auth = new UsernamePasswordAuthenticationToken(
|
var auth = new UsernamePasswordAuthenticationToken(
|
||||||
|
|||||||
@ -34,47 +34,6 @@ public class JwtUtil {
|
|||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* UIWS 2FA 이식: 1차 로그인 통과 후 발급하는 단기 verify-token.
|
|
||||||
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가).
|
|
||||||
*/
|
|
||||||
public String generateVerifyToken(String username, long validitySeconds) {
|
|
||||||
return Jwts.builder()
|
|
||||||
.subject(username)
|
|
||||||
.claim("purpose", "2fa")
|
|
||||||
.issuedAt(new Date())
|
|
||||||
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
|
|
||||||
.signWith(key())
|
|
||||||
.compact();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
|
|
||||||
public String parseVerifyTokenUsername(String token) {
|
|
||||||
try {
|
|
||||||
Claims c = parse(token);
|
|
||||||
if (!"2fa".equals(c.get("purpose", String.class))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return c.getSubject();
|
|
||||||
} catch (JwtException | IllegalArgumentException e) {
|
|
||||||
log.debug("verify-token 검증 실패: {}", e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 보안: 토큰이 2FA verify-token(purpose=2fa)인지 판별.
|
|
||||||
* JwtFilter 가 access 토큰만 인증 컨텍스트로 인정하도록 verify-token 을 걸러내는 데 사용.
|
|
||||||
* 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) {
|
public Claims parse(String token) {
|
||||||
return Jwts.parser().verifyWith(key()).build()
|
return Jwts.parser().verifyWith(key()).build()
|
||||||
.parseSignedClaims(token).getPayload();
|
.parseSignedClaims(token).getPayload();
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package com.zioinfo.mall.auth;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/** 계정 (mall_account). 역할: ADMIN/MANAGER(운영) · USER(고객). */
|
/** 계정 (mall_account). 역할: ADMIN/MANAGER/USER(고객). */
|
||||||
@Data
|
@Data
|
||||||
public class MallUser {
|
public class MallUser {
|
||||||
private Long id;
|
private Long id;
|
||||||
@ -13,28 +13,4 @@ public class MallUser {
|
|||||||
private String displayName;
|
private String displayName;
|
||||||
private boolean active;
|
private boolean active;
|
||||||
private LocalDateTime createdAt;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,7 @@ package com.zioinfo.mall.auth.mapper;
|
|||||||
import com.zioinfo.mall.auth.MallUser;
|
import com.zioinfo.mall.auth.MallUser;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Update;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 계정 매퍼. findByUsername/insert/countByUsername 는 UserMapper.xml 에 정의(2FA 컬럼 포함 resultMap).
|
|
||||||
* UIWS 2FA 이식 UPDATE 5종은 어노테이션으로 추가 — XML 중복 정의 없음(빈 등록 충돌 회피).
|
|
||||||
*/
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface UserMapper {
|
public interface UserMapper {
|
||||||
|
|
||||||
@ -19,81 +12,4 @@ public interface UserMapper {
|
|||||||
int insert(MallUser user);
|
int insert(MallUser user);
|
||||||
|
|
||||||
int countByUsername(@Param("username") String username);
|
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -71,10 +71,6 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/admin/audit").hasAnyRole("ADMIN", "MANAGER")
|
.requestMatchers("/api/admin/audit").hasAnyRole("ADMIN", "MANAGER")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("ADMIN", "MANAGER")
|
.requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("ADMIN", "MANAGER")
|
||||||
.requestMatchers("/api/admin/settings/**").hasRole("ADMIN")
|
.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 이상
|
// 운영 분석 — MANAGER 이상
|
||||||
.requestMatchers("/api/mall/analytics/**").hasAnyRole("ADMIN", "MANAGER")
|
.requestMatchers("/api/mall/analytics/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
.requestMatchers("/api/mall/settlement/**").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")
|
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
.requestMatchers(HttpMethod.DELETE, "/api/mall/product/**", "/api/mall/category/**",
|
.requestMatchers(HttpMethod.DELETE, "/api/mall/product/**", "/api/mall/category/**",
|
||||||
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
|
"/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER")
|
||||||
// 최신 AI 기법(중앙 guardia-rag) — 운영 의사결정·토글 변경은 MANAGER+ (추천/피드백/토글조회는 인증 사용자)
|
// 그 외 mall API (장바구니·주문·결제·배송·찜·CS·회원·AI) — 인증 사용자
|
||||||
.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 추천/피드백/토글조회) — 인증 사용자
|
|
||||||
.requestMatchers("/api/mall/**").authenticated()
|
.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 노출 방지)
|
// 나머지 모든 API/WS/Actuator는 인증 (아래 SPA permit 보다 먼저 — API 노출 방지)
|
||||||
.requestMatchers("/api/**", "/ws/**", "/actuator/**").authenticated()
|
.requestMatchers("/api/**", "/ws/**", "/actuator/**").authenticated()
|
||||||
// 스토어프론트 SPA 딥링크(/app·/cart·/events·/category·/product·/checkout·/mypage·/orders·/search 등)
|
// 스토어프론트 SPA 딥링크(/app·/cart·/events·/category·/product·/checkout·/mypage·/orders·/search 등)
|
||||||
|
|||||||
@ -5,12 +5,10 @@ import com.zioinfo.mall.integration.CrmClient;
|
|||||||
import com.zioinfo.mall.integration.ItsmSecuritySanitizer;
|
import com.zioinfo.mall.integration.ItsmSecuritySanitizer;
|
||||||
import com.zioinfo.mall.member.mapper.MemberMapper;
|
import com.zioinfo.mall.member.mapper.MemberMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/** 회원 API — /api/mall/member. 본인 프로필 + CRM 인사이트 연계(새니타이즈). */
|
/** 회원 API — /api/mall/member. 본인 프로필 + CRM 인사이트 연계(새니타이즈). */
|
||||||
@ -34,27 +32,6 @@ public class MemberController {
|
|||||||
return ApiResponse.ok(mapper.findByUsername(auth.getName()));
|
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로 정제. */
|
/** CRM 고객 인사이트(구매 성향) — 응답은 ItsmSecuritySanitizer로 정제. */
|
||||||
@GetMapping("/me/insight")
|
@GetMapping("/me/insight")
|
||||||
public ApiResponse<Map<String, Object>> insight(Authentication auth) {
|
public ApiResponse<Map<String, Object>> insight(Authentication auth) {
|
||||||
|
|||||||
@ -1,21 +1,11 @@
|
|||||||
package com.zioinfo.mall.member.mapper;
|
package com.zioinfo.mall.member.mapper;
|
||||||
|
|
||||||
import com.zioinfo.mall.member.MallMember;
|
import com.zioinfo.mall.member.MallMember;
|
||||||
import com.zioinfo.mall.member.MallMemberSummary;
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface MemberMapper {
|
public interface MemberMapper {
|
||||||
MallMember findByUsername(@Param("username") String username);
|
MallMember findByUsername(@Param("username") String username);
|
||||||
int upsert(MallMember m);
|
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,33 +47,11 @@ public class SubscriptionController {
|
|||||||
if (s == null || !s.getOwner().equals(auth.getName())) {
|
if (s == null || !s.getOwner().equals(auth.getName())) {
|
||||||
throw new RuntimeException("ERR-SUB-403: 본인 구독만 변경할 수 있습니다");
|
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));
|
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) {
|
private LocalDate nextDate(String freq) {
|
||||||
LocalDate base = LocalDate.now();
|
LocalDate base = LocalDate.now();
|
||||||
if ("MONTHLY".equalsIgnoreCase(freq)) return base.plusMonths(1);
|
if ("MONTHLY".equalsIgnoreCase(freq)) return base.plusMonths(1);
|
||||||
|
|||||||
@ -8,20 +8,12 @@ spring:
|
|||||||
username: ${DB_USER:mall_user}
|
username: ${DB_USER:mall_user}
|
||||||
password: ${DB_PASS:mall_pass2026}
|
password: ${DB_PASS:mall_pass2026}
|
||||||
driver-class-name: org.postgresql.Driver
|
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:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 20MB
|
max-file-size: 20MB
|
||||||
max-request-size: 20MB
|
max-request-size: 20MB
|
||||||
mybatis:
|
mybatis:
|
||||||
mapper-locations: classpath:mapper/**/*.xml # ** : 하위 mapper/uiws/*.xml(UIWS 이식) 포함
|
mapper-locations: classpath:mapper/*.xml
|
||||||
configuration:
|
configuration:
|
||||||
map-underscore-to-camel-case: true
|
map-underscore-to-camel-case: true
|
||||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||||
@ -42,34 +34,13 @@ mall:
|
|||||||
provider: ${MALL_SMS_PROVIDER:mock} # mock | twilio
|
provider: ${MALL_SMS_PROVIDER:mock} # mock | twilio
|
||||||
email:
|
email:
|
||||||
provider: ${MALL_EMAIL_PROVIDER:mock} # mock | sendgrid
|
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:
|
guardia:
|
||||||
itsm-url: ${ITSM_URL:http://localhost:9001}
|
itsm-url: ${ITSM_URL:http://localhost:9001}
|
||||||
erp-url: ${ERP_URL:http://localhost:8003}
|
erp-url: ${ERP_URL:http://localhost:8003}
|
||||||
crm-url: ${CRM_URL:http://localhost:8004}
|
crm-url: ${CRM_URL:http://localhost:8004}
|
||||||
ocr-url: ${OCR_URL:http://localhost:8005}
|
ocr-url: ${OCR_URL:http://localhost:8005}
|
||||||
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
ollama-url: ${OLLAMA_URL:http://localhost:11434}
|
||||||
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b}
|
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
|
||||||
# 로컬 임베디드 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
|
|
||||||
crypto:
|
crypto:
|
||||||
secret: ${CRYPTO_SECRET:guardia-mall-aes-256-gcm-master-key-2026-zioinfo}
|
secret: ${CRYPTO_SECRET:guardia-mall-aes-256-gcm-master-key-2026-zioinfo}
|
||||||
jwt:
|
jwt:
|
||||||
|
|||||||
@ -47,20 +47,7 @@ INSERT INTO mall_setting (key, value) VALUES
|
|||||||
('hours_saturday','Sat 9:00 AM - 4:00 PM'),
|
('hours_saturday','Sat 9:00 AM - 4:00 PM'),
|
||||||
('hours_sunday','Sun 9:00 AM - 12:00 PM'),
|
('hours_sunday','Sun 9:00 AM - 12:00 PM'),
|
||||||
('payment_provider','mock'),('tax_provider','mock'),('address_provider','mock'),
|
('payment_provider','mock'),('tax_provider','mock'),('address_provider','mock'),
|
||||||
('sms_provider','mock'),('email_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')
|
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS mall_ai_result (
|
CREATE TABLE IF NOT EXISTS mall_ai_result (
|
||||||
|
|||||||
@ -32,7 +32,6 @@
|
|||||||
<update id="updateRole">UPDATE mall_account SET role = #{role} WHERE id = #{id}</update>
|
<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="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="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>
|
<delete id="deleteById">DELETE FROM mall_account WHERE id = #{id}</delete>
|
||||||
|
|
||||||
<select id="countAdmins" resultType="int">
|
<select id="countAdmins" resultType="int">
|
||||||
|
|||||||
@ -11,44 +11,4 @@
|
|||||||
display_name = EXCLUDED.display_name, email = EXCLUDED.email, phone = EXCLUDED.phone,
|
display_name = EXCLUDED.display_name, email = EXCLUDED.email, phone = EXCLUDED.phone,
|
||||||
default_zip = EXCLUDED.default_zip, default_address = EXCLUDED.default_address
|
default_zip = EXCLUDED.default_zip, default_address = EXCLUDED.default_address
|
||||||
</insert>
|
</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) < 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>
|
</mapper>
|
||||||
|
|||||||
@ -11,21 +11,10 @@
|
|||||||
<result property="displayName" column="display_name"/>
|
<result property="displayName" column="display_name"/>
|
||||||
<result property="active" column="is_active"/>
|
<result property="active" column="is_active"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
<!-- UIWS 2FA 이식 컬럼 (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>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findByUsername" resultMap="userMap">
|
<select id="findByUsername" resultMap="userMap">
|
||||||
SELECT id, username, password_hash, role, display_name, is_active, created_at,
|
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
|
FROM mall_account
|
||||||
WHERE username = #{username}
|
WHERE username = #{username}
|
||||||
</select>
|
</select>
|
||||||
@ -40,42 +29,4 @@
|
|||||||
SELECT COUNT(*) FROM mall_account WHERE username = #{username}
|
SELECT COUNT(*) FROM mall_account WHERE username = #{username}
|
||||||
</select>
|
</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>
|
</mapper>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
489
backend/src/main/resources/static/assets/index-CUN395kM.js
Normal file
489
backend/src/main/resources/static/assets/index-CUN395kM.js
Normal file
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 |
@ -4,7 +4,6 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#fdfaf5" />
|
<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" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.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"
|
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"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<script type="module" crossorigin src="/assets/index-Cc_He-EQ.js"></script>
|
<script type="module" crossorigin src="/assets/index-CUN395kM.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DGX5ektI.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BzL8NSpt.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#fdfaf5" />
|
<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" href="/favicon.ico" sizes="any" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.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 |
@ -41,24 +41,6 @@ import UserManagement from './admin/UserManagement'
|
|||||||
import AuditLog from './admin/AuditLog'
|
import AuditLog from './admin/AuditLog'
|
||||||
import Settings from './admin/Settings'
|
import Settings from './admin/Settings'
|
||||||
import AdminApp from './admin/AdminApp'
|
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({
|
const qc = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 } },
|
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 30_000 } },
|
||||||
@ -98,7 +80,6 @@ export default function App() {
|
|||||||
<Route path="/admin" element={<AdminLayout />}>
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
<Route path="mypage" element={<MyPage />} />
|
|
||||||
<Route path="stores" element={<Stores />} />
|
<Route path="stores" element={<Stores />} />
|
||||||
<Route path="products" element={<Products />} />
|
<Route path="products" element={<Products />} />
|
||||||
<Route path="inventory" element={<Inventory />} />
|
<Route path="inventory" element={<Inventory />} />
|
||||||
@ -113,22 +94,7 @@ export default function App() {
|
|||||||
<Route path="users" element={<UserManagement />} />
|
<Route path="users" element={<UserManagement />} />
|
||||||
<Route path="audit" element={<AuditLog />} />
|
<Route path="audit" element={<AuditLog />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<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 />} />
|
<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>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 {
|
import {
|
||||||
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
|
LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone,
|
||||||
Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone,
|
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'
|
} from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getMe } from '../api/client'
|
import { getMe } from '../api/client'
|
||||||
@ -30,28 +28,6 @@ const adminLinks = [
|
|||||||
{ to: '/admin/settings', key: 'settings', icon: Settings, roles: ['ADMIN'] },
|
{ to: '/admin/settings', key: 'settings', icon: Settings, roles: ['ADMIN'] },
|
||||||
{ to: '/admin/app', key: 'appInstall', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] },
|
{ 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 }) =>
|
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'}`
|
`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 visible = links.filter(l => !role || l.roles.includes(role))
|
||||||
const visibleAdmin = adminLinks.filter(l => 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 = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('mall_admin_token'); localStorage.removeItem('mall_role'); localStorage.removeItem('mall_admin_user')
|
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>
|
<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>)}
|
{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>
|
</nav>
|
||||||
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">{t('admin.onPremiseTag')}</div>
|
<div className="p-4 text-[11px] text-slate-500 border-t border-edge">{t('admin.onPremiseTag')}</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -123,7 +84,7 @@ export default function AdminLayout() {
|
|||||||
<div className="text-sm text-slate-400 truncate">{t('admin.header')}</div>
|
<div className="text-sm text-slate-400 truncate">{t('admin.header')}</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<LanguageSwitcher variant="admin" />
|
<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>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -2,269 +2,57 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { login, getMe } from '../api/client'
|
import { login, getMe } from '../api/client'
|
||||||
import { verify2fa, verifyOtp, authSignup, authFindId, authResetPassword } from '../api/uiws'
|
|
||||||
import LanguageSwitcher from '../i18n/LanguageSwitcher'
|
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() {
|
export default function AdminLogin() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [username, setUsername] = useState('admin')
|
const [username, setUsername] = useState('admin')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
// 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 nav = useNavigate()
|
||||||
|
|
||||||
const isOtp = verifyMethod === 'OTP' || verifyMethod === 'OTP_SETUP'
|
|
||||||
const isSetup = verifyMethod === 'OTP_SETUP'
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.add('admin-shell')
|
document.documentElement.classList.add('admin-shell')
|
||||||
return () => document.documentElement.classList.remove('admin-shell')
|
return () => document.documentElement.classList.remove('admin-shell')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/** 토큰 저장 + 역할 확인 후 진입(공통). */
|
const submit = async (e: React.FormEvent) => {
|
||||||
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) => {
|
|
||||||
e.preventDefault(); setErr('')
|
e.preventDefault(); setErr('')
|
||||||
try {
|
try {
|
||||||
const res = await login(username, password)
|
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
|
const token = res.data?.data?.token
|
||||||
if (!token) throw new Error('no token')
|
if (!token) throw new Error('no token')
|
||||||
// 시크릿 잔존 방지
|
// Store the admin token under a key separate from the customer session (mall_token) — used by the interceptor in the /admin area
|
||||||
setQrImage(''); setSecret('')
|
localStorage.setItem('mall_admin_token', token)
|
||||||
await finishLogin(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 {
|
} catch {
|
||||||
setErr('인증 코드가 올바르지 않거나 만료되었습니다. (Invalid or expired code)')
|
setErr(t('admin.login.errFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell min-h-screen flex items-center justify-center bg-ink text-[#e6edf6]">
|
<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>
|
<div className="absolute top-5 right-5"><LanguageSwitcher variant="admin" /></div>
|
||||||
|
<form onSubmit={submit} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
|
||||||
{step === 'login' ? (
|
<div className="flex items-center gap-2 justify-center mb-6">
|
||||||
<form onSubmit={submitLogin} className="w-[360px] bg-panel border border-edge rounded-2xl p-8">
|
<img src="/login.png" alt="GUARDiA" style={{height:36,width:'auto'}} onError={e => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
||||||
<div className="flex items-center gap-2 justify-center mb-6">
|
<span className="text-xl font-bold">{t('admin.login.title')}</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-center text-sm text-slate-400 mb-6">{t('admin.login.subtitle')}</p>
|
||||||
<form onSubmit={submit}>
|
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.username')}</label>
|
||||||
{(kind === 'signup' || kind === 'resetPw') && (
|
<input value={username} onChange={e => setUsername(e.target.value)}
|
||||||
<>
|
className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" />
|
||||||
<label className="block text-xs text-slate-400 mb-1">아이디</label>
|
<label className="block text-xs text-slate-400 mb-1">{t('admin.login.password')}</label>
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} className={field} autoFocus />
|
<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>}
|
||||||
{kind === 'signup' && (
|
<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>
|
||||||
<label className="block text-xs text-slate-400 mb-1">비밀번호 (4자 이상)</label>
|
</form>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,16 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Users, RefreshCw, Search } from 'lucide-react'
|
import { Users, RefreshCw } from 'lucide-react'
|
||||||
import { getLoyaltyByTier, recalcAllLoyalty, getAdminMembers } from '../api/client'
|
import { getLoyaltyByTier, recalcAllLoyalty } from '../api/client'
|
||||||
import Chart from '../components/Chart'
|
import Chart from '../components/Chart'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import { money } from '../store/shop'
|
import { money } from '../store/shop'
|
||||||
|
|
||||||
const TIERS = ['', 'STANDARD', 'BASIC', 'SILVER', 'GOLD', 'VIP']
|
|
||||||
|
|
||||||
export default function Members() {
|
export default function Members() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [kw, setKw] = useState('')
|
|
||||||
const [tier, setTier] = useState('')
|
|
||||||
const { data: byTier } = useQuery({ queryKey: ['by-tier'], queryFn: () => getLoyaltyByTier(30) })
|
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 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 rows = (byTier || []).map((t: any) => ({ name: t.tier, customers: t.customers, sales: t.sales }))
|
||||||
const list = members?.items || []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -37,8 +30,7 @@ export default function Members() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card border border-edge rounded-xl p-5 mb-5">
|
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||||
<h2 className="text-sm font-semibold mb-3">Tier Summary</h2>
|
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
|
<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>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,184 +1,35 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Flower2, Search, Plus, Pencil, Trash2, X } from 'lucide-react'
|
import { Flower2, Search } from 'lucide-react'
|
||||||
import {
|
import { getProducts, setProductStatus } from '../api/client'
|
||||||
getProducts, getProduct, getCategories, createProduct, updateProduct, deleteProduct, setProductStatus,
|
|
||||||
} from '../api/client'
|
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import { money } from '../store/shop'
|
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() {
|
export default function Products() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [kw, setKw] = useState('')
|
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 } = 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 items = data?.items || []
|
||||||
const categories = cats || []
|
|
||||||
|
|
||||||
const refresh = () => qc.invalidateQueries({ queryKey: ['admin-products'] })
|
|
||||||
|
|
||||||
const cycle = async (id: number, status: string) => {
|
const cycle = async (id: number, status: string) => {
|
||||||
const next = status === 'ON_SALE' ? 'HIDDEN' : 'ON_SALE'
|
const next = status === 'ACTIVE' ? 'HIDDEN' : 'ACTIVE'
|
||||||
await setProductStatus(id, next).catch(() => {}); refresh()
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-5">
|
<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"><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">
|
||||||
<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" />
|
||||||
<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" />
|
||||||
<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>
|
</div>
|
||||||
</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">
|
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
|
<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-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>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((p: any) => (
|
{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-slate-400">{p.salesCount}</td>
|
||||||
<td className="text-right text-amber-400">{p.ratingAvg?.toFixed(1) || '-'}</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"><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>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -84,9 +84,9 @@ export default function Stores() {
|
|||||||
<input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} placeholder="Phone" className={fld} />
|
<input value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })} placeholder="Phone" className={fld} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<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 type="time" 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 type="time" 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.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} />
|
<input value={form.timezone} onChange={e => setForm({ ...form, timezone: e.target.value })} placeholder="Timezone" className={fld} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
@ -1,25 +1,20 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Repeat, Pause, Play, Ban } from 'lucide-react'
|
import { Repeat } from 'lucide-react'
|
||||||
import { getAdminSubscriptions, setAdminSubscriptionStatus } from '../api/client'
|
import { getAdminSubscriptions } from '../api/client'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import { money } from '../store/shop'
|
import { money } from '../store/shop'
|
||||||
|
|
||||||
export default function Subscriptions() {
|
export default function Subscriptions() {
|
||||||
const qc = useQueryClient()
|
|
||||||
const { data: subs } = useQuery({ queryKey: ['admin-subs'], queryFn: getAdminSubscriptions })
|
const { data: subs } = useQuery({ queryKey: ['admin-subs'], queryFn: getAdminSubscriptions })
|
||||||
const list = subs || []
|
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 (
|
return (
|
||||||
<div>
|
<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="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">
|
<div className="bg-card border border-edge rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead><tr className="text-slate-400 text-xs border-b border-edge bg-panel">
|
<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>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{list.map((s: any) => (
|
{list.map((s: any) => (
|
||||||
@ -30,23 +25,9 @@ export default function Subscriptions() {
|
|||||||
<td className="text-slate-400">{s.nextDeliveryDate || '-'}</td>
|
<td className="text-slate-400">{s.nextDeliveryDate || '-'}</td>
|
||||||
<td className="text-right">{money(s.price)}</td>
|
<td className="text-right">{money(s.price)}</td>
|
||||||
<td className="text-center"><StatusBadge status={s.status} /></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>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { UserCog, Plus } from 'lucide-react'
|
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']
|
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 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 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() } }
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -55,7 +53,6 @@ export default function UserManagement() {
|
|||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<div className="inline-flex gap-1">
|
<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={() => 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>
|
<button onClick={() => del(u.id)} className="text-xs bg-rose-500/15 text-rose-400 px-2 py-1 rounded">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -36,14 +36,6 @@ export const register = (username: string, password: string, displayName: string
|
|||||||
api.post('/api/mall/auth/register', { username, password, displayName })
|
api.post('/api/mall/auth/register', { username, password, displayName })
|
||||||
export const getMe = () => u(api.get('/api/mall/auth/me'))
|
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 ───────────── */
|
/* ───────────── 1. 매장 Store ───────────── */
|
||||||
export const getStores = (activeOnly = true) => u(api.get(`/api/mall/store?activeOnly=${activeOnly}`))
|
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}`))
|
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 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 setSubscriptionStatus = (id: number, status: string) => u(api.put(`/api/mall/subscription/${id}/status`, { status }))
|
||||||
export const getAdminSubscriptions = () => u(api.get('/api/mall/subscription/admin'))
|
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. 매장간 재고 이양 ───────────── */
|
/* ───────────── 10. 매장간 재고 이양 ───────────── */
|
||||||
export const getTransfers = (status = '', storeId = '') => {
|
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 getMember = () => u(api.get('/api/mall/member/me'))
|
||||||
export const updateMember = (d: object) => u(api.put('/api/mall/member/me', d))
|
export const updateMember = (d: object) => u(api.put('/api/mall/member/me', d))
|
||||||
export const getMemberInsight = () => u(api.get('/api/mall/member/me/insight'))
|
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 문의 ───────────── */
|
/* ───────────── 14. CS 문의 ───────────── */
|
||||||
export const getMyCs = () => u(api.get('/api/mall/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 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 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}`)
|
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) => {
|
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))
|
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}`))
|
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 getEventPerformance = (id: number) => u(api.get(`/api/mall/event/${id}/performance`))
|
||||||
export const aiEventCopy = (eventType: string, theme: string, tone: string) =>
|
export const aiEventCopy = (eventType: string, theme: string, tone: string) =>
|
||||||
u(api.post('/api/mall/event/ai/copy', { eventType, theme, tone }))
|
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))
|
|
||||||
|
|||||||
@ -84,16 +84,22 @@ export default function ScheduleCalendar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// datetime-local(yyyy-MM-ddTHH:mm) → API 계약(yyyy-MM-ddTHH:mm:ss) 보정: 초 없으면 :00 부가
|
||||||
|
function toApiDt(v: string): string {
|
||||||
|
if (!v) return v
|
||||||
|
return v.length === 16 ? `${v}:00` : v
|
||||||
|
}
|
||||||
|
|
||||||
function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: string; onClose: () => void; onSaved: () => void }) {
|
function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: string; onClose: () => void; onSaved: () => void }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [startDt, setStartDt] = useState(`${date}T09:00:00`)
|
const [startDt, setStartDt] = useState(`${date}T09:00`)
|
||||||
const [endDt, setEndDt] = useState(`${date}T18:00:00`)
|
const [endDt, setEndDt] = useState(`${date}T18:00`)
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setErr('')
|
setErr('')
|
||||||
try {
|
try {
|
||||||
await createSchedule({ scheType: type, title, startDt, endDt, content, attachmentIds: [] })
|
await createSchedule({ scheType: type, title, startDt: toApiDt(startDt), endDt: toApiDt(endDt), content, attachmentIds: [] })
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') }
|
} catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') }
|
||||||
}
|
}
|
||||||
@ -101,8 +107,8 @@ function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: st
|
|||||||
<Modal title="일정 등록" onClose={onClose}
|
<Modal title="일정 등록" onClose={onClose}
|
||||||
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
footer={<><Button variant="ghost" onClick={onClose}>취소</Button><Button onClick={save}>저장</Button></>}>
|
||||||
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
|
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
|
||||||
<FormField label="시작(yyyy-MM-ddTHH:mm:ss)"><Input value={startDt} onChange={setStartDt} style={{ width: '100%' }} /></FormField>
|
<FormField label="시작"><Input type="datetime-local" value={startDt} onChange={setStartDt} style={{ width: '100%' }} /></FormField>
|
||||||
<FormField label="종료"><Input value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
|
<FormField label="종료"><Input type="datetime-local" value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
|
||||||
<FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
|
<FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
|
||||||
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user