디자인 수정
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package com.erp.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/**
|
||||
* 비밀번호 해시 검증/생성 유틸.
|
||||
*
|
||||
* <p>운영 환경에 MD5 로 저장된 레거시 해시와 BCrypt 신규 해시가 공존하는 점진 마이그레이션 단계용.
|
||||
* BCrypt 해시는 항상 {@code $2a$/$2b$/$2y$} prefix 로 시작하므로 prefix 로 알고리즘을 식별한다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #matches(String, String)} - 입력 평문과 저장 해시를 비교 (알고리즘 자동 판별)</li>
|
||||
* <li>{@link #encode(String)} - BCrypt 로 신규 해시 생성 (회원가입 / 점진 재해시 용)</li>
|
||||
* <li>{@link #isLegacy(String)} - 저장된 해시가 BCrypt 가 아닌지 (= 재해시 대상인지) 판단</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
public class PasswordHasher {
|
||||
|
||||
private final PasswordEncoder bcryptEncoder;
|
||||
|
||||
@Autowired
|
||||
public PasswordHasher(PasswordEncoder bcryptEncoder) {
|
||||
this.bcryptEncoder = bcryptEncoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 평문 비밀번호가 저장된 해시와 일치하는지.
|
||||
* 저장 해시가 BCrypt 면 BCrypt 로, 아니면 MD5 로 비교.
|
||||
*/
|
||||
public boolean matches(String rawPassword, String storedHash) {
|
||||
if (rawPassword == null || storedHash == null) {
|
||||
return false;
|
||||
}
|
||||
if (isBcrypt(storedHash)) {
|
||||
return bcryptEncoder.matches(rawPassword, storedHash);
|
||||
}
|
||||
return md5(rawPassword).equals(storedHash);
|
||||
}
|
||||
|
||||
/** BCrypt 로 새 해시 생성. 회원가입과 점진 재해시 양쪽에서 사용. */
|
||||
public String encode(String rawPassword) {
|
||||
return bcryptEncoder.encode(rawPassword);
|
||||
}
|
||||
|
||||
/** 저장된 해시가 BCrypt 가 아니면 (즉 레거시 MD5 면) true. 점진 재해시 대상 판별. */
|
||||
public boolean isLegacy(String storedHash) {
|
||||
return storedHash != null && !isBcrypt(storedHash);
|
||||
}
|
||||
|
||||
private boolean isBcrypt(String hash) {
|
||||
return hash.startsWith("$2a$") || hash.startsWith("$2b$") || hash.startsWith("$2y$");
|
||||
}
|
||||
|
||||
private String md5(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format("%032x", new BigInteger(1, digest));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("MD5 해시 생성 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.security;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
@@ -15,6 +16,7 @@ import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@@ -24,6 +26,13 @@ public class SecurityConfig {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
/**
|
||||
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invion.com").
|
||||
* application.yml 또는 환경변수 CORS_ALLOWED_ORIGINS 로 설정.
|
||||
*/
|
||||
@Value("${cors.allowed-origins}")
|
||||
private String corsAllowedOrigins;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
@@ -31,6 +40,10 @@ public class SecurityConfig {
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// ⚠️ 95개 컨트롤러 중 자체 토큰 검증을 하는 건 AuthController 한 곳뿐.
|
||||
// SecurityConfig 단계에서 강제 인증을 켜면 회귀 위험 너무 큼 (Phase 3 별도 트랙).
|
||||
// 그동안은 기존 동작 유지하되, JwtAuthenticationFilter 가 valid 토큰일 때
|
||||
// SecurityContext 를 채워주어 컨트롤러가 사용자 정보 attribute 를 받을 수 있게 함.
|
||||
.requestMatchers("/api/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
@@ -48,7 +61,12 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOriginPatterns(List.of("*"));
|
||||
// 와일드카드 금지 — 환경변수에서 받은 화이트리스트만 허용
|
||||
List<String> origins = Arrays.stream(corsAllowedOrigins.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
config.setAllowedOrigins(origins);
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
@@ -2,17 +2,13 @@ package com.erp.service;
|
||||
|
||||
import com.erp.common.BaseService;
|
||||
import com.erp.security.JwtTokenProvider;
|
||||
import com.erp.security.PasswordHasher;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -26,70 +22,94 @@ public class AuthService extends BaseService {
|
||||
|
||||
private static final String NS_ADMIN = "admin.";
|
||||
|
||||
@Value("${auth.master-password:qlalfqjsgh11}")
|
||||
private String masterPassword;
|
||||
/** 로그인 실패 시 사용자에게 노출되는 통일 메시지 (user enumeration 방지) */
|
||||
private static final String LOGIN_FAIL_MESSAGE = "아이디 또는 비밀번호를 확인해주세요.";
|
||||
|
||||
/** 락아웃 카운팅 윈도우 (분) */
|
||||
private static final int LOCKOUT_WINDOW_MINUTES = 5;
|
||||
|
||||
/** 락아웃 한계 (실패 횟수) */
|
||||
private static final int LOCKOUT_MAX_FAILURES = 5;
|
||||
|
||||
/** 락아웃 발생 시 사용자에게 노출되는 메시지 */
|
||||
private static final String LOCKOUT_MESSAGE =
|
||||
"로그인 실패가 너무 많습니다. " + LOCKOUT_WINDOW_MINUTES + "분 후 다시 시도해주세요.";
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final PasswordHasher passwordHasher;
|
||||
|
||||
/**
|
||||
* 로그인 처리
|
||||
* Node.js AuthService.processLogin() 포팅
|
||||
*
|
||||
* 보안 변경 사항 (2026-04-08):
|
||||
* - 마스터 패스워드 백도어 제거
|
||||
* - BCrypt 점진 재해시 (MD5 검증 성공 시 BCrypt 로 자동 재저장)
|
||||
* - 로그인 락아웃 (최근 N분 내 N회 이상 실패 시 차단)
|
||||
* - User enumeration 방지 (모든 실패 응답 메시지 통일)
|
||||
*/
|
||||
public Map<String, Object> login(Map<String, Object> params) {
|
||||
String userId = (String) params.get("user_id");
|
||||
String password = (String) params.get("password");
|
||||
String remoteAddr = (String) params.getOrDefault("remote_addr", "unknown");
|
||||
|
||||
// 0. 락아웃 체크 — 비번 검증 전에 먼저 차단
|
||||
if (isLockedOut(userId)) {
|
||||
log.warn("로그인 락아웃 차단: {} ({})", userId, remoteAddr);
|
||||
recordLoginAttempt(userId, false, "락아웃 차단", remoteAddr);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("login_failed", true);
|
||||
result.put("error_reason", LOCKOUT_MESSAGE);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 1. 비밀번호 검증
|
||||
boolean loginSuccess = false;
|
||||
String errorReason = null;
|
||||
String internalErrorReason = null;
|
||||
String storedPw = null;
|
||||
|
||||
Map<String, Object> pwRow = sqlSession.selectOne("auth.selectUserPassword", Map.of("user_id", userId));
|
||||
if (pwRow == null) {
|
||||
errorReason = "사용자가 존재하지 않습니다.";
|
||||
internalErrorReason = "사용자가 존재하지 않습니다.";
|
||||
} else {
|
||||
String storedPw = (String) pwRow.get("user_password");
|
||||
if (masterPassword.equals(password)) {
|
||||
storedPw = (String) pwRow.get("user_password");
|
||||
if (passwordHasher.matches(password, storedPw)) {
|
||||
loginSuccess = true;
|
||||
log.debug("마스터 패스워드로 로그인 성공: {}", userId);
|
||||
} else if (storedPw != null && md5(password).equals(storedPw)) {
|
||||
loginSuccess = true;
|
||||
log.debug("MD5 패스워드 일치로 로그인 성공: {}", userId);
|
||||
} else {
|
||||
errorReason = "패스워드가 일치하지 않습니다.";
|
||||
internalErrorReason = "패스워드가 일치하지 않습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 로그인 로그 기록 (실패해도 로그인 프로세스 유지)
|
||||
try {
|
||||
Map<String, Object> logParams = new HashMap<>();
|
||||
logParams.put("system_name", "PMS");
|
||||
logParams.put("user_id", userId);
|
||||
logParams.put("login_result", loginSuccess);
|
||||
logParams.put("error_message", errorReason);
|
||||
logParams.put("remote_addr", remoteAddr);
|
||||
logParams.put("recptn_dt", null);
|
||||
logParams.put("recptn_rslt_dtl", null);
|
||||
logParams.put("recptn_rslt", null);
|
||||
logParams.put("recptn_rslt_cd", null);
|
||||
sqlSession.insert("auth.insertLoginLog", logParams);
|
||||
} catch (Exception e) {
|
||||
log.warn("로그인 로그 기록 실패 (무시): {}", e.getMessage());
|
||||
}
|
||||
recordLoginAttempt(userId, loginSuccess, internalErrorReason, remoteAddr);
|
||||
|
||||
if (!loginSuccess) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("login_failed", true);
|
||||
result.put("error_reason", errorReason);
|
||||
// 사용자에게는 항상 통일 메시지 (user enumeration 방지)
|
||||
result.put("error_reason", LOGIN_FAIL_MESSAGE);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2-1. BCrypt 점진 재해시 — MD5 로 검증 성공한 경우 BCrypt 로 갱신
|
||||
if (passwordHasher.isLegacy(storedPw)) {
|
||||
try {
|
||||
String newHash = passwordHasher.encode(password);
|
||||
Map<String, Object> updateParams = new HashMap<>();
|
||||
updateParams.put("user_id", userId);
|
||||
updateParams.put("user_password", newHash);
|
||||
sqlSession.update("auth.updateUserPassword", updateParams);
|
||||
log.info("비밀번호 BCrypt 재해시 완료: {}", userId);
|
||||
} catch (Exception e) {
|
||||
log.warn("비밀번호 BCrypt 재해시 실패 (로그인은 정상 진행): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 사용자 정보 조회
|
||||
Map<String, Object> userInfoRow = sqlSession.selectOne("auth.selectUserInfo", Map.of("user_id", userId));
|
||||
if (userInfoRow == null) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("login_failed", true);
|
||||
result.put("error_reason", "사용자 정보를 조회할 수 없습니다.");
|
||||
result.put("error_reason", LOGIN_FAIL_MESSAGE);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -244,7 +264,6 @@ public class AuthService extends BaseService {
|
||||
result.put("user_name", getStr(dbUser, "user_name", ""));
|
||||
result.put("dept_name", getStr(dbUser, "dept_name", ""));
|
||||
result.put("company_code", companyCode);
|
||||
result.put("company_code", companyCode);
|
||||
result.put("user_type", userType);
|
||||
result.put("user_type_name", getStr(dbUser, "user_type_name", "일반사용자"));
|
||||
result.put("email", getStr(dbUser, "email", ""));
|
||||
@@ -355,8 +374,8 @@ public class AuthService extends BaseService {
|
||||
throw new IllegalArgumentException("이미 등록된 차량번호입니다.");
|
||||
}
|
||||
|
||||
// 비밀번호 MD5 해싱
|
||||
String hashedPassword = md5((String) params.get("password"));
|
||||
// 신규 회원가입은 BCrypt 로 저장 (점진 마이그레이션 정책에 따라 신규는 BCrypt 강제)
|
||||
String hashedPassword = passwordHasher.encode((String) params.get("password"));
|
||||
|
||||
Map<String, Object> insertParams = new HashMap<>(params);
|
||||
insertParams.put("user_password", hashedPassword);
|
||||
@@ -369,18 +388,48 @@ public class AuthService extends BaseService {
|
||||
|
||||
// ── 내부 유틸 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 락아웃 판단 — 최근 LOCKOUT_WINDOW_MINUTES 분 내 동일 user_id 의 실패 건수가
|
||||
* LOCKOUT_MAX_FAILURES 이상이면 차단.
|
||||
*/
|
||||
private boolean isLockedOut(String userId) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("user_id", userId);
|
||||
p.put("lockout_window_minutes", LOCKOUT_WINDOW_MINUTES);
|
||||
Integer fails = sqlSession.selectOne("auth.countRecentLoginFailures", p);
|
||||
return fails != null && fails >= LOCKOUT_MAX_FAILURES;
|
||||
} catch (Exception e) {
|
||||
// 카운트 쿼리 실패는 인증 자체를 막지 않는다 (가용성 우선)
|
||||
log.warn("락아웃 카운트 조회 실패 (무시): {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 로그인 시도 결과 1건 기록 */
|
||||
private void recordLoginAttempt(String userId, boolean success, String errorMessage, String remoteAddr) {
|
||||
try {
|
||||
Map<String, Object> logParams = new HashMap<>();
|
||||
logParams.put("system_name", "PMS");
|
||||
logParams.put("user_id", userId);
|
||||
logParams.put("login_result", success);
|
||||
logParams.put("error_message", errorMessage);
|
||||
logParams.put("remote_addr", remoteAddr);
|
||||
logParams.put("recptn_dt", null);
|
||||
logParams.put("recptn_rslt_dtl", null);
|
||||
logParams.put("recptn_rslt", null);
|
||||
logParams.put("recptn_rslt_cd", null);
|
||||
sqlSession.insert("auth.insertLoginLog", logParams);
|
||||
} catch (Exception e) {
|
||||
log.warn("로그인 로그 기록 실패 (무시): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String getStr(Map<String, Object> map, String key, String defaultVal) {
|
||||
Object val = map.get(key);
|
||||
return val != null ? val.toString() : defaultVal;
|
||||
}
|
||||
|
||||
private String md5(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||
return String.format("%032x", new BigInteger(1, digest));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("MD5 해시 생성 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,14 @@ mybatis:
|
||||
default-statement-timeout: 30
|
||||
|
||||
jwt:
|
||||
secret: ilshin-plm-super-secret-jwt-key-2024
|
||||
expiration: 86400000
|
||||
# JWT_SECRET 환경변수 필수. 디폴트 없음 — 미지정 시 앱 기동 실패 (의도된 동작)
|
||||
# 새 secret 생성: openssl rand -base64 64 | tr -d '\n='
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
|
||||
cors:
|
||||
# 콤마 구분 문자열. dev 디폴트는 localhost 와 사무실 Tailscale IP
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:9772,http://localhost:9771,http://100.126.230.80:9772}
|
||||
|
||||
file:
|
||||
upload-dir: ./uploads
|
||||
|
||||
@@ -63,6 +63,23 @@
|
||||
WHERE COMPANY_CODE = #{company_code}
|
||||
</select>
|
||||
|
||||
<!-- 비밀번호 해시 업데이트 (BCrypt 점진 재해시 + 회원가입 후 강제 변경 등) -->
|
||||
<update id="updateUserPassword" parameterType="map">
|
||||
UPDATE USER_INFO
|
||||
SET USER_PASSWORD = #{user_password}
|
||||
WHERE USER_ID = #{user_id}
|
||||
</update>
|
||||
|
||||
<!-- 락아웃 판단용: 최근 N분 내 동일 user_id 의 로그인 실패 건수 -->
|
||||
<!-- LOGIN_RESULT 컬럼은 character varying 이고 'true'/'false' 문자열로 저장됨 (boolean 아님) -->
|
||||
<select id="countRecentLoginFailures" parameterType="map" resultType="int">
|
||||
SELECT COUNT(*)
|
||||
FROM LOGIN_ACCESS_LOG
|
||||
WHERE UPPER(USER_ID) = UPPER(#{user_id})
|
||||
AND LOGIN_RESULT = 'false'
|
||||
AND LOG_TIME > NOW() - (#{lockout_window_minutes}::text || ' minutes')::interval
|
||||
</select>
|
||||
|
||||
<!-- 로그인 접근 로그 기록 -->
|
||||
<insert id="insertLoginLog" parameterType="map">
|
||||
INSERT INTO LOGIN_ACCESS_LOG (
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# invyone 도커 dev 환경변수 예제
|
||||
# 실제 사용은 docker/dev/.env 에 복사 후 값 채우기
|
||||
#
|
||||
# JWT_SECRET 은 64자 이상 random base64 권장:
|
||||
# openssl rand -base64 64 | tr -d '\n='
|
||||
|
||||
JWT_SECRET=__GENERATE_WITH_OPENSSL__
|
||||
JWT_EXPIRATION=86400000
|
||||
|
||||
# CORS 화이트리스트 (콤마 구분, 공백 없이)
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:9772
|
||||
@@ -32,8 +32,10 @@ services:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://211.115.91.141:11134/test_dev
|
||||
SPRING_DATASOURCE_USERNAME: postgres
|
||||
SPRING_DATASOURCE_PASSWORD: vexplor0909!!
|
||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||
JWT_EXPIRATION: "86400000"
|
||||
# JWT_SECRET 은 docker/dev/.env 에서 주입 (이 파일은 git 추적, .env 는 gitignored + syncthing 동기화)
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 환경변수 필요. docker/dev/.env 파일 확인}
|
||||
JWT_EXPIRATION: ${JWT_EXPIRATION:-86400000}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:9772,http://100.126.230.80:9772}
|
||||
FILE_UPLOAD_DIR: ./uploads
|
||||
volumes:
|
||||
- ../../backend-spring:/app
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useLogin } from "@/hooks/useLogin";
|
||||
import { animatedThemeChange } from "@/lib/themeTransition";
|
||||
import { User, Lock, Eye, EyeOff, ArrowRight } from "lucide-react";
|
||||
import "./login.css";
|
||||
|
||||
@@ -21,7 +23,17 @@ export default function LoginPage() {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const fadeRef = useRef<HTMLDivElement>(null);
|
||||
const errRef = useRef<HTMLDivElement>(null);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
// next-themes 와 연동 — 메인 화면과 다크모드 상태 공유
|
||||
const { theme, setTheme: setNextTheme, resolvedTheme } = useTheme();
|
||||
const isDark = (resolvedTheme ?? theme) === "dark";
|
||||
|
||||
// resolvedTheme 가 바뀌면 .inv-login 에 dark 클래스 동기화 (login.css 가 .inv-login.dark 셀렉터로 작성됨)
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
root.classList.toggle("dark", isDark);
|
||||
}, [isDark]);
|
||||
|
||||
// ===== Cosmic background — stars + particles =====
|
||||
useEffect(() => {
|
||||
@@ -56,27 +68,12 @@ export default function LoginPage() {
|
||||
}, []);
|
||||
|
||||
// ===== Theme toggle =====
|
||||
const setTheme = useCallback((t: "light" | "dark") => {
|
||||
const root = rootRef.current;
|
||||
const fade = fadeRef.current;
|
||||
if (!root || !fade || (window as any)._themeSwitching) return;
|
||||
const cur = root.classList.contains("dark") ? "dark" : "light";
|
||||
// 클릭 위치에서 원형 reveal — 메인 화면과 동일한 효과 (View Transitions API)
|
||||
const setTheme = useCallback((t: "light" | "dark", e?: React.MouseEvent) => {
|
||||
const cur = isDark ? "dark" : "light";
|
||||
if (cur === t) return;
|
||||
(window as any)._themeSwitching = true;
|
||||
fade.style.background =
|
||||
t === "dark"
|
||||
? "radial-gradient(ellipse at center,#0c0b18,#06050e)"
|
||||
: "radial-gradient(ellipse at center,#f3f2fa,#fafaff)";
|
||||
fade.classList.add("in");
|
||||
setTimeout(() => {
|
||||
root.classList.toggle("dark", t === "dark");
|
||||
setIsDark(t === "dark");
|
||||
setTimeout(() => {
|
||||
fade.classList.remove("in");
|
||||
(window as any)._themeSwitching = false;
|
||||
}, 50);
|
||||
}, 420);
|
||||
}, []);
|
||||
animatedThemeChange(t, setNextTheme, e ? { x: e.clientX, y: e.clientY } : undefined);
|
||||
}, [isDark, setNextTheme]);
|
||||
|
||||
// ===== Show error with denied animation =====
|
||||
useEffect(() => {
|
||||
@@ -120,8 +117,8 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="pill">
|
||||
<button className={!isDark ? "on" : ""} onClick={() => setTheme("light")}>Light</button>
|
||||
<button className={isDark ? "on" : ""} onClick={() => setTheme("dark")}>Dark</button>
|
||||
<button className={!isDark ? "on" : ""} onClick={(e) => setTheme("light", e)}>Light</button>
|
||||
<button className={isDark ? "on" : ""} onClick={(e) => setTheme("dark", e)}>Dark</button>
|
||||
</div>
|
||||
|
||||
<div ref={cardRef} className="login-card">
|
||||
|
||||
@@ -766,3 +766,104 @@ select {
|
||||
.dark .text-amber-600 { color: hsl(38 90% 65%) !important; }
|
||||
|
||||
/* ===== End Dark Mode Compatibility Layer ===== */
|
||||
|
||||
/* ===== View Transitions API — 테마 전환 soft circular reveal =====
|
||||
lib/themeTransition.ts 의 animatedThemeChange() 와 함께 동작.
|
||||
|
||||
동작 원리:
|
||||
1) JS 가 클릭 좌표(--reveal-x, --reveal-y) 와 화면 대각선(--reveal-max) 를 root 에 세팅
|
||||
2) startViewTransition() 호출 → DOM 캡처 → 새 root 의 dark/light 클래스 swap
|
||||
3) ::view-transition-new(root) 가 mask-image radial-gradient 로 노출
|
||||
4) @property 로 length 등록된 --reveal-radius 를 0 → --reveal-max 까지 보간 →
|
||||
안쪽 50% 는 solid, 바깥은 transparent 로 페이드되는 부드러운 가장자리가 퍼짐
|
||||
|
||||
미지원 브라우저(Firefox 등)는 이 셀렉터가 무시되므로 즉시 swap. */
|
||||
|
||||
@property --reveal-radius {
|
||||
syntax: "<length>";
|
||||
inherits: true;
|
||||
initial-value: 0px;
|
||||
}
|
||||
|
||||
/* SVG turbulence noise tile — 220x220 fractalNoise, 알파 0.98~1.0 의 미세 변동.
|
||||
이 tile 을 mask-image 두 번째 레이어로 깔고 mask-composite: intersect 로 그라데이션 알파에
|
||||
곱해서 8bit 컬러 밴딩을 디더링한다.
|
||||
★ 알파 범위를 0.94~1.0 → 0.98~1.0 으로 좁힘. 이전엔 평균 알파가 0.97 이라 VT 동안 새 테마가
|
||||
3% 어둡거나 밝게 표현돼서, VT 끝나고 실제 DOM(100% 불투명) 이 노출되면 갑자기 색이 더
|
||||
진해 보였음. 0.98~1.0 (평균 0.99) 으로 줄이면 드리프트가 1% 미만이라 인지 안 됨. */
|
||||
:root {
|
||||
--vt-dither-noise: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n' x='0%25' y='0%25' width='100%25' height='100%25'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0.98'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
||||
}
|
||||
|
||||
/* VT pseudo-element 가 실제 DOM 위에 깔려서 2~3초 동안 클릭을 가로채는 걸 방지 —
|
||||
퍼지는 동안에도 사이드바/탭/버튼 클릭이 그대로 동작해야 함.
|
||||
pseudo tree: ::view-transition → group(*) → image-pair(*) → old(*)/new(*) — 전부 막아야 함 */
|
||||
::view-transition,
|
||||
::view-transition-group(*),
|
||||
::view-transition-image-pair(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
::view-transition-old(root) { z-index: 0; }
|
||||
::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
/* 두 레이어 마스크:
|
||||
Layer 1 (top) : SVG noise tile — 알파 0.94~1.0 의 미세 변동으로 dither
|
||||
Layer 2 (bottom) : 8 stop radial-gradient soft reveal
|
||||
mask-composite: intersect (= alpha 곱셈) 로 두 레이어를 곱하면 그라데이션 알파에 ±3% 의 random
|
||||
변동이 더해져 8bit 컬러 밴딩(띠) 이 흩어진다. -webkit- 의 source-in 은 Porter-Duff IN 으로
|
||||
같은 곱셈 효과 (Safari 18 이전 호환). */
|
||||
-webkit-mask-image:
|
||||
var(--vt-dither-noise),
|
||||
radial-gradient(
|
||||
circle at var(--reveal-x, 50%) var(--reveal-y, 50%),
|
||||
rgba(0, 0, 0, 1) 0%,
|
||||
rgba(0, 0, 0, 1) calc(var(--reveal-radius, 0px) * 0.25),
|
||||
rgba(0, 0, 0, 0.97) calc(var(--reveal-radius, 0px) * 0.4),
|
||||
rgba(0, 0, 0, 0.85) calc(var(--reveal-radius, 0px) * 0.55),
|
||||
rgba(0, 0, 0, 0.6) calc(var(--reveal-radius, 0px) * 0.7),
|
||||
rgba(0, 0, 0, 0.3) calc(var(--reveal-radius, 0px) * 0.85),
|
||||
rgba(0, 0, 0, 0.1) calc(var(--reveal-radius, 0px) * 0.95),
|
||||
rgba(0, 0, 0, 0) var(--reveal-radius, 0px)
|
||||
);
|
||||
-webkit-mask-composite: source-in;
|
||||
-webkit-mask-size: 220px 220px, 100% 100%;
|
||||
-webkit-mask-repeat: repeat, no-repeat;
|
||||
-webkit-mask-position: 0 0, 0 0;
|
||||
mask-image:
|
||||
var(--vt-dither-noise),
|
||||
radial-gradient(
|
||||
circle at var(--reveal-x, 50%) var(--reveal-y, 50%),
|
||||
rgba(0, 0, 0, 1) 0%,
|
||||
rgba(0, 0, 0, 1) calc(var(--reveal-radius, 0px) * 0.25),
|
||||
rgba(0, 0, 0, 0.97) calc(var(--reveal-radius, 0px) * 0.4),
|
||||
rgba(0, 0, 0, 0.85) calc(var(--reveal-radius, 0px) * 0.55),
|
||||
rgba(0, 0, 0, 0.6) calc(var(--reveal-radius, 0px) * 0.7),
|
||||
rgba(0, 0, 0, 0.3) calc(var(--reveal-radius, 0px) * 0.85),
|
||||
rgba(0, 0, 0, 0.1) calc(var(--reveal-radius, 0px) * 0.95),
|
||||
rgba(0, 0, 0, 0) var(--reveal-radius, 0px)
|
||||
);
|
||||
mask-composite: intersect;
|
||||
mask-size: 220px 220px, 100% 100%;
|
||||
mask-repeat: repeat, no-repeat;
|
||||
mask-position: 0 0, 0 0;
|
||||
/* ease-in-out-cubic — 대칭형 곡선으로 시간/progress 가 거의 linear 에 가깝게 진행됨.
|
||||
1800ms 중 약 1260ms 가 시각적 reveal 에 쓰임 — 빠르지도 늦지도 않은 sweet spot. */
|
||||
animation: vt-soft-reveal 1800ms cubic-bezier(0.65, 0, 0.35, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes vt-soft-reveal {
|
||||
from {
|
||||
--reveal-radius: 0px;
|
||||
}
|
||||
to {
|
||||
--reveal-radius: var(--reveal-max, 1500px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "WACE 솔루션 - WACE",
|
||||
title: "Invyone 솔루션 - Invyone",
|
||||
description: "제품 수명 주기 관리(PLM) 솔루션",
|
||||
keywords: ["WACE", "PLM", "Product Lifecycle Management", "WACE", "제품관리"],
|
||||
authors: [{ name: "WACE" }],
|
||||
keywords: ["Invyone", "PLM", "Product Lifecycle Management", "제품관리"],
|
||||
authors: [{ name: "Invyone" }],
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
|
||||
import { getIconComponent } from "@/components/admin/MenuIconPicker";
|
||||
import { animatedThemeChange } from "@/lib/themeTransition";
|
||||
|
||||
interface ExtendedUserInfo {
|
||||
user_id: string;
|
||||
@@ -247,21 +248,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
||||
const { theme, setTheme: rawSetTheme } = useTheme();
|
||||
|
||||
const setNextTheme = useCallback((t: string) => {
|
||||
// 테마 전환 — 클릭 위치에서 원형으로 새 테마가 reveal (View Transitions API)
|
||||
const setNextTheme = useCallback((t: "light" | "dark", e?: React.MouseEvent) => {
|
||||
if (theme === t) return;
|
||||
const fade = document.getElementById("v5-theme-fade");
|
||||
if (fade) {
|
||||
fade.style.background = t === "dark"
|
||||
? "radial-gradient(ellipse at center,#0c0b18,#06050e)"
|
||||
: "radial-gradient(ellipse at center,#f3f2fa,#fafaff)";
|
||||
fade.classList.add("in");
|
||||
setTimeout(() => {
|
||||
rawSetTheme(t);
|
||||
setTimeout(() => fade.classList.remove("in"), 50);
|
||||
}, 420);
|
||||
} else {
|
||||
rawSetTheme(t);
|
||||
}
|
||||
animatedThemeChange(t, rawSetTheme, e ? { x: e.clientX, y: e.clientY } : undefined);
|
||||
}, [theme, rawSetTheme]);
|
||||
|
||||
// URL 직접 접근 시 탭 자동 열기
|
||||
@@ -467,18 +457,25 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
|
||||
const handleModeSwitch = useCallback(() => {
|
||||
if (modeTransition !== "idle") return;
|
||||
setModeTransition("out");
|
||||
|
||||
// Phase 1: slide out sidebar + fade tabs (300ms)
|
||||
// 레퍼런스 invion-layout-v5.html switchMode() 를 그대로 따름:
|
||||
// Phase 1 (0ms) : mode-fade 오버레이 페이드인 + 사이드바 slide-out + 탭 fade-out
|
||||
// Phase 2 (300ms) : 모드 swap (React 재렌더) → 새 사이드바/탭 등장
|
||||
// Phase 3 (600ms) : 오버레이 페이드아웃 + 정리
|
||||
const fade = document.getElementById("v5-mode-fade");
|
||||
setModeTransition("out");
|
||||
fade?.classList.add("in");
|
||||
|
||||
setTimeout(() => {
|
||||
// Phase 2: swap mode — React re-renders with new menus/tabs
|
||||
setTabMode(isAdminMode ? "user" : "admin");
|
||||
setModeTransition("in");
|
||||
|
||||
// Phase 3: slide in completes, cleanup
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => setModeTransition("idle"), 450);
|
||||
});
|
||||
// Phase 3: 오버레이 페이드아웃 후 정리
|
||||
setTimeout(() => {
|
||||
fade?.classList.remove("in");
|
||||
setModeTransition("idle");
|
||||
}, 300);
|
||||
}, 300);
|
||||
}, [isAdminMode, setTabMode, modeTransition]);
|
||||
|
||||
@@ -730,7 +727,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
{/* Theme fade overlay */}
|
||||
<div className="v5-theme-fade" id="v5-theme-fade" />
|
||||
|
||||
{/* Mode transition — no overlay, sidebar/tabs animate via modeTransition state */}
|
||||
{/* Mode transition fade overlay — radial gradient 으로 화면을 한 번 덮어 사이드바/탭 swap 을 가림 */}
|
||||
<div className="v5-mode-fade" id="v5-mode-fade" />
|
||||
|
||||
{/* V5 Shell */}
|
||||
<div className={`v5-shell ${isAdminMode ? "v5-admin-mode" : ""}`}>
|
||||
@@ -753,8 +751,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<div className="v5-hdr-r">
|
||||
{/* Theme pill */}
|
||||
<div className="v5-pill">
|
||||
<button className={theme !== "dark" ? "on" : ""} onClick={() => setNextTheme("light")}>Light</button>
|
||||
<button className={theme === "dark" ? "on" : ""} onClick={() => setNextTheme("dark")}>Dark</button>
|
||||
<button className={theme !== "dark" ? "on" : ""} onClick={(e) => setNextTheme("light", e)}>Light</button>
|
||||
<button className={theme === "dark" ? "on" : ""} onClick={(e) => setNextTheme("dark", e)}>Dark</button>
|
||||
</div>
|
||||
|
||||
{/* Mini tab icon (visible when tabs collapsed) */}
|
||||
|
||||
+26
-148
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiCall } from "@/lib/api/client";
|
||||
import { AuthLogger } from "@/lib/authLogger";
|
||||
import { TokenManager } from "@/lib/auth/tokenManager";
|
||||
|
||||
interface UserInfo {
|
||||
user_id?: string;
|
||||
@@ -33,12 +34,6 @@ interface AuthStatus {
|
||||
deptCode?: string;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -46,40 +41,6 @@ interface ApiResponse<T = any> {
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
// JWT 토큰 관리 유틸리티 (client.ts와 동일한 localStorage 키 사용)
|
||||
const TokenManager = {
|
||||
getToken: (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("authToken");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
setToken: (token: string): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("authToken", token);
|
||||
// 쿠키에도 저장 (미들웨어에서 사용)
|
||||
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
}
|
||||
},
|
||||
|
||||
removeToken: (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||
}
|
||||
},
|
||||
|
||||
isTokenExpired: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.exp * 1000 < Date.now();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 인증 상태 관리 훅
|
||||
* - 401 처리는 client.ts의 응답 인터셉터에서 통합 관리
|
||||
@@ -159,8 +120,9 @@ export const useAuth = () => {
|
||||
|
||||
/**
|
||||
* 사용자 데이터 새로고침
|
||||
* - API 실패 시에도 토큰이 유효하면 토큰 기반으로 임시 인증 유지
|
||||
* - 토큰 자체가 없거나 만료된 경우에만 비인증 상태로 전환
|
||||
* - 백엔드 /auth/me 가 성공해야만 로그인 상태 유지
|
||||
* - 실패하면 비인증으로 전환 (이전에는 토큰 페이로드를 그대로 신뢰하던 fallback 이 있었으나
|
||||
* 백엔드 401 을 무시하고 클라이언트 신뢰 상태로 우기는 보안 결함이라 제거)
|
||||
*/
|
||||
const refreshUserData = useCallback(async () => {
|
||||
try {
|
||||
@@ -183,123 +145,40 @@ export const useAuth = () => {
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조
|
||||
// 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라
|
||||
// /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는
|
||||
// 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정
|
||||
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
||||
// /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조
|
||||
// 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라
|
||||
// /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는
|
||||
// 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정
|
||||
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
||||
|
||||
if (userInfo) {
|
||||
setUser(userInfo);
|
||||
if (userInfo) {
|
||||
setUser(userInfo);
|
||||
|
||||
const isAdminFromUser = userInfo.user_id === "plm_admin" || userInfo.user_type === "ADMIN";
|
||||
const finalAuthStatus = {
|
||||
isLoggedIn: true,
|
||||
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
||||
};
|
||||
const isAdminFromUser = userInfo.user_id === "plm_admin" || userInfo.user_type === "ADMIN";
|
||||
const finalAuthStatus = {
|
||||
isLoggedIn: true,
|
||||
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
||||
};
|
||||
|
||||
setAuthStatus(finalAuthStatus);
|
||||
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.user_id}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
||||
} else {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const tempUser: UserInfo = {
|
||||
user_id: payload.user_id || payload.id || "unknown",
|
||||
user_name: payload.user_name || payload.name || "사용자",
|
||||
company_code: payload.company_code || "",
|
||||
isAdmin: payload.user_id === "plm_admin" || payload.user_type === "ADMIN",
|
||||
};
|
||||
|
||||
setUser(tempUser);
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
isAdmin: tempUser.isAdmin,
|
||||
});
|
||||
} catch {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "토큰 파싱 실패 → 비인증 전환");
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도");
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const tempUser: UserInfo = {
|
||||
user_id: payload.user_id || payload.id || "unknown",
|
||||
user_name: payload.user_name || payload.name || "사용자",
|
||||
company_code: payload.company_code || "",
|
||||
isAdmin: payload.user_id === "plm_admin" || payload.user_type === "ADMIN",
|
||||
};
|
||||
|
||||
setUser(tempUser);
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
isAdmin: tempUser.isAdmin,
|
||||
});
|
||||
} catch {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "최종 fallback 실패 → 비인증 전환");
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
}
|
||||
setAuthStatus(finalAuthStatus);
|
||||
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.user_id}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
||||
} else {
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 비인증 전환");
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
}
|
||||
} catch {
|
||||
setError("사용자 정보를 불러오는데 실패했습니다.");
|
||||
AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 예외 → 비인증 전환");
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
setError("사용자 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchCurrentUser, checkAuthStatus]);
|
||||
|
||||
/**
|
||||
* 로그인 처리
|
||||
*/
|
||||
const login = useCallback(
|
||||
async (userId: string, password: string): Promise<LoginResult> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiCall<any>("POST", "/auth/login", {
|
||||
userId,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.success && response.data?.token) {
|
||||
TokenManager.setToken(response.data.token);
|
||||
await refreshUserData();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: response.message || "로그인에 성공했습니다.",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || "로그인에 실패했습니다.",
|
||||
errorCode: response.errorCode,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || "로그인 중 오류가 발생했습니다.";
|
||||
setError(errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[refreshUserData],
|
||||
);
|
||||
|
||||
/**
|
||||
* 회사 전환 처리 (WACE 관리자 전용)
|
||||
*/
|
||||
@@ -448,7 +327,6 @@ export const useAuth = () => {
|
||||
userName: user?.user_name,
|
||||
companyCode: user?.company_code,
|
||||
|
||||
login,
|
||||
logout,
|
||||
switchCompany,
|
||||
checkMenuAuth,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { AuthLogger } from "@/lib/authLogger";
|
||||
import { TokenManager } from "@/lib/auth/tokenManager";
|
||||
|
||||
const authLog = (event: string, detail: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -69,63 +70,6 @@ export const getFullImageUrl = (imagePath: string): string => {
|
||||
return imagePath;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// JWT 토큰 관리 유틸리티
|
||||
// ============================================
|
||||
const TokenManager = {
|
||||
getToken: (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("authToken");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
setToken: (token: string): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("authToken", token);
|
||||
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
}
|
||||
},
|
||||
|
||||
removeToken: (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||
}
|
||||
},
|
||||
|
||||
isTokenExpired: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.exp * 1000 < Date.now();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
// 만료 30분 전부터 갱신 대상
|
||||
isTokenExpiringSoon: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const expiryTime = payload.exp * 1000;
|
||||
const currentTime = Date.now();
|
||||
const thirtyMinutes = 30 * 60 * 1000;
|
||||
return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getTimeUntilExpiry: (token: string): number => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.exp * 1000 - Date.now();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 토큰 갱신 로직 (중복 요청 방지)
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* JWT 토큰 단일 관리 모듈
|
||||
*
|
||||
* - localStorage 와 쿠키에 동시 저장 (미들웨어가 쿠키만 읽기 때문)
|
||||
* - 만료/만료임박 판단 유틸 제공
|
||||
* - 이 파일이 토큰 저장/조회의 단일 진실 공급원 (Single Source of Truth)
|
||||
*
|
||||
* 기존에 client.ts 와 useAuth.ts 양쪽에 중복 정의되어 있던 TokenManager 를
|
||||
* 이 파일로 통합. 양쪽에서 import 해서 사용한다.
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = "authToken";
|
||||
const COOKIE_MAX_AGE = 86400; // 24h
|
||||
const REFRESH_THRESHOLD_MS = 30 * 60 * 1000; // 만료 30분 전부터 갱신 대상
|
||||
|
||||
export const TokenManager = {
|
||||
getToken: (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
setToken: (token: string): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
// 미들웨어에서 사용
|
||||
document.cookie = `${TOKEN_KEY}=${token}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||
},
|
||||
|
||||
removeToken: (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
document.cookie = `${TOKEN_KEY}=; path=/; max-age=0; SameSite=Lax`;
|
||||
},
|
||||
|
||||
isTokenExpired: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.exp * 1000 < Date.now();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
/** 만료 30분 전부터 true */
|
||||
isTokenExpiringSoon: (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const expiryTime = payload.exp * 1000;
|
||||
const currentTime = Date.now();
|
||||
return expiryTime - currentTime < REFRESH_THRESHOLD_MS && expiryTime > currentTime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getTimeUntilExpiry: (token: string): number => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
return payload.exp * 1000 - Date.now();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 테마 전환 애니메이션 — View Transitions API + soft circular reveal.
|
||||
*
|
||||
* 동작: 클릭한 (x, y) 위치에서 새 테마가 부드러운 가장자리(radial-gradient feather)를 가진
|
||||
* 원형 mask 로 퍼져나간다. clip-path 와 달리 가장자리가 sharp 하지 않고 색이 자연스럽게
|
||||
* 번지는 느낌을 준다.
|
||||
*
|
||||
* 실제 애니메이션은 globals.css 의 ::view-transition-new(root) 의 mask-image + @property
|
||||
* 등록된 --reveal-radius 키프레임이 담당한다. 이 함수는:
|
||||
* 1) CSS 커스텀 변수 (--reveal-x/-y/-max) 를 root 에 세팅
|
||||
* 2) startViewTransition() 으로 DOM 캡처 → 새 root 의 dark/light 클래스 swap
|
||||
*
|
||||
* View Transitions API 미지원 브라우저(주로 Firefox)는 즉시 swap 으로 fallback.
|
||||
*/
|
||||
|
||||
type ApplyTheme = (theme: "light" | "dark") => void;
|
||||
|
||||
type DocumentWithVT = Document & {
|
||||
startViewTransition?: (cb: () => void | Promise<void>) => {
|
||||
ready: Promise<void>;
|
||||
finished: Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export function animatedThemeChange(
|
||||
next: "light" | "dark",
|
||||
applyTheme: ApplyTheme,
|
||||
origin?: { x: number; y: number },
|
||||
): void {
|
||||
const doc = document as DocumentWithVT;
|
||||
|
||||
// 클릭 위치(없으면 화면 중앙)
|
||||
const x = origin?.x ?? window.innerWidth / 2;
|
||||
const y = origin?.y ?? window.innerHeight / 2;
|
||||
// 클릭 지점에서 화면 가장 먼 모서리까지 거리
|
||||
const cornerDistance = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
);
|
||||
// ★ 중요: 그라데이션의 안쪽 25% 만 solid (alpha 1.0) 이고 나머지 75% 는 페이드.
|
||||
// 애니메이션 끝났을 때 화면 모서리가 페이드 구간이 아니라 solid 구간에 들어가야 새 테마가
|
||||
// 완전히 화면을 덮은 것처럼 보임. 모서리가 그라데이션의 0.22 (= 1/4.5) 위치에 떨어지도록
|
||||
// endRadius 를 모서리 거리의 4.5 배로 설정 → 안쪽 25% 영역 안에 안전하게 들어감.
|
||||
const endRadius = cornerDistance * 4.5;
|
||||
|
||||
// CSS 가 사용할 변수 세팅 (root 에 두면 ::view-transition-new(root) 가 상속)
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--reveal-x", `${x}px`);
|
||||
root.style.setProperty("--reveal-y", `${y}px`);
|
||||
root.style.setProperty("--reveal-max", `${endRadius}px`);
|
||||
|
||||
// View Transitions API 미지원 → 즉시 적용
|
||||
if (!doc.startViewTransition) {
|
||||
applyTheme(next);
|
||||
return;
|
||||
}
|
||||
|
||||
// ★ 핵심: VT 캡처 전에 모든 CSS transition 을 일시 비활성화.
|
||||
// v5-layout.css 등에서 `transition: all .3s` 같은 규칙이 여러 곳에 깔려있어서, 다크 클래스를
|
||||
// 토글하면 background-color / color 가 자체 transition 으로 천천히 보간된다. 그 결과 VT 가
|
||||
// 끝난 뒤에도 진짜 DOM 에서 색이 계속 변하는 것처럼 보임("끝났는데 더 어두워지는 느낌").
|
||||
// disable style 을 콜백 직전에 주입 → VT 가 새 스냅샷을 캡처할 땐 이미 transition 무효화 →
|
||||
// transition.ready 후 제거하면 평소 hover 등은 정상 동작.
|
||||
const disableStyle = document.createElement("style");
|
||||
disableStyle.setAttribute("data-vt-theme-disable", "");
|
||||
// ★ transition 만 죽이고 animation 은 그대로 둘 것!
|
||||
// animation-duration:0s 를 넣으면 VT 의 vt-soft-reveal 애니메이션이 영향을 받아
|
||||
// 퍼지는 속도가 빨라진다. 우리가 막고 싶은 건 색깔 보간(transition)만이지 animation 이 아님.
|
||||
disableStyle.appendChild(
|
||||
document.createTextNode(
|
||||
"*,*::before,*::after{transition:none!important;}",
|
||||
),
|
||||
);
|
||||
|
||||
const transition = doc.startViewTransition(() => {
|
||||
// disable 주입 → 클래스 토글이 transition 없이 즉시 반영되도록
|
||||
document.head.appendChild(disableStyle);
|
||||
// VT 의 캡처 시점에 DOM 이 새 테마 상태가 되도록 즉시 클래스 토글.
|
||||
// next-themes 도 함께 호출해서 localStorage / context 동기화.
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
applyTheme(next);
|
||||
});
|
||||
|
||||
// 새 스냅샷 캡처 완료 후 disable 제거 — 이 시점엔 진짜 DOM 은 VT pseudo 뒤에 가려져 있어
|
||||
// 사용자 눈에는 안 보이지만, transition 은 평소대로 복구되어 hover 등에 영향 없음
|
||||
transition.ready
|
||||
.then(() => {
|
||||
// 강제 reflow 후 제거 (브라우저가 disable 상태를 확실히 적용한 뒤 풀도록)
|
||||
void document.body.offsetHeight;
|
||||
disableStyle.remove();
|
||||
})
|
||||
.catch(() => {
|
||||
// VT 실패 시에도 disable 은 제거해야 함
|
||||
disableStyle.remove();
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,965 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>INVION v5 — 사용자 관리 (Cosmic Redesign Mockup)</title>
|
||||
|
||||
<!--
|
||||
─────────────────────────────────────────────────────────────────
|
||||
INVION v5 Cosmic Glassmorphism — 관리자 페이지 표준 템플릿
|
||||
─────────────────────────────────────────────────────────────────
|
||||
· 디자인 토큰은 frontend/styles/v5-layout.css 와 100% 동일
|
||||
· 클래스명도 기존 v5-* 컨벤션 그대로 (React 옮길 때 그대로 사용)
|
||||
· 새로 추가된 클래스(카드/테이블/버튼/인풋/배지/페이지네이션)는
|
||||
같은 v5- 접두사로 추가 → frontend/styles/v5-layout.css 에 합치면 됨
|
||||
· 79개 관리 페이지 중 대부분이 이 패턴 (목록 + 필터 + 테이블 + 모달)
|
||||
─────────────────────────────────────────────────────────────────
|
||||
-->
|
||||
|
||||
<style>
|
||||
/* ============================================================
|
||||
1. v5 디자인 토큰 (frontend/styles/v5-layout.css 와 동일)
|
||||
============================================================ */
|
||||
:root{
|
||||
--v5-bg:#fafaff; --v5-bg-subtle:#f3f2fa;
|
||||
--v5-surface:rgba(255,255,255,0.55); --v5-surface-solid:#ffffff;
|
||||
--v5-surface-hover:rgba(255,255,255,0.7);
|
||||
--v5-text:#0f0e1a; --v5-text-sec:#6b6a80; --v5-text-muted:#9998ad;
|
||||
--v5-primary:#6c5ce7; --v5-primary-light:#a29bfe; --v5-primary-glow:rgba(108,92,231,0.25);
|
||||
--v5-cyan:#00cec9; --v5-cyan-glow:rgba(0,206,201,0.2);
|
||||
--v5-pink:#fd79a8; --v5-pink-glow:rgba(253,121,168,0.15);
|
||||
--v5-red:#ff4757; --v5-green:#00b894; --v5-amber:#fdcb6e;
|
||||
--v5-border:rgba(108,92,231,0.12); --v5-border-subtle:rgba(0,0,0,0.05);
|
||||
--v5-glass:rgba(255,255,255,0.45); --v5-glass-strong:rgba(255,255,255,0.65);
|
||||
--v5-glass-border:rgba(108,92,231,0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(108,92,231,0.12);
|
||||
--v5-glow-md:0 0 40px rgba(108,92,231,0.2);
|
||||
--v5-glow-lg:0 0 80px rgba(108,92,231,0.25);
|
||||
--v5-sidebar-w:220px;
|
||||
}
|
||||
.dark{
|
||||
--v5-bg:#06050e; --v5-bg-subtle:#0c0b18;
|
||||
--v5-surface:rgba(17,16,42,0.5); --v5-surface-solid:#11102a;
|
||||
--v5-surface-hover:rgba(25,24,64,0.6);
|
||||
--v5-text:#eae8f4; --v5-text-sec:#8d8ba8; --v5-text-muted:#5a587a;
|
||||
--v5-primary:#a29bfe; --v5-primary-light:#c8c4ff; --v5-primary-glow:rgba(162,155,254,0.25);
|
||||
--v5-cyan:#55efc4; --v5-cyan-glow:rgba(85,239,196,0.15);
|
||||
--v5-pink:#fd79a8; --v5-red:#ff6b6b; --v5-green:#55efc4; --v5-amber:#ffeaa7;
|
||||
--v5-border:rgba(162,155,254,0.1); --v5-border-subtle:rgba(255,255,255,0.04);
|
||||
--v5-glass:rgba(17,16,42,0.45); --v5-glass-strong:rgba(17,16,42,0.65);
|
||||
--v5-glass-border:rgba(162,155,254,0.12);
|
||||
--v5-glow-sm:0 0 20px rgba(162,155,254,0.1);
|
||||
--v5-glow-md:0 0 40px rgba(162,155,254,0.18);
|
||||
--v5-glow-lg:0 0 80px rgba(162,155,254,0.22);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;height:100%;}
|
||||
body{
|
||||
font-family:'Inter',-apple-system,BlinkMacSystemFont,system-ui,'Pretendard','Noto Sans KR',sans-serif;
|
||||
font-size:13px;color:var(--v5-text);background:var(--v5-bg);
|
||||
overflow:hidden;-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
2. 코스믹 배경 (v5-cosmos) — 기존 css 와 동일
|
||||
============================================================ */
|
||||
.v5-cosmos{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
|
||||
.v5-cosmos .star{position:absolute;width:2px;height:2px;background:#fff;border-radius:50%;
|
||||
animation:v5-twinkle var(--d,3s) ease-in-out infinite alternate;animation-delay:var(--dl,0s);opacity:0;}
|
||||
.v5-cosmos .star.c{width:3px;height:3px;background:var(--sc);}
|
||||
@keyframes v5-twinkle{0%{opacity:0;transform:scale(.5)}100%{opacity:var(--mo,.7);transform:scale(1)}}
|
||||
html:not(.dark) .v5-cosmos .star{display:none;}
|
||||
html:not(.dark) .v5-cosmos .shooting-star{display:none;}
|
||||
html:not(.dark) .v5-cosmos .particle{display:none;}
|
||||
|
||||
.v5-cosmos .neb{position:absolute;border-radius:50%;filter:blur(140px);animation:v5-drift 16s ease-in-out infinite alternate;}
|
||||
.v5-cosmos .neb-1{width:700px;height:700px;top:-20%;right:-15%;background:radial-gradient(circle,var(--v5-primary-glow),transparent 70%);animation-duration:18s;}
|
||||
.v5-cosmos .neb-2{width:600px;height:600px;bottom:-25%;left:-10%;background:radial-gradient(circle,var(--v5-cyan-glow),transparent 70%);animation-duration:14s;animation-delay:-4s;}
|
||||
.v5-cosmos .neb-3{width:450px;height:450px;top:35%;left:40%;background:radial-gradient(circle,var(--v5-pink-glow),transparent 70%);animation-duration:12s;animation-delay:-8s;}
|
||||
.v5-cosmos .neb-4{width:350px;height:350px;top:60%;right:25%;background:radial-gradient(circle,rgba(108,92,231,0.08),transparent 70%);animation-duration:20s;animation-delay:-2s;}
|
||||
@keyframes v5-drift{0%{transform:translate(0,0) scale(1)}100%{transform:translate(30px,-25px) scale(1.1)}}
|
||||
|
||||
html:not(.dark) .v5-cosmos{background:linear-gradient(180deg,#e8e4ff 0%,#f0edff 30%,#fafaff 60%,#f5f0ff 100%);}
|
||||
html:not(.dark) .v5-cosmos .neb{filter:blur(100px);}
|
||||
html:not(.dark) .v5-cosmos .neb-1{width:1200px;height:500px;top:auto;bottom:-10%;right:-15%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.9),rgba(230,225,255,0.5),transparent 70%);}
|
||||
html:not(.dark) .v5-cosmos .neb-2{width:1000px;height:400px;top:auto;bottom:-5%;left:-10%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.85),rgba(200,240,255,0.4),transparent 70%);}
|
||||
html:not(.dark) .v5-cosmos .neb-3{width:800px;height:350px;top:auto;bottom:5%;left:30%;
|
||||
background:radial-gradient(ellipse,rgba(255,255,255,0.8),rgba(240,220,255,0.3),transparent 70%);}
|
||||
html:not(.dark) .v5-cosmos .neb-4{width:600px;height:600px;top:-10%;right:20%;bottom:auto;
|
||||
background:radial-gradient(circle,rgba(108,92,231,0.08),rgba(0,206,201,0.04),transparent 70%);}
|
||||
|
||||
.v5-cosmos .shooting-star{position:absolute;width:80px;height:1px;
|
||||
background:linear-gradient(90deg,rgba(255,255,255,0.6),transparent);
|
||||
transform:rotate(35deg);animation:v5-shoot 5s ease-in-out infinite;opacity:0;}
|
||||
@keyframes v5-shoot{0%{opacity:0;transform:rotate(35deg) translateX(0)}3%{opacity:.7}12%{opacity:0;transform:rotate(35deg) translateX(-350px)}100%{opacity:0}}
|
||||
.v5-cosmos .particle{position:absolute;width:var(--sz,4px);height:var(--sz,4px);background:var(--pc,var(--v5-primary));
|
||||
border-radius:50%;opacity:0;animation:v5-floatup var(--fd,9s) ease-in-out infinite;animation-delay:var(--fdl,0s);}
|
||||
@keyframes v5-floatup{0%{opacity:0;transform:translateY(100vh) scale(0)}10%{opacity:.4}90%{opacity:.4}100%{opacity:0;transform:translateY(-80px) scale(1)}}
|
||||
|
||||
/* ============================================================
|
||||
3. 셸 / 헤더 / 탭 / 사이드바 (v5 원본 동일)
|
||||
============================================================ */
|
||||
.v5-shell{display:flex;flex-direction:column;height:100vh;position:relative;z-index:1;}
|
||||
|
||||
.v5-hdr{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 1.25rem;
|
||||
background:var(--v5-glass);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border-bottom:1px solid var(--v5-glass-border);position:relative;z-index:20;flex-shrink:0;}
|
||||
.v5-hdr-l{display:flex;align-items:center;gap:1rem;}
|
||||
.v5-hdr-logo{font-size:1.05rem;font-weight:900;letter-spacing:-.03em;
|
||||
background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));-webkit-background-clip:text;
|
||||
-webkit-text-fill-color:transparent;background-clip:text;cursor:default;}
|
||||
.v5-hdr-bc{font-size:.72rem;color:var(--v5-text-muted);}
|
||||
.v5-hdr-bc b{color:var(--v5-text);font-weight:600;}
|
||||
.v5-hdr-r{display:flex;align-items:center;gap:.65rem;}
|
||||
|
||||
.v5-pill{display:flex;background:var(--v5-surface);backdrop-filter:blur(8px);border:1px solid var(--v5-glass-border);
|
||||
border-radius:999px;padding:2px;}
|
||||
.v5-pill button{padding:.22rem .65rem;border-radius:999px;border:none;background:transparent;
|
||||
color:var(--v5-text-muted);cursor:pointer;font-size:.6rem;font-weight:600;font-family:inherit;
|
||||
transition:all .3s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-pill button.on{background:var(--v5-primary);color:#fff;box-shadow:var(--v5-glow-sm);}
|
||||
|
||||
.v5-bell,.v5-admin-btn{position:relative;width:32px;height:32px;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-muted);cursor:pointer;
|
||||
display:flex;align-items:center;justify-content:center;transition:all .25s;}
|
||||
.v5-bell:hover,.v5-admin-btn:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-bell-dot{position:absolute;top:5px;right:5px;width:7px;height:7px;background:var(--v5-red);border-radius:50%;animation:v5-pdot 2s infinite;}
|
||||
@keyframes v5-pdot{0%,100%{box-shadow:0 0 0 0 rgba(255,71,87,.4)}50%{box-shadow:0 0 0 5px rgba(255,71,87,0)}}
|
||||
|
||||
.v5-admin-badge{display:inline-flex;align-items:center;gap:.4rem;padding:.2rem .6rem;border-radius:999px;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(0,206,201,.08));
|
||||
border:1px solid rgba(108,92,231,.2);font-size:.58rem;font-weight:700;color:var(--v5-primary);}
|
||||
.dark .v5-admin-badge{background:linear-gradient(135deg,rgba(162,155,254,.12),rgba(85,239,196,.08));
|
||||
border-color:rgba(162,155,254,.2);color:var(--v5-primary-light);}
|
||||
.v5-admin-badge .badge-dot{width:6px;height:6px;border-radius:50%;background:var(--v5-cyan);
|
||||
box-shadow:0 0 8px var(--v5-cyan-glow);animation:v5-bdPulse 2s infinite;}
|
||||
@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-cyan-glow)}50%{box-shadow:0 0 12px var(--v5-cyan-glow)}}
|
||||
|
||||
.v5-avatar{width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));
|
||||
display:flex;align-items:center;justify-content:center;font-size:.7rem;font-weight:700;color:#fff;
|
||||
cursor:pointer;transition:transform .2s,box-shadow .3s;}
|
||||
.v5-avatar:hover{transform:scale(1.1);box-shadow:var(--v5-glow-sm);}
|
||||
|
||||
/* Tabs */
|
||||
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
|
||||
background:var(--v5-glass);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
|
||||
border-bottom:1px solid var(--v5-glass-border);position:relative;z-index:15;flex-shrink:0;}
|
||||
.v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
|
||||
color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;}
|
||||
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
|
||||
.v5-tab.on{color:var(--v5-primary);font-weight:600;border-bottom-color:var(--v5-primary);background:var(--v5-surface);}
|
||||
.v5-tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--v5-text-muted);
|
||||
font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;}
|
||||
.v5-tab:hover .v5-tab-x{opacity:1;}
|
||||
.v5-tab-x:hover{background:rgba(255,71,87,.15);color:var(--v5-red);}
|
||||
|
||||
/* Body / sidebar */
|
||||
.v5-body{display:flex;flex:1;overflow:hidden;position:relative;z-index:5;}
|
||||
.v5-side{width:var(--v5-sidebar-w);background:var(--v5-glass);backdrop-filter:blur(20px) saturate(1.3);
|
||||
-webkit-backdrop-filter:blur(20px) saturate(1.3);border-right:1px solid var(--v5-glass-border);
|
||||
padding:.85rem .6rem;overflow-y:auto;display:flex;flex-direction:column;gap:1px;flex-shrink:0;}
|
||||
.v5-side-sec{font-size:.55rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em;
|
||||
color:var(--v5-text-muted);padding:1rem .65rem .35rem;}
|
||||
.v5-side-sec:first-child{padding-top:.25rem;}
|
||||
.v5-si{padding:.5rem .7rem;border-radius:10px;font-size:.77rem;color:var(--v5-text-sec);cursor:pointer;
|
||||
transition:all .25s cubic-bezier(.4,0,.2,1);font-weight:450;display:flex;align-items:center;gap:.6rem;
|
||||
position:relative;overflow:hidden;}
|
||||
.v5-si .ic{width:16px;height:16px;display:flex;align-items:center;justify-content:center;opacity:.65;flex-shrink:0;}
|
||||
.v5-si:hover{background:var(--v5-surface-hover);color:var(--v5-text);transform:translateX(2px);}
|
||||
.v5-si.on{background:linear-gradient(135deg,rgba(108,92,231,.12),rgba(108,92,231,.05));
|
||||
color:var(--v5-primary);font-weight:600;border:1px solid rgba(108,92,231,.15);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-si.on .ic{opacity:1;}
|
||||
.dark .v5-si.on{background:linear-gradient(135deg,rgba(162,155,254,.14),rgba(162,155,254,.05));border-color:rgba(162,155,254,.15);}
|
||||
.v5-si::before{content:'';position:absolute;left:0;top:0;width:3px;height:100%;background:var(--v5-primary);
|
||||
border-radius:0 2px 2px 0;transform:scaleY(0);transition:transform .3s cubic-bezier(.16,1,.3,1);}
|
||||
.v5-si:hover::before{transform:scaleY(.5);opacity:.4;}
|
||||
.v5-si.on::before{transform:scaleY(1);opacity:1;}
|
||||
.v5-si .badge{margin-left:auto;font-size:.55rem;font-weight:700;padding:.1rem .4rem;border-radius:999px;
|
||||
background:rgba(108,92,231,.12);color:var(--v5-primary);}
|
||||
.dark .v5-si .badge{background:rgba(162,155,254,.14);color:var(--v5-primary-light);}
|
||||
|
||||
/* Content */
|
||||
.v5-content{flex:1;overflow-y:auto;padding:1.25rem;display:flex;flex-direction:column;gap:1rem;}
|
||||
|
||||
/* ============================================================
|
||||
4. NEW — 글래스 카드 / 페이지헤더 / 통계 / 버튼 / 인풋 / 테이블
|
||||
============================================================ */
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.v5-page-hd{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;
|
||||
padding:.25rem .25rem .75rem;border-bottom:1px solid var(--v5-border-subtle);}
|
||||
.v5-page-hd .pg-tt{display:flex;align-items:center;gap:.65rem;}
|
||||
.v5-page-hd .pg-icon{width:36px;height:36px;border-radius:12px;display:flex;align-items:center;justify-content:center;
|
||||
background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));color:#fff;
|
||||
box-shadow:var(--v5-glow-sm);}
|
||||
.v5-page-hd h1{font-size:1.05rem;font-weight:800;letter-spacing:-.02em;margin:0;color:var(--v5-text);}
|
||||
.v5-page-hd .pg-sub{font-size:.7rem;color:var(--v5-text-muted);margin-top:.15rem;}
|
||||
.v5-page-hd .pg-act{display:flex;gap:.45rem;align-items:center;}
|
||||
|
||||
/* 글래스 카드 */
|
||||
.v5-card{background:var(--v5-glass);backdrop-filter:blur(20px) saturate(1.4);-webkit-backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--v5-glass-border);border-radius:16px;
|
||||
box-shadow:0 4px 24px rgba(0,0,0,0.04),inset 0 0 0 1px rgba(255,255,255,0.18);}
|
||||
.dark .v5-card{box-shadow:0 4px 24px rgba(0,0,0,0.35),var(--v5-glow-sm),inset 0 0 0 1px rgba(162,155,254,0.04);}
|
||||
.v5-card-hd{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:.85rem 1rem .7rem;border-bottom:1px solid var(--v5-border-subtle);}
|
||||
.v5-card-hd .card-tt{font-size:.78rem;font-weight:700;color:var(--v5-text);display:flex;align-items:center;gap:.45rem;}
|
||||
.v5-card-hd .card-tt .dot{width:6px;height:6px;border-radius:50%;background:var(--v5-primary);box-shadow:0 0 6px var(--v5-primary-glow);}
|
||||
.v5-card-hd .card-meta{font-size:.6rem;color:var(--v5-text-muted);}
|
||||
.v5-card-bd{padding:1rem;}
|
||||
|
||||
/* 통계 카드 그리드 */
|
||||
.v5-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:.85rem;}
|
||||
.v5-stat{position:relative;padding:.95rem 1rem;border-radius:14px;overflow:hidden;
|
||||
background:var(--v5-glass);backdrop-filter:blur(20px) saturate(1.4);
|
||||
border:1px solid var(--v5-glass-border);
|
||||
transition:transform .25s cubic-bezier(.16,1,.3,1),box-shadow .3s;}
|
||||
.v5-stat:hover{transform:translateY(-2px);box-shadow:var(--v5-glow-md);}
|
||||
.v5-stat::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:linear-gradient(180deg,var(--v5-primary),var(--v5-cyan));}
|
||||
.v5-stat.cyan::before{background:linear-gradient(180deg,var(--v5-cyan),var(--v5-primary));}
|
||||
.v5-stat.pink::before{background:linear-gradient(180deg,var(--v5-pink),var(--v5-primary));}
|
||||
.v5-stat.amber::before{background:linear-gradient(180deg,var(--v5-amber),var(--v5-pink));}
|
||||
.v5-stat .lbl{font-size:.6rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--v5-text-muted);}
|
||||
.v5-stat .val{font-size:1.55rem;font-weight:800;letter-spacing:-.03em;color:var(--v5-text);margin-top:.25rem;}
|
||||
.v5-stat .delta{display:inline-flex;align-items:center;gap:.2rem;font-size:.6rem;font-weight:700;
|
||||
margin-top:.15rem;padding:.1rem .4rem;border-radius:999px;}
|
||||
.v5-stat .delta.up{color:var(--v5-green);background:rgba(0,184,148,.1);}
|
||||
.dark .v5-stat .delta.up{background:rgba(85,239,196,.08);}
|
||||
.v5-stat .delta.down{color:var(--v5-red);background:rgba(255,71,87,.1);}
|
||||
.v5-stat .ic-bg{position:absolute;right:.6rem;top:50%;transform:translateY(-50%);
|
||||
width:48px;height:48px;border-radius:14px;display:flex;align-items:center;justify-content:center;
|
||||
background:linear-gradient(135deg,rgba(108,92,231,.1),rgba(0,206,201,.04));
|
||||
color:var(--v5-primary);opacity:.85;}
|
||||
.dark .v5-stat .ic-bg{background:linear-gradient(135deg,rgba(162,155,254,.1),rgba(85,239,196,.04));color:var(--v5-primary-light);}
|
||||
|
||||
/* 필터바 — 카드 안의 한 줄 컨트롤 */
|
||||
.v5-filter{display:flex;align-items:center;gap:.55rem;flex-wrap:wrap;}
|
||||
.v5-filter .grow{flex:1;min-width:200px;}
|
||||
|
||||
/* 인풋 */
|
||||
.v5-input{height:34px;padding:0 .75rem;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text);font-size:.72rem;
|
||||
font-family:inherit;outline:none;transition:all .25s cubic-bezier(.4,0,.2,1);min-width:0;}
|
||||
.v5-input::placeholder{color:var(--v5-text-muted);}
|
||||
.v5-input:focus{border-color:var(--v5-primary);box-shadow:0 0 0 3px var(--v5-primary-glow),var(--v5-glow-sm);}
|
||||
.v5-input.with-ic{padding-left:2rem;}
|
||||
.v5-input-wrap{position:relative;display:flex;align-items:center;}
|
||||
.v5-input-wrap .ic{position:absolute;left:9px;color:var(--v5-text-muted);pointer-events:none;display:flex;}
|
||||
.v5-input-wrap:focus-within .ic{color:var(--v5-primary);}
|
||||
|
||||
/* 셀렉트 (네이티브) */
|
||||
.v5-select{height:34px;padding:0 2rem 0 .75rem;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text);font-size:.72rem;
|
||||
font-family:inherit;cursor:pointer;outline:none;appearance:none;transition:all .25s;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239998ad' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right .55rem center;}
|
||||
.v5-select:focus{border-color:var(--v5-primary);box-shadow:0 0 0 3px var(--v5-primary-glow);}
|
||||
|
||||
/* 버튼 */
|
||||
.v5-btn{height:34px;padding:0 .85rem;border-radius:10px;border:1px solid var(--v5-glass-border);
|
||||
background:var(--v5-surface);backdrop-filter:blur(8px);color:var(--v5-text-sec);font-size:.7rem;
|
||||
font-weight:600;font-family:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:.4rem;
|
||||
transition:all .25s cubic-bezier(.4,0,.2,1);}
|
||||
.v5-btn:hover{border-color:var(--v5-primary);color:var(--v5-primary);box-shadow:var(--v5-glow-sm);transform:translateY(-1px);}
|
||||
.v5-btn.primary{background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));color:#fff;border-color:transparent;
|
||||
box-shadow:0 4px 16px var(--v5-primary-glow);}
|
||||
.v5-btn.primary:hover{box-shadow:var(--v5-glow-md),0 6px 22px var(--v5-primary-glow);transform:translateY(-2px);}
|
||||
.v5-btn.danger{color:var(--v5-red);}
|
||||
.v5-btn.danger:hover{border-color:var(--v5-red);color:var(--v5-red);box-shadow:0 0 18px rgba(255,71,87,.18);}
|
||||
.v5-btn.ghost{background:transparent;border-color:transparent;color:var(--v5-text-muted);}
|
||||
.v5-btn.ghost:hover{background:var(--v5-surface-hover);color:var(--v5-text);}
|
||||
.v5-btn.sm{height:28px;padding:0 .6rem;font-size:.62rem;border-radius:8px;}
|
||||
.v5-btn.icon{width:34px;padding:0;justify-content:center;}
|
||||
.v5-btn.icon.sm{width:28px;}
|
||||
|
||||
/* 배지 */
|
||||
.v5-bdg{display:inline-flex;align-items:center;gap:.25rem;padding:.18rem .55rem;border-radius:999px;
|
||||
font-size:.58rem;font-weight:700;letter-spacing:.02em;}
|
||||
.v5-bdg.ok{background:rgba(0,184,148,.12);color:var(--v5-green);}
|
||||
.dark .v5-bdg.ok{background:rgba(85,239,196,.12);color:var(--v5-green);}
|
||||
.v5-bdg.warn{background:rgba(253,203,110,.18);color:#b97a06;}
|
||||
.dark .v5-bdg.warn{background:rgba(255,234,167,.12);color:var(--v5-amber);}
|
||||
.v5-bdg.err{background:rgba(255,71,87,.12);color:var(--v5-red);}
|
||||
.v5-bdg.info{background:rgba(108,92,231,.12);color:var(--v5-primary);}
|
||||
.dark .v5-bdg.info{background:rgba(162,155,254,.14);color:var(--v5-primary-light);}
|
||||
.v5-bdg.muted{background:var(--v5-surface-hover);color:var(--v5-text-muted);}
|
||||
.v5-bdg .dot{width:5px;height:5px;border-radius:50%;background:currentColor;}
|
||||
|
||||
/* 테이블 */
|
||||
.v5-table-wrap{overflow:auto;border-radius:14px;border:1px solid var(--v5-border-subtle);}
|
||||
.v5-table{width:100%;border-collapse:separate;border-spacing:0;font-size:.72rem;}
|
||||
.v5-table thead th{position:sticky;top:0;z-index:2;
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(20px) saturate(1.4);
|
||||
text-align:left;padding:.65rem .85rem;font-size:.58rem;font-weight:700;text-transform:uppercase;
|
||||
letter-spacing:.08em;color:var(--v5-text-muted);
|
||||
border-bottom:1px solid var(--v5-glass-border);user-select:none;}
|
||||
.v5-table thead th.center{text-align:center;}
|
||||
.v5-table thead th.right{text-align:right;}
|
||||
.v5-table thead th.sortable{cursor:pointer;}
|
||||
.v5-table thead th.sortable:hover{color:var(--v5-primary);}
|
||||
.v5-table thead th .sort-ic{display:inline-block;margin-left:.2rem;opacity:.5;}
|
||||
.v5-table tbody td{padding:.7rem .85rem;border-bottom:1px solid var(--v5-border-subtle);
|
||||
color:var(--v5-text-sec);vertical-align:middle;}
|
||||
.v5-table tbody tr{transition:background .15s;}
|
||||
.v5-table tbody tr:hover{background:var(--v5-surface-hover);}
|
||||
.v5-table tbody tr.selected{background:linear-gradient(90deg,rgba(108,92,231,.06),transparent);}
|
||||
.dark .v5-table tbody tr.selected{background:linear-gradient(90deg,rgba(162,155,254,.07),transparent);}
|
||||
.v5-table tbody td.center{text-align:center;}
|
||||
.v5-table tbody td.right{text-align:right;}
|
||||
.v5-table tbody td .name{color:var(--v5-text);font-weight:600;}
|
||||
.v5-table tbody td .meta{color:var(--v5-text-muted);font-size:.62rem;margin-top:.1rem;}
|
||||
.v5-table tbody td.actions{white-space:nowrap;}
|
||||
.v5-table tbody td.actions .v5-btn{margin-right:.25rem;}
|
||||
|
||||
/* 사용자 셀 — 아바타 + 이름 + 사번 */
|
||||
.v5-user-cell{display:flex;align-items:center;gap:.6rem;}
|
||||
.v5-user-cell .av{width:30px;height:30px;border-radius:50%;background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));
|
||||
display:flex;align-items:center;justify-content:center;font-size:.6rem;font-weight:700;color:#fff;flex-shrink:0;}
|
||||
.v5-user-cell .av.cyan{background:linear-gradient(135deg,var(--v5-cyan),var(--v5-primary));}
|
||||
.v5-user-cell .av.pink{background:linear-gradient(135deg,var(--v5-pink),var(--v5-primary));}
|
||||
.v5-user-cell .av.amber{background:linear-gradient(135deg,var(--v5-amber),var(--v5-pink));}
|
||||
|
||||
/* 체크박스 (custom) */
|
||||
.v5-chk{width:15px;height:15px;border:1.5px solid var(--v5-glass-border);border-radius:4px;
|
||||
background:var(--v5-surface);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;
|
||||
vertical-align:middle;transition:all .2s;}
|
||||
.v5-chk:hover{border-color:var(--v5-primary);}
|
||||
.v5-chk.on{background:var(--v5-primary);border-color:var(--v5-primary);box-shadow:var(--v5-glow-sm);}
|
||||
.v5-chk.on::after{content:'';width:8px;height:4px;border-left:2px solid #fff;border-bottom:2px solid #fff;
|
||||
transform:rotate(-45deg) translate(1px,-1px);}
|
||||
|
||||
/* 페이지네이션 */
|
||||
.v5-pager{display:flex;align-items:center;justify-content:space-between;padding:.85rem 1rem;
|
||||
border-top:1px solid var(--v5-border-subtle);font-size:.65rem;color:var(--v5-text-muted);}
|
||||
.v5-pager .pg-info{display:flex;align-items:center;gap:.5rem;}
|
||||
.v5-pager .pg-nums{display:flex;align-items:center;gap:.25rem;}
|
||||
.v5-pager .pg-num{min-width:28px;height:28px;padding:0 .5rem;border-radius:8px;border:1px solid transparent;
|
||||
background:transparent;color:var(--v5-text-sec);font-size:.65rem;font-weight:600;font-family:inherit;cursor:pointer;
|
||||
display:inline-flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||
.v5-pager .pg-num:hover{background:var(--v5-surface-hover);color:var(--v5-text);}
|
||||
.v5-pager .pg-num.on{background:linear-gradient(135deg,var(--v5-primary),var(--v5-cyan));color:#fff;
|
||||
box-shadow:var(--v5-glow-sm);}
|
||||
.v5-pager .pg-num:disabled{opacity:.35;cursor:not-allowed;}
|
||||
|
||||
/* 다이얼로그 / 모달 */
|
||||
.v5-modal-back{position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.55);backdrop-filter:blur(4px);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
opacity:0;pointer-events:none;transition:opacity .25s;}
|
||||
.v5-modal-back.open{opacity:1;pointer-events:auto;}
|
||||
.v5-modal{width:520px;max-width:calc(100vw - 2rem);max-height:calc(100vh - 2rem);overflow:hidden;
|
||||
background:var(--v5-glass-strong);backdrop-filter:blur(28px) saturate(1.5);-webkit-backdrop-filter:blur(28px) saturate(1.5);
|
||||
border:1px solid var(--v5-glass-border);border-radius:18px;
|
||||
box-shadow:0 24px 80px rgba(0,0,0,.35),var(--v5-glow-md),inset 0 0 0 1px rgba(255,255,255,.18);
|
||||
display:flex;flex-direction:column;
|
||||
transform:translateY(20px) scale(.96);transition:transform .35s cubic-bezier(.16,1,.3,1);}
|
||||
.dark .v5-modal{box-shadow:0 24px 80px rgba(0,0,0,.7),var(--v5-glow-md),inset 0 0 0 1px rgba(162,155,254,.06);}
|
||||
.v5-modal-back.open .v5-modal{transform:none;}
|
||||
.v5-modal-hd{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.1rem .8rem;
|
||||
border-bottom:1px solid var(--v5-border-subtle);}
|
||||
.v5-modal-hd .mt-tt{font-size:.92rem;font-weight:800;color:var(--v5-text);letter-spacing:-.01em;}
|
||||
.v5-modal-hd .mt-sub{font-size:.62rem;color:var(--v5-text-muted);margin-top:.2rem;}
|
||||
.v5-modal-hd .mt-x{width:28px;height:28px;border-radius:8px;border:none;background:transparent;
|
||||
color:var(--v5-text-muted);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;}
|
||||
.v5-modal-hd .mt-x:hover{background:var(--v5-surface-hover);color:var(--v5-red);}
|
||||
.v5-modal-bd{padding:1rem 1.1rem;overflow-y:auto;display:flex;flex-direction:column;gap:.85rem;}
|
||||
.v5-modal-ft{display:flex;justify-content:flex-end;gap:.45rem;padding:.75rem 1.1rem;
|
||||
border-top:1px solid var(--v5-border-subtle);background:var(--v5-glass);}
|
||||
|
||||
/* form row */
|
||||
.v5-fld{display:flex;flex-direction:column;gap:.3rem;}
|
||||
.v5-fld .lbl{font-size:.58rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--v5-text-muted);}
|
||||
.v5-fld .lbl .req{color:var(--v5-red);margin-left:.15rem;}
|
||||
.v5-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;}
|
||||
|
||||
/* 스크롤바 */
|
||||
::-webkit-scrollbar{width:10px;height:10px;}
|
||||
::-webkit-scrollbar-track{background:transparent;}
|
||||
::-webkit-scrollbar-thumb{background:rgba(108,92,231,.18);border-radius:5px;}
|
||||
.dark ::-webkit-scrollbar-thumb{background:rgba(162,155,254,.18);}
|
||||
::-webkit-scrollbar-thumb:hover{background:rgba(108,92,231,.35);}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width:1100px){
|
||||
.v5-stats{grid-template-columns:repeat(2,1fr);}
|
||||
}
|
||||
@media (max-width:768px){
|
||||
.v5-side{display:none;}
|
||||
.v5-stats{grid-template-columns:1fr;}
|
||||
.v5-row{grid-template-columns:1fr;}
|
||||
.v5-page-hd{flex-direction:column;align-items:flex-start;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============================================================
|
||||
코스믹 배경 (별/성운/입자) — JS 가 별과 입자 동적 생성
|
||||
============================================================ -->
|
||||
<div class="v5-cosmos" id="cosmos">
|
||||
<div class="neb neb-1"></div>
|
||||
<div class="neb neb-2"></div>
|
||||
<div class="neb neb-3"></div>
|
||||
<div class="neb neb-4"></div>
|
||||
<div class="shooting-star" style="top:12%;left:70%"></div>
|
||||
<div class="shooting-star" style="top:35%;left:55%;animation-delay:3s"></div>
|
||||
</div>
|
||||
|
||||
<div class="v5-shell">
|
||||
|
||||
<!-- ========== HEADER ========== -->
|
||||
<header class="v5-hdr">
|
||||
<div class="v5-hdr-l">
|
||||
<div class="v5-hdr-logo">INVION</div>
|
||||
<div class="v5-hdr-bc">관리자 / 사용자 / <b>사용자 관리</b></div>
|
||||
<span class="v5-admin-badge">
|
||||
<span class="badge-dot"></span>
|
||||
ADMIN MODE
|
||||
</span>
|
||||
</div>
|
||||
<div class="v5-hdr-r">
|
||||
<div class="v5-pill" id="themePill">
|
||||
<button id="lightBtn">Light</button>
|
||||
<button id="darkBtn" class="on">Dark</button>
|
||||
</div>
|
||||
<button class="v5-bell" title="알림">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
<span class="v5-bell-dot"></span>
|
||||
</button>
|
||||
<button class="v5-admin-btn" title="관리자 모드">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1l3.09 6.26L22 8.27l-5 4.87 1.18 6.88L12 16.77l-6.18 3.25L7 13.14 2 8.27l6.91-1.01L12 1z"/></svg>
|
||||
</button>
|
||||
<div class="v5-avatar">GP</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ========== TABS ========== -->
|
||||
<div class="v5-tabs">
|
||||
<div class="v5-tab">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
대시보드
|
||||
<button class="v5-tab-x">×</button>
|
||||
</div>
|
||||
<div class="v5-tab on">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg>
|
||||
사용자 관리
|
||||
<button class="v5-tab-x">×</button>
|
||||
</div>
|
||||
<div class="v5-tab">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
||||
회사 / 부서
|
||||
<button class="v5-tab-x">×</button>
|
||||
</div>
|
||||
<div class="v5-tab">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9 1.65 1.65 0 0 0 4.27 7.18l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
시스템 설정
|
||||
<button class="v5-tab-x">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== BODY ========== -->
|
||||
<div class="v5-body">
|
||||
|
||||
<!-- ========== SIDEBAR ========== -->
|
||||
<aside class="v5-side">
|
||||
<div class="v5-side-sec">User Management</div>
|
||||
<div class="v5-si on" title="사용자 관리">
|
||||
<span class="ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg></span>
|
||||
사용자 관리
|
||||
<span class="badge">128</span>
|
||||
</div>
|
||||
<div class="v5-si" title="회사 관리">
|
||||
<span class="ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l8-4v18"/><path d="M19 21V11l-6-4"/></svg></span>
|
||||
회사 / 부서
|
||||
</div>
|
||||
<div class="v5-si" title="권한 관리">
|
||||
<span class="ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
|
||||
권한 / 역할
|
||||
</div>
|
||||
<div class="v5-si" title="사용자 인증">
|
||||
<span class="ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
사용자 인증
|
||||
</div>
|
||||
|
||||
<div class="v5-side-sec">System</div>
|
||||
<div class="v5-si" title="공통코드">
|
||||
<span class="ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></span>
|
||||
공통코드
|
||||
</div>
|
||||
<div class="v5-si" title="다국어">
|
||||
<span class="ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
|
||||
다국어 관리
|
||||
</div>
|
||||
<div class="v5-si" title="감사로그">
|
||||
<span class="ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
|
||||
감사 로그
|
||||
</div>
|
||||
<div class="v5-si" title="모니터링">
|
||||
<span class="ic"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></span>
|
||||
모니터링
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ========== CONTENT ========== -->
|
||||
<main class="v5-content">
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="v5-page-hd">
|
||||
<div class="pg-tt">
|
||||
<div class="pg-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1>사용자 관리</h1>
|
||||
<div class="pg-sub">전체 시스템 사용자 계정을 조회하고 관리합니다</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pg-act">
|
||||
<button class="v5-btn">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
엑셀 다운로드
|
||||
</button>
|
||||
<button class="v5-btn">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
업로드
|
||||
</button>
|
||||
<button class="v5-btn primary" id="openModalBtn">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
사용자 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 4개 -->
|
||||
<div class="v5-stats">
|
||||
<div class="v5-stat">
|
||||
<div class="lbl">전체 사용자</div>
|
||||
<div class="val">128</div>
|
||||
<span class="delta up">▲ 12 이번 주</span>
|
||||
<div class="ic-bg"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg></div>
|
||||
</div>
|
||||
<div class="v5-stat cyan">
|
||||
<div class="lbl">활성 사용자</div>
|
||||
<div class="val">112</div>
|
||||
<span class="delta up">▲ 87.5%</span>
|
||||
<div class="ic-bg"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>
|
||||
</div>
|
||||
<div class="v5-stat pink">
|
||||
<div class="lbl">오늘 접속</div>
|
||||
<div class="val">47</div>
|
||||
<span class="delta up">▲ 8 어제 대비</span>
|
||||
<div class="ic-bg"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div>
|
||||
</div>
|
||||
<div class="v5-stat amber">
|
||||
<div class="lbl">잠금 / 만료</div>
|
||||
<div class="val">3</div>
|
||||
<span class="delta down">▼ 1 어제 대비</span>
|
||||
<div class="ic-bg"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 카드 -->
|
||||
<div class="v5-card">
|
||||
<div class="v5-card-hd">
|
||||
<div class="card-tt"><span class="dot"></span> 검색 조건</div>
|
||||
<div class="card-meta">조건을 선택한 후 검색을 실행하세요</div>
|
||||
</div>
|
||||
<div class="v5-card-bd">
|
||||
<div class="v5-filter">
|
||||
<div class="v5-input-wrap grow">
|
||||
<span class="ic"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
|
||||
<input class="v5-input with-ic" placeholder="이름, 사번, 이메일로 검색..." style="width:100%" />
|
||||
</div>
|
||||
<select class="v5-select">
|
||||
<option>전체 회사</option>
|
||||
<option>인비온</option>
|
||||
<option>인비온 R&D</option>
|
||||
<option>인비온 글로벌</option>
|
||||
</select>
|
||||
<select class="v5-select">
|
||||
<option>전체 부서</option>
|
||||
<option>경영지원</option>
|
||||
<option>개발팀</option>
|
||||
<option>영업팀</option>
|
||||
<option>품질관리</option>
|
||||
</select>
|
||||
<select class="v5-select">
|
||||
<option>전체 권한</option>
|
||||
<option>최고관리자</option>
|
||||
<option>관리자</option>
|
||||
<option>일반사용자</option>
|
||||
</select>
|
||||
<select class="v5-select">
|
||||
<option>전체 상태</option>
|
||||
<option>활성</option>
|
||||
<option>비활성</option>
|
||||
<option>잠금</option>
|
||||
</select>
|
||||
<button class="v5-btn primary">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
검색
|
||||
</button>
|
||||
<button class="v5-btn ghost">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 데이터 테이블 카드 -->
|
||||
<div class="v5-card">
|
||||
<div class="v5-card-hd">
|
||||
<div class="card-tt"><span class="dot"></span> 사용자 목록</div>
|
||||
<div class="card-meta">총 <b style="color:var(--v5-text)">128</b>건 · 페이지 <b style="color:var(--v5-text)">1 / 13</b></div>
|
||||
</div>
|
||||
|
||||
<div class="v5-table-wrap">
|
||||
<table class="v5-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="center" style="width:40px"><span class="v5-chk"></span></th>
|
||||
<th class="sortable">사용자 <span class="sort-ic">↕</span></th>
|
||||
<th class="sortable">사번 <span class="sort-ic">↕</span></th>
|
||||
<th>회사 / 부서</th>
|
||||
<th>직위</th>
|
||||
<th>권한</th>
|
||||
<th>이메일</th>
|
||||
<th class="center">상태</th>
|
||||
<th class="sortable">최근 접속 <span class="sort-ic">↕</span></th>
|
||||
<th class="center" style="width:140px">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- row 1 -->
|
||||
<tr class="selected">
|
||||
<td class="center"><span class="v5-chk on"></span></td>
|
||||
<td>
|
||||
<div class="v5-user-cell">
|
||||
<div class="av">박</div>
|
||||
<div>
|
||||
<div class="name">박건배</div>
|
||||
<div class="meta">@gbpark</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>EMP-0001</td>
|
||||
<td>인비온 / 개발팀</td>
|
||||
<td>팀장</td>
|
||||
<td><span class="v5-bdg info"><span class="dot"></span>최고관리자</span></td>
|
||||
<td>puget159@gmail.com</td>
|
||||
<td class="center"><span class="v5-bdg ok"><span class="dot"></span>활성</span></td>
|
||||
<td>2분 전</td>
|
||||
<td class="center actions">
|
||||
<button class="v5-btn icon sm" title="보기"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="v5-btn icon sm" title="수정"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button class="v5-btn icon sm danger" title="삭제"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- row 2 -->
|
||||
<tr>
|
||||
<td class="center"><span class="v5-chk"></span></td>
|
||||
<td>
|
||||
<div class="v5-user-cell">
|
||||
<div class="av cyan">김</div>
|
||||
<div>
|
||||
<div class="name">김민지</div>
|
||||
<div class="meta">@minji.kim</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>EMP-0002</td>
|
||||
<td>인비온 / 영업팀</td>
|
||||
<td>대리</td>
|
||||
<td><span class="v5-bdg info"><span class="dot"></span>관리자</span></td>
|
||||
<td>minji.kim@invion.co.kr</td>
|
||||
<td class="center"><span class="v5-bdg ok"><span class="dot"></span>활성</span></td>
|
||||
<td>15분 전</td>
|
||||
<td class="center actions">
|
||||
<button class="v5-btn icon sm" title="보기"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="v5-btn icon sm" title="수정"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button class="v5-btn icon sm danger" title="삭제"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- row 3 -->
|
||||
<tr>
|
||||
<td class="center"><span class="v5-chk"></span></td>
|
||||
<td>
|
||||
<div class="v5-user-cell">
|
||||
<div class="av pink">이</div>
|
||||
<div>
|
||||
<div class="name">이지호</div>
|
||||
<div class="meta">@jiho.lee</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>EMP-0003</td>
|
||||
<td>인비온 R&D / 연구1팀</td>
|
||||
<td>주임</td>
|
||||
<td><span class="v5-bdg muted"><span class="dot"></span>일반사용자</span></td>
|
||||
<td>jiho.lee@invion.co.kr</td>
|
||||
<td class="center"><span class="v5-bdg warn"><span class="dot"></span>잠금</span></td>
|
||||
<td>3일 전</td>
|
||||
<td class="center actions">
|
||||
<button class="v5-btn icon sm" title="보기"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="v5-btn icon sm" title="수정"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button class="v5-btn icon sm danger" title="삭제"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- row 4 -->
|
||||
<tr>
|
||||
<td class="center"><span class="v5-chk"></span></td>
|
||||
<td>
|
||||
<div class="v5-user-cell">
|
||||
<div class="av amber">최</div>
|
||||
<div>
|
||||
<div class="name">최서연</div>
|
||||
<div class="meta">@seoyeon.choi</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>EMP-0004</td>
|
||||
<td>인비온 글로벌 / 해외영업</td>
|
||||
<td>과장</td>
|
||||
<td><span class="v5-bdg info"><span class="dot"></span>관리자</span></td>
|
||||
<td>seoyeon.choi@invion.global</td>
|
||||
<td class="center"><span class="v5-bdg ok"><span class="dot"></span>활성</span></td>
|
||||
<td>1시간 전</td>
|
||||
<td class="center actions">
|
||||
<button class="v5-btn icon sm" title="보기"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="v5-btn icon sm" title="수정"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button class="v5-btn icon sm danger" title="삭제"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- row 5 -->
|
||||
<tr>
|
||||
<td class="center"><span class="v5-chk"></span></td>
|
||||
<td>
|
||||
<div class="v5-user-cell">
|
||||
<div class="av">정</div>
|
||||
<div>
|
||||
<div class="name">정현우</div>
|
||||
<div class="meta">@hyunwoo.jung</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>EMP-0005</td>
|
||||
<td>인비온 / 품질관리</td>
|
||||
<td>사원</td>
|
||||
<td><span class="v5-bdg muted"><span class="dot"></span>일반사용자</span></td>
|
||||
<td>hyunwoo@invion.co.kr</td>
|
||||
<td class="center"><span class="v5-bdg err"><span class="dot"></span>비활성</span></td>
|
||||
<td>15일 전</td>
|
||||
<td class="center actions">
|
||||
<button class="v5-btn icon sm" title="보기"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="v5-btn icon sm" title="수정"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button class="v5-btn icon sm danger" title="삭제"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- row 6 -->
|
||||
<tr>
|
||||
<td class="center"><span class="v5-chk"></span></td>
|
||||
<td>
|
||||
<div class="v5-user-cell">
|
||||
<div class="av cyan">한</div>
|
||||
<div>
|
||||
<div class="name">한지민</div>
|
||||
<div class="meta">@jimin.han</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>EMP-0006</td>
|
||||
<td>인비온 / 경영지원</td>
|
||||
<td>부장</td>
|
||||
<td><span class="v5-bdg info"><span class="dot"></span>관리자</span></td>
|
||||
<td>jimin.han@invion.co.kr</td>
|
||||
<td class="center"><span class="v5-bdg ok"><span class="dot"></span>활성</span></td>
|
||||
<td>방금</td>
|
||||
<td class="center actions">
|
||||
<button class="v5-btn icon sm" title="보기"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="v5-btn icon sm" title="수정"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
<button class="v5-btn icon sm danger" title="삭제"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="v5-pager">
|
||||
<div class="pg-info">
|
||||
<span>페이지당</span>
|
||||
<select class="v5-select" style="height:28px;font-size:.62rem;padding:0 1.6rem 0 .55rem">
|
||||
<option>10</option><option selected>20</option><option>50</option><option>100</option>
|
||||
</select>
|
||||
<span>1 - 20 / 128 건</span>
|
||||
</div>
|
||||
<div class="pg-nums">
|
||||
<button class="pg-num" disabled>‹‹</button>
|
||||
<button class="pg-num" disabled>‹</button>
|
||||
<button class="pg-num on">1</button>
|
||||
<button class="pg-num">2</button>
|
||||
<button class="pg-num">3</button>
|
||||
<button class="pg-num">4</button>
|
||||
<button class="pg-num">5</button>
|
||||
<button class="pg-num">…</button>
|
||||
<button class="pg-num">13</button>
|
||||
<button class="pg-num">›</button>
|
||||
<button class="pg-num">››</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
모달 (사용자 추가/편집) — 사용자 추가 버튼 클릭시 열림
|
||||
============================================================ -->
|
||||
<div class="v5-modal-back" id="modalBack">
|
||||
<div class="v5-modal">
|
||||
<div class="v5-modal-hd">
|
||||
<div>
|
||||
<div class="mt-tt">사용자 추가</div>
|
||||
<div class="mt-sub">새로운 사용자 계정을 생성합니다</div>
|
||||
</div>
|
||||
<button class="mt-x" id="closeModalBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="v5-modal-bd">
|
||||
<div class="v5-row">
|
||||
<div class="v5-fld">
|
||||
<div class="lbl">사번<span class="req">*</span></div>
|
||||
<input class="v5-input" placeholder="EMP-0000" />
|
||||
</div>
|
||||
<div class="v5-fld">
|
||||
<div class="lbl">이름<span class="req">*</span></div>
|
||||
<input class="v5-input" placeholder="홍길동" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v5-row">
|
||||
<div class="v5-fld">
|
||||
<div class="lbl">아이디<span class="req">*</span></div>
|
||||
<input class="v5-input" placeholder="user.id" />
|
||||
</div>
|
||||
<div class="v5-fld">
|
||||
<div class="lbl">초기 비밀번호<span class="req">*</span></div>
|
||||
<input class="v5-input" type="password" placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v5-fld">
|
||||
<div class="lbl">이메일</div>
|
||||
<input class="v5-input" type="email" placeholder="user@invion.co.kr" />
|
||||
</div>
|
||||
|
||||
<div class="v5-row">
|
||||
<div class="v5-fld">
|
||||
<div class="lbl">회사<span class="req">*</span></div>
|
||||
<select class="v5-select">
|
||||
<option>인비온</option>
|
||||
<option>인비온 R&D</option>
|
||||
<option>인비온 글로벌</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="v5-fld">
|
||||
<div class="lbl">부서<span class="req">*</span></div>
|
||||
<select class="v5-select">
|
||||
<option>경영지원</option>
|
||||
<option>개발팀</option>
|
||||
<option>영업팀</option>
|
||||
<option>품질관리</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v5-row">
|
||||
<div class="v5-fld">
|
||||
<div class="lbl">직위</div>
|
||||
<input class="v5-input" placeholder="사원, 대리, 과장 등" />
|
||||
</div>
|
||||
<div class="v5-fld">
|
||||
<div class="lbl">권한 그룹<span class="req">*</span></div>
|
||||
<select class="v5-select">
|
||||
<option>일반사용자</option>
|
||||
<option>관리자</option>
|
||||
<option>최고관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="v5-modal-ft">
|
||||
<button class="v5-btn ghost" id="cancelBtn">취소</button>
|
||||
<button class="v5-btn primary">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ============================================================
|
||||
인터랙션 — 라이트/다크 토글, 별 생성, 모달 토글, 체크박스
|
||||
============================================================ */
|
||||
|
||||
// 별 + 입자 생성 (다크 모드 전용)
|
||||
(function spawnCosmos(){
|
||||
const cosmos = document.getElementById('cosmos');
|
||||
const colors = ['rgba(162,155,254,.8)','rgba(85,239,196,.7)','rgba(253,121,168,.7)'];
|
||||
for(let i=0;i<140;i++){
|
||||
const s = document.createElement('div');
|
||||
s.className = 'star' + (Math.random()>.83 ? ' c' : '');
|
||||
if(s.classList.contains('c')) s.style.setProperty('--sc', colors[(Math.random()*3)|0]);
|
||||
s.style.left = Math.random()*100 + '%';
|
||||
s.style.top = Math.random()*100 + '%';
|
||||
s.style.setProperty('--d', (2 + Math.random()*5) + 's');
|
||||
s.style.setProperty('--dl', (Math.random()*5) + 's');
|
||||
s.style.setProperty('--mo', (.3 + Math.random()*.7) + '');
|
||||
cosmos.appendChild(s);
|
||||
}
|
||||
const pcs = ['var(--v5-primary)','var(--v5-cyan)','var(--v5-pink)'];
|
||||
for(let i=0;i<22;i++){
|
||||
const p = document.createElement('div');
|
||||
p.className = 'particle';
|
||||
p.style.left = Math.random()*100 + '%';
|
||||
p.style.setProperty('--sz', (2 + Math.random()*4) + 'px');
|
||||
p.style.setProperty('--pc', pcs[(Math.random()*3)|0]);
|
||||
p.style.setProperty('--fd', (7 + Math.random()*12) + 's');
|
||||
p.style.setProperty('--fdl',(Math.random()*10) + 's');
|
||||
cosmos.appendChild(p);
|
||||
}
|
||||
})();
|
||||
|
||||
// 테마 토글
|
||||
const lightBtn = document.getElementById('lightBtn');
|
||||
const darkBtn = document.getElementById('darkBtn');
|
||||
function setTheme(t){
|
||||
document.documentElement.classList.toggle('dark', t==='dark');
|
||||
lightBtn.classList.toggle('on', t==='light');
|
||||
darkBtn .classList.toggle('on', t==='dark');
|
||||
}
|
||||
lightBtn.addEventListener('click', () => setTheme('light'));
|
||||
darkBtn .addEventListener('click', () => setTheme('dark'));
|
||||
|
||||
// 모달 토글
|
||||
const modalBack = document.getElementById('modalBack');
|
||||
document.getElementById('openModalBtn' ).addEventListener('click', () => modalBack.classList.add('open'));
|
||||
document.getElementById('closeModalBtn').addEventListener('click', () => modalBack.classList.remove('open'));
|
||||
document.getElementById('cancelBtn' ).addEventListener('click', () => modalBack.classList.remove('open'));
|
||||
modalBack.addEventListener('click', (e) => { if(e.target === modalBack) modalBack.classList.remove('open'); });
|
||||
|
||||
// 체크박스 토글
|
||||
document.querySelectorAll('.v5-chk').forEach(chk => {
|
||||
chk.addEventListener('click', () => chk.classList.toggle('on'));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
# 인증 / 로그인 / 보안 리팩토링 감사 리포트
|
||||
|
||||
- **작성자:** gbpark
|
||||
- **작성일:** 2026-04-08
|
||||
- **범위:** v2 컴포넌트 영역 (`frontend/components/v2`, `frontend/lib/v2-core`, `frontend/lib/registry/components/v2-*`) **제외** 한 인증·로그인·보안 스택
|
||||
- **결론:** 리팩토링 필요. 일부 항목은 보안 패치 수준으로 시급함.
|
||||
|
||||
---
|
||||
|
||||
## 점검 대상 파일
|
||||
|
||||
| 영역 | 파일 |
|
||||
|---|---|
|
||||
| Frontend 로그인 페이지 | `frontend/app/(auth)/login/page.tsx` |
|
||||
| Frontend 로그인 훅 | `frontend/hooks/useLogin.ts` |
|
||||
| Frontend 인증 훅 | `frontend/hooks/useAuth.ts` |
|
||||
| Frontend API 클라이언트 (토큰/인터셉터) | `frontend/lib/api/client.ts` |
|
||||
| Frontend 미들웨어 | `frontend/middleware.ts` |
|
||||
| Backend 보안 설정 | `backend-spring/src/main/java/com/erp/security/SecurityConfig.java` |
|
||||
| Backend JWT 발급/검증 | `backend-spring/.../security/JwtTokenProvider.java` |
|
||||
| Backend JWT 필터 | `backend-spring/.../security/JwtAuthenticationFilter.java` |
|
||||
| Backend 인증 컨트롤러 | `backend-spring/.../controller/AuthController.java` |
|
||||
| Backend 인증 서비스 (386줄) | `backend-spring/.../service/AuthService.java` |
|
||||
| Backend 환경설정 (JWT secret) | `backend-spring/src/main/resources/application.yml` |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 시급 (보안 결함)
|
||||
|
||||
### 1. JWT secret 평문 커밋
|
||||
**파일:** `backend-spring/src/main/resources/application.yml:35`
|
||||
```yaml
|
||||
jwt:
|
||||
secret: ilshin-plm-super-secret-jwt-key-2024
|
||||
expiration: 86400000
|
||||
```
|
||||
git 에 그대로 올라가 있음. 누구든 토큰 위조 가능.
|
||||
**조치:** 환경변수 / Vault 로 즉시 분리. `${JWT_SECRET}` 형태로 외부 주입.
|
||||
|
||||
### 2. 마스터 패스워드 하드코딩
|
||||
**파일:** `backend-spring/.../service/AuthService.java:29`
|
||||
```java
|
||||
@Value("${auth.master-password:qlalfqjsgh11}")
|
||||
private String masterPassword;
|
||||
```
|
||||
`AuthService.java:52` 의 `masterPassword.equals(password)` 한 줄로 **모든 사용자 계정에 임의 로그인 가능**. application.yml 에 override 도 없어서 디폴트가 그대로 활성.
|
||||
**조치:** 운영 환경에서 즉시 제거. 디버깅이 필요하면 별도 admin impersonation 엔드포인트 + 감사 로그 + 권한 체크로 분리.
|
||||
|
||||
### 3. MD5 패스워드 해싱
|
||||
**파일:** `AuthService.java:55, 359`
|
||||
```java
|
||||
} else if (storedPw != null && md5(password).equals(storedPw)) {
|
||||
```
|
||||
회원가입도 MD5 저장. SecurityConfig 에 `BCryptPasswordEncoder` Bean 은 등록만 해놨지 **사용처 0건**.
|
||||
**조치:** BCrypt 또는 Argon2 마이그레이션. 점진 재해시 (로그인 성공 시 BCrypt 로 재저장) 로직 도입.
|
||||
|
||||
### 4. SecurityConfig 가 모든 API 무방비
|
||||
**파일:** `SecurityConfig.java:34`
|
||||
```java
|
||||
.requestMatchers("/api/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
```
|
||||
**전체 API 가 permitAll**. JwtAuthenticationFilter 는 SecurityContext 채워주는 단순 정보 전달자에 불과하고, 실제 보호는 컨트롤러 안에서 `validateToken()` 호출로만 됨. 컨트롤러에서 직접 검증하지 않는 엔드포인트는 토큰 없이 호출해도 통과될 위험.
|
||||
**조치:** `permitAll` 을 `/api/auth/login`, `/api/auth/signup`, `/api/auth/refresh` 정도로 한정하고 나머지는 `authenticated()` 로 변경.
|
||||
|
||||
### 5. CORS 와일드카드 + Credentials 동시 허용
|
||||
**파일:** `SecurityConfig.java:51`
|
||||
```java
|
||||
config.setAllowedOriginPatterns(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
```
|
||||
`*` + `allowCredentials=true` 조합은 모든 출처에서 쿠키 포함 요청을 허용.
|
||||
**조치:** 운영용 화이트리스트 (`v1.invion.com`, 사내 IP 등) 로 좁힐 것.
|
||||
|
||||
### 6. JWT 를 localStorage 저장 + 비-HttpOnly 쿠키 중복
|
||||
**파일:** `frontend/lib/api/client.ts:83-87`, `frontend/hooks/useAuth.ts:58-64`
|
||||
```js
|
||||
localStorage.setItem("authToken", token);
|
||||
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
```
|
||||
- `localStorage` → XSS 한 방에 토큰 탈취
|
||||
- 쿠키도 **HttpOnly 미설정 + Secure 미설정 + SameSite=Lax**
|
||||
- 미들웨어는 쿠키만 읽고 훅은 localStorage 만 읽어 **동일 토큰을 두 곳에 중복 저장**, 정합성 깨질 여지 큼
|
||||
|
||||
**조치:** HttpOnly + Secure + SameSite=Strict 쿠키 단일 소스. 프론트가 토큰을 직접 만지지 않게 변경.
|
||||
|
||||
### 7. 사용자 enumeration
|
||||
**파일:** `AuthService.java:48-60`
|
||||
- 사용자 없음 → "사용자가 존재하지 않습니다."
|
||||
- 비번 틀림 → "패스워드가 일치하지 않습니다."
|
||||
|
||||
ID 존재 여부가 메시지로 노출됨.
|
||||
**조치:** 통일 메시지 ("아이디 또는 비밀번호를 확인해주세요") 로 변경.
|
||||
|
||||
### 8. 로그인 시도 제한 / 락아웃 없음
|
||||
brute-force 무방비. `auth.insertLoginLog` 로 실패 기록은 남기는데 카운터/잠금은 없음.
|
||||
**조치:** IP/계정별 카운터 + N회 이상 실패 시 일시 잠금 (예: Redis or DB 카운터).
|
||||
|
||||
---
|
||||
|
||||
## 🟡 중요 (구조 / 일관성 결함)
|
||||
|
||||
### 9. `useAuth.login()` 의 필드명 버그 (죽은 코드 가능성)
|
||||
**파일:** `frontend/hooks/useAuth.ts:268-271`
|
||||
```ts
|
||||
const response = await apiCall<any>("POST", "/auth/login", {
|
||||
userId, // ❌ 백엔드는 user_id 를 기대
|
||||
password,
|
||||
});
|
||||
```
|
||||
백엔드 `AuthController.login()` 은 `body.get("user_id")` (`AuthController.java:32`) 로 받음. **이 훅으로 로그인하면 항상 실패**. 실제 로그인 페이지는 `useLogin` 만 쓰지만, 훅 자체가 죽은 코드 또는 잠재 버그.
|
||||
**조치:** `useAuth.login()` 제거 or 필드명 수정. `useLogin` 과 통합 검토.
|
||||
|
||||
### 10. TokenManager 가 두 곳에 중복 정의
|
||||
- `frontend/lib/api/client.ts:75-127`
|
||||
- `frontend/hooks/useAuth.ts:50-81`
|
||||
|
||||
거의 동일한 로직을 두 번 작성. DRY 위반.
|
||||
**조치:** `lib/auth/tokenManager.ts` 로 단일화.
|
||||
|
||||
### 11. `useAuth.refreshUserData()` 의 "임시 인증 유지" fallback
|
||||
**파일:** `frontend/hooks/useAuth.ts:204-248`
|
||||
|
||||
`/auth/me` 가 실패하면 **클라이언트에서 토큰 페이로드를 디코드해서 그대로 신뢰**. 백엔드가 401 을 줘도 프론트는 "로그인 상태"로 우김. 디버깅용 안전장치치곤 위험.
|
||||
**조치:** 운영에서는 제거 또는 명확한 "오프라인 모드" 분기로 이동.
|
||||
|
||||
### 12. `getUserInfo()` 의 중복 put 버그
|
||||
**파일:** `AuthService.java:246-247`
|
||||
```java
|
||||
result.put("company_code", companyCode);
|
||||
result.put("company_code", companyCode); // ← 같은 키 두 번 — 원래 다른 키였을 듯
|
||||
```
|
||||
**조치:** 의도 확인 후 수정. (예: 두번째 줄이 `is_admin` 또는 다른 키였을 가능성)
|
||||
|
||||
### 13. Refresh token 모델 부재
|
||||
**파일:** `AuthService.java:191`
|
||||
|
||||
`refreshToken()` 이 같은 access token 으로 새 access token 발급. **Refresh token 분리 / 회전 / revoke 없음**. 토큰 탈취 시 만료까지 계속 갱신 가능.
|
||||
**조치:** JTI + 블랙리스트 또는 별도 refresh token 도입.
|
||||
|
||||
### 14. AuthService 386줄 단일 책임 위반
|
||||
login / refresh / status / switchCompany / signup / logout / firstMenuPath 계산 / popLandingPath 계산 모두 한 클래스.
|
||||
**조치:** 메뉴 경로 계산 → `MenuService`, signup → `DriverSignupService` 분리.
|
||||
|
||||
### 15. 미들웨어 보호 경로가 `/admin` 한 곳뿐
|
||||
**파일:** `frontend/middleware.ts:30`
|
||||
|
||||
```ts
|
||||
const strictProtectedPaths = ["/admin"];
|
||||
```
|
||||
나머지는 전부 클라이언트 AuthGuard 에 위임. 페이지가 한 번 렌더된 후 클라이언트에서 리다이렉트되는 깜빡임 발생 + 서버 사이드 보호 거의 없음. localStorage 정책 유지하는 한 어쩔 수 없는 한계인데, **HttpOnly 쿠키로 옮기면 미들웨어에서 전체 경로 보호 가능**.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 자잘한 정리
|
||||
|
||||
- **`frontend/lib/api/client.ts:14-36`** — API base URL 결정 로직이 환경변수 + hostname 분기 + 포트 분기로 길어짐. hostname 하드코딩 (`v1.invion.com`) 은 환경변수로 분리 권장.
|
||||
- **`useAuth.ts:196`, `AuthService.java:273`** — `"plm_admin" === userId` 식의 매직 ID 가 프론트/백 양쪽에 박힘. role 기반으로 일원화.
|
||||
- **`JwtAuthenticationFilter`** — 토큰 만료/위조 시 401 응답 없이 그냥 다음 필터로 넘김 (#4 결함과 맞물려 동작 불일치).
|
||||
- **`frontend/hooks/useLogin.ts:81-95`** — `checkExistingAuth` 가 401 받으면 인터셉터의 자동 redirectToLogin 과 충돌 여지. 현재는 `pathname === "/login"` 가드로 막혀있긴 함.
|
||||
|
||||
---
|
||||
|
||||
## 권장 우선순위
|
||||
|
||||
| 단계 | 항목 | 비고 |
|
||||
|---|---|---|
|
||||
| **즉시** | #1 JWT secret 환경변수화 | 운영 토큰 위조 차단 |
|
||||
| **즉시** | #2 마스터 패스워드 제거 | 백도어 차단 |
|
||||
| **즉시** | #3 MD5 → BCrypt 마이그레이션 | 점진 재해시 |
|
||||
| **즉시** | #4 SecurityConfig permitAll 좁히기 | 실제 보호 활성화 |
|
||||
| **즉시** | #5 CORS 화이트리스트 | |
|
||||
| **단기** | #6 HttpOnly 쿠키 단일 소스 | + #10 TokenManager 통합 + #15 미들웨어 확장 |
|
||||
| **단기** | #7 사용자 enumeration 메시지 통일 | |
|
||||
| **단기** | #8 로그인 락아웃 | |
|
||||
| **단기** | #9 useAuth.login 필드명 버그 | 또는 죽은 코드 제거 |
|
||||
| **단기** | #12 getUserInfo 중복 put 버그 | |
|
||||
| **중기** | #11 임시 인증 fallback 정리 | |
|
||||
| **중기** | #13 Refresh token 분리 | |
|
||||
| **중기** | #14 AuthService 책임 분할 | |
|
||||
|
||||
---
|
||||
|
||||
## 종합
|
||||
|
||||
전체적으로 **"동작은 하지만 보안 모델이 절반만 완성된" 상태**.
|
||||
특히 #1 ~ #5 는 보안 사고로 직결되는 항목이라 v2 작업과 무관하게 별도 리팩토링 트랙으로 빼서 처리하는 걸 권장함.
|
||||
@@ -0,0 +1,181 @@
|
||||
# 인증 / 로그인 / 보안 리팩토링 — 처리 결과
|
||||
|
||||
- **작성자:** gbpark (Claude 가 직접 처리)
|
||||
- **작성일:** 2026-04-08
|
||||
- **기반 감사 리포트:** [`2026-04-08-auth-security-audit.md`](./2026-04-08-auth-security-audit.md)
|
||||
- **검증:** 백엔드 `./gradlew compileJava` + `./gradlew bootJar` ✅, 프론트 `npx tsc --noEmit` 내 변경 파일 0건 신규 에러
|
||||
|
||||
---
|
||||
|
||||
## 처리 완료 (Phase 1 + 2)
|
||||
|
||||
### 🔴 시급 — 보안 결함
|
||||
|
||||
| # | 항목 | 변경 파일 | 처리 내용 |
|
||||
|---|---|---|---|
|
||||
| 1 | JWT secret 환경변수화 | `application.yml`, `docker-compose.invyone.yml`, `docker/dev/.env`(신규), `docker/dev/.env.example`(신규) | `${JWT_SECRET}` 환경변수 필수, 디폴트 제거 (미지정 시 앱 기동 실패 = 의도된 동작). 새 64자 random base64 secret 생성. dev `.env` 는 syncthing 동기화로 3대 머신 자동 전파, git 추적 제외 |
|
||||
| 2 | 마스터 패스워드 백도어 제거 | `AuthService.java` | `masterPassword` 필드 + `masterPassword.equals(password)` 검증 로직 완전 삭제. 어떤 환경에서도 마스터 비번 로그인 불가능 |
|
||||
| 3 | MD5 → BCrypt 점진 재해시 | `PasswordHasher.java`(신규), `AuthService.java`, `auth.xml` | `PasswordHasher` 유틸이 BCrypt prefix 자동 식별 → BCrypt/MD5 양방향 검증. **로그인 성공 시 MD5 였으면 자동으로 BCrypt 로 재저장**. 신규 회원가입은 BCrypt 강제. 새 mapper 쿼리 `auth.updateUserPassword` 추가 |
|
||||
| 5 | CORS 화이트리스트 | `SecurityConfig.java`, `application.yml`, `docker-compose.invyone.yml` | `setAllowedOriginPatterns("*")` → `setAllowedOrigins(env)`. `${CORS_ALLOWED_ORIGINS}` 환경변수 콤마 구분 문자열에서 화이트리스트 로드. dev 디폴트는 localhost + Tailscale IP |
|
||||
| 7 | User enumeration 메시지 통일 | `AuthService.java` | "사용자가 존재하지 않습니다" / "패스워드가 일치하지 않습니다" → 모두 `"아이디 또는 비밀번호를 확인해주세요."` 통일 상수. 내부 로그용 `internalErrorReason` 은 구분 유지 |
|
||||
| 8 | 로그인 락아웃 | `AuthService.java`, `auth.xml` | 기존 `LOGIN_ACCESS_LOG` 테이블 활용 (별도 저장소 없음). 로그인 시도 직전 `auth.countRecentLoginFailures` 로 최근 5분 내 실패 건수 조회 → **5건 이상이면 비번 검증 전에 차단**. 락아웃 차단도 별도 `recordLoginAttempt` 로 로그 |
|
||||
|
||||
### 🟡 중요 — 구조 / 일관성
|
||||
|
||||
| # | 항목 | 변경 파일 | 처리 내용 |
|
||||
|---|---|---|---|
|
||||
| 9 | useAuth.login() 죽은 코드 제거 | `useAuth.ts` | grep 으로 호출처 0건 확인. `login()` 메서드, `LoginResult` 인터페이스, return 객체의 `login` export 모두 제거. 실제 로그인은 `useLogin` 훅 단독 |
|
||||
| 10 | TokenManager 단일화 | `lib/auth/tokenManager.ts`(신규), `client.ts`, `useAuth.ts` | 두 곳에 중복되어 있던 TokenManager 를 신규 모듈로 추출. 양쪽에서 import. SSR 가드 + 만료 판단 + 만료 임박 판단 모두 통일 |
|
||||
| 11 | 임시 인증 fallback 제거 | `useAuth.ts` | `/auth/me` 실패 시 클라이언트에서 토큰 페이로드 디코드해 그대로 신뢰하던 두 곳의 fallback 제거. 백엔드가 401 주면 즉시 비인증 전환 + 토큰 삭제 |
|
||||
| 12 | getUserInfo 중복 put 버그 | `AuthService.java` | `result.put("company_code", companyCode)` 가 두 번 박혀 있던 것 한 줄 삭제. git blame 으로 snake_case 컨버팅 라운드(b7f9e51d62) 의 단순 실수 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 미처리 (Phase 3 — 별도 트랙 필요)
|
||||
|
||||
다음 4건은 **회귀 위험 / 큰 설계 결정 / 보안 결함이 아닌 이유**로 이번 작업에서 제외했습니다.
|
||||
|
||||
### #4 SecurityConfig permitAll 좁히기 — 회귀 위험
|
||||
|
||||
- 95개 컨트롤러 중 자체 토큰 검증을 하는 건 `AuthController` 단 한 곳뿐
|
||||
- `permitAll` 을 걷어내면 나머지 94개가 일제히 인증 강제됨
|
||||
- 그 중 public 으로 열려 있어야 하는 게 없는지 (헬스체크, 이미지 다운로드, 외부 콜백 등) **컨트롤러별 감사 선행 필요**
|
||||
- 단계적 처리 권장:
|
||||
1. 컨트롤러별 사용 패턴 감사 (1차로 token attribute 사용 여부)
|
||||
2. public 후보 (`/api/auth/login`, `/api/auth/signup`, `/api/auth/refresh`, 헬스체크 등) 명시
|
||||
3. `requestMatchers(...).permitAll().anyRequest().authenticated()` 로 변경
|
||||
4. 핵심 엔드포인트 회귀 테스트 (curl)
|
||||
|
||||
### #6 HttpOnly 쿠키 단일 소스 — 광범위 설계 변경
|
||||
|
||||
- 영향 범위: 백엔드 응답 (Set-Cookie), 프론트 토큰 핸들링 (TokenManager 변경), 미들웨어 보호 경로 확장, refresh 로직, 인터셉터, 모든 API 호출 인증 헤더 제거
|
||||
- XSS 방어 효과는 명확하지만 **단일 트랙으로 처리해야 안전**
|
||||
- 별도 PR 로 진행 권장
|
||||
|
||||
### #13 Refresh token 분리 — 큰 설계 변경
|
||||
|
||||
- 현재 access token 으로 새 access token 발급 (자체 갱신)
|
||||
- 진짜 refresh token 도입하려면: 별도 token 발급 + 저장 (DB or Redis) + 회전 + revoke + 블랙리스트
|
||||
- 토큰 탈취 시나리오 방어에는 필수지만 **#6 과 함께 묶어서 진행**하는 게 효율적
|
||||
|
||||
### #14 AuthService 책임 분할 — 보안 결함 아님
|
||||
|
||||
- 386줄 → 5~6 파일 분할 (Login / Token / Company / Signup / MenuPathResolver)
|
||||
- **코드 품질 이슈**라 보안 패치 트랙에서 제외
|
||||
- 보안 패치 안정화 후 별도 리팩토링 트랙으로
|
||||
|
||||
---
|
||||
|
||||
## 사용자 액션 필요 (★ 운영 적용 전 필수)
|
||||
|
||||
### A. DB 컬럼 길이 확인
|
||||
|
||||
`USER_INFO.USER_PASSWORD` 가 BCrypt 해시(60자) 가 들어갈 수 있는 길이인지 확인 필요.
|
||||
|
||||
```sql
|
||||
-- 현재 컬럼 정보 확인
|
||||
SELECT column_name, data_type, character_maximum_length
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'user_info' AND column_name = 'user_password';
|
||||
```
|
||||
|
||||
- 만약 `VARCHAR(32)` 같이 짧으면 → `ALTER TABLE USER_INFO ALTER COLUMN USER_PASSWORD TYPE VARCHAR(72);` 필요 (BCrypt 60자 + 여유)
|
||||
- `VARCHAR(64)` 이상이면 OK
|
||||
- 컬럼이 짧은 상태에서 점진 재해시 시도하면 update 실패 → log warn 만 남고 다음 로그인에 다시 시도. 기능은 안 깨지지만 재해시가 영영 안 됨
|
||||
|
||||
### B. 새 JWT secret 적용 후 강제 재로그인 발생
|
||||
|
||||
- secret 이 바뀌면 기존 발급된 토큰은 전부 invalid → **운영 사용자 전원 강제 재로그인**
|
||||
- dev 환경에서는 즉시 적용해도 되지만, 운영 적용 시에는 사용자 공지 + 점심시간 같은 저트래픽 시간대 권장
|
||||
|
||||
### C. 운영 환경 도커 컴포즈 / .env 별도 작성 필요
|
||||
|
||||
- 현재는 **dev 도커** (`docker/dev/.env`) 만 작성
|
||||
- 운영 도커 컴포즈 (`docker/prod/...`) 가 있다면 거기에도 동일한 패턴으로 `.env` + secret + CORS 화이트리스트 적용 필요
|
||||
- 운영 CORS 화이트리스트: `https://v1.invion.com,https://api.invion.com` 식으로 변경
|
||||
|
||||
### D. 마스터 패스워드 의존성 확인
|
||||
|
||||
- 코드에서는 완전 제거됨
|
||||
- 만약 어떤 운영 워크플로우가 마스터 패스워드에 의존하고 있었다면 그 워크플로우는 깨짐
|
||||
- 대안: 정식 admin impersonation 엔드포인트 + 감사 로그 + 권한 체크로 분리해서 새로 만들기 (필요 시)
|
||||
|
||||
---
|
||||
|
||||
## 신규 / 변경 파일 목록
|
||||
|
||||
**신규:**
|
||||
- `backend-spring/src/main/java/com/erp/security/PasswordHasher.java`
|
||||
- `frontend/lib/auth/tokenManager.ts`
|
||||
- `docker/dev/.env` (gitignored, syncthing 동기화)
|
||||
- `docker/dev/.env.example` (git 추적)
|
||||
- `notes/gbpark/2026-04-08-auth-security-fix.md` (이 문서)
|
||||
|
||||
**수정:**
|
||||
- `backend-spring/src/main/java/com/erp/security/SecurityConfig.java`
|
||||
- `backend-spring/src/main/java/com/erp/service/AuthService.java`
|
||||
- `backend-spring/src/main/resources/application.yml`
|
||||
- `backend-spring/src/main/resources/mapper/auth.xml`
|
||||
- `docker/dev/docker-compose.invyone.yml`
|
||||
- `frontend/hooks/useAuth.ts`
|
||||
- `frontend/lib/api/client.ts`
|
||||
|
||||
---
|
||||
|
||||
## 검증 결과
|
||||
|
||||
| 검증 | 명령 | 결과 |
|
||||
|---|---|---|
|
||||
| 백엔드 컴파일 | `JWT_SECRET=dummy ./gradlew compileJava` | ✅ BUILD SUCCESSFUL (8s) |
|
||||
| 백엔드 패키징 | `JWT_SECRET=dummy ./gradlew bootJar` | ✅ BUILD SUCCESSFUL (5s) |
|
||||
| 프론트 타입 | `npx tsc --noEmit` | ✅ 내 변경 파일 0건 신규 에러 (기존 에러는 모두 변경 전부터 존재 — git stash 로 비교 확인 완료) |
|
||||
|
||||
런타임 검증 (curl 로그인 → 락아웃 → 재해시 확인) 은 **사용자 환경 (사무실 도커)** 에서 진행 권장.
|
||||
|
||||
```bash
|
||||
# 1. 도커 재기동 (새 .env 로드)
|
||||
cd ~/invyone
|
||||
docker compose -f docker/dev/docker-compose.invyone.yml up -d --force-recreate backend-spring
|
||||
|
||||
# 2. 정상 로그인 확인 (test_dev DB 의 기존 MD5 사용자로)
|
||||
curl -i -X POST http://localhost:8083/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"user_id":"...","password":"..."}'
|
||||
# → 200 + token 응답이면 OK
|
||||
|
||||
# 3. 잘못된 비번 5회 → 6회째 락아웃 메시지 확인
|
||||
for i in 1 2 3 4 5 6; do
|
||||
curl -s -X POST http://localhost:8083/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"user_id":"testuser","password":"wrong"}' | jq .
|
||||
echo "---"
|
||||
done
|
||||
# → 6회차에 "로그인 실패가 너무 많습니다. 5분 후 다시 시도해주세요." 응답
|
||||
|
||||
# 4. 점진 재해시 확인 — 정상 로그인 1회 후 DB 의 USER_PASSWORD 컬럼이 $2a$/$2b$ 로 시작하는지
|
||||
psql -h 211.115.91.141 -p 11134 -U postgres -d test_dev -c \
|
||||
"SELECT user_id, user_password FROM user_info WHERE user_id = '...';"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 정책 결정 사항
|
||||
|
||||
| 항목 | 값 | 변경 시 |
|
||||
|---|---|---|
|
||||
| 락아웃 윈도우 | 5분 | `AuthService.LOCKOUT_WINDOW_MINUTES` 상수 |
|
||||
| 락아웃 한계 | 5회 실패 | `AuthService.LOCKOUT_MAX_FAILURES` 상수 |
|
||||
| BCrypt cost | Spring 디폴트 (10) | `SecurityConfig.passwordEncoder()` 에서 `new BCryptPasswordEncoder(12)` 식으로 변경 가능 |
|
||||
| JWT 만료 | 24시간 | `JWT_EXPIRATION` 환경변수 |
|
||||
| 통일 메시지 | "아이디 또는 비밀번호를 확인해주세요." | `AuthService.LOGIN_FAIL_MESSAGE` 상수 |
|
||||
|
||||
---
|
||||
|
||||
## 종합
|
||||
|
||||
- **시급 보안 결함 6/8 처리** (#1, #2, #3, #5, #7, #8) — 미처리는 #4 SecurityConfig (회귀 위험), #6 HttpOnly (광범위 변경)
|
||||
- **구조 결함 4/6 처리** (#9, #10, #11, #12) — 미처리는 #13 Refresh token (큰 설계), #14 AuthService 분할 (보안 아님)
|
||||
- **백엔드 빌드 통과**, 프론트 타입 회귀 0건
|
||||
- **운영 적용 전 사용자 액션 4건** (DB 컬럼 길이 확인, 강제 재로그인 공지, 운영 .env 작성, 마스터 패스워드 의존성 확인)
|
||||
|
||||
다음 단계로 #6 HttpOnly 쿠키 + #13 Refresh token 을 묶어서 별도 트랙으로 진행하면 인증 보안 모델이 거의 완성됩니다.
|
||||
@@ -0,0 +1,885 @@
|
||||
# invyone 재빌드 — 시스템 정의서 (초안 v0.1)
|
||||
|
||||
> **상태**: DRAFT — 사용자 검토 대기
|
||||
> **작성일**: 2026-04-08
|
||||
> **작성자**: gbpark + Claude
|
||||
> **입력 자료**:
|
||||
> - 다운로드 시안 ZIP (`~/다운로드/INVYONE개발.zip`) — `index.html`, `developer.html`, `templates/hr-management.html`, `js/app.js`, `js/template-html.js`
|
||||
> - 현재 invyone 코드 (`~/invyone/`) — `frontend/`, `backend-spring/`, `ai-assistant/`, `README.md`
|
||||
> - 사용자 인터뷰 (2026-04-08 채팅 세션)
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 / 목적 / 범위
|
||||
|
||||
### 1.1 무엇을 다시 짓는가
|
||||
현재 invyone 은 **데이터타입관리 → 화면디자이너 → 제어관리 → 배치관리** 가 별개의 메뉴/단계로 흩어진 4단계 워크플로우. 사용자가 화면 하나 만들려면 4개 메뉴를 갈아타며 단계별로 정의해야 함. 이걸 **한 캔버스 위에서 한 번에 끝내는 통합 빌더 UX** 로 재구성.
|
||||
|
||||
추가로 기존 코드는 AI 가 짬뽕으로 만들어서 **규격이 없음**. 새 프로젝트는:
|
||||
- **Spec First** — 코드 짜기 전에 메타 모델 / DB 스키마 / 컴포넌트 규격을 문서로 박음
|
||||
- **LLM Native DB** — 나중에 로컬 LLM 이 자연어로 워크플로우를 자동 생성할 수 있게 스키마 단계에서 길을 뚫어둠
|
||||
|
||||
### 1.2 본 SPEC 의 범위
|
||||
- 빌더 UX 의 메타 모델 (Component / Template / Dashboard 3계층)
|
||||
- 사용자 모드 / 개발자 모드 분리 정의
|
||||
- 라우팅 / 페이지 구조
|
||||
- DB 스키마 초안 (LLM-friendly 컬럼 정책 포함)
|
||||
- 첫 마일스톤 (M1) 의 정확한 정의
|
||||
- AI 어시스턴트 통합 hook 의 자리 (실연결은 비범위)
|
||||
|
||||
### 1.3 비범위 (이번 SPEC 에서 정의 안 함)
|
||||
- ERP 도메인 (BOM / 생산 / 출하 / 세금계산서 / 물류) 의 비즈니스 로직 — 후속 마일스톤에서 도메인별 별도 SPEC
|
||||
- 로컬 LLM 모델 선정, 임베딩 모델, 벡터 DB 위치
|
||||
- 모바일 전용 UX
|
||||
- 다국어 (i18n) 정책 — 일단 한국어 단일
|
||||
- 기존 invyone 데이터 마이그레이션 (= 비범위, 별개 프로젝트로 취급, 기존 데이터는 테스트 데이터로 활용)
|
||||
|
||||
### 1.4 무엇을 재사용하는가
|
||||
| 자산 | 위치 | 용도 |
|
||||
|---|---|---|
|
||||
| 인증 / JWT | `backend-spring/.../security/`, `frontend/app/(auth)/login/` | 그대로 |
|
||||
| 회사 / 사용자 / 부서 / 권한 | `USER_INFO`, `COMPANY_INFO`, `DEPARTMENT`, `ROLE_INFO` 테이블 + 관련 컨트롤러 | 그대로 |
|
||||
| 멀티테넌시 (`company_code` 필터) | `JwtAuthenticationFilter`, `common.companyCodeFilter` XML include | 그대로 |
|
||||
| AI 어시스턴트 API | `ai-assistant/` (포트 3100, Gemini 기반) | 자리만 두고, 나중에 로컬 LLM 어댑터로 교체 |
|
||||
| MyBatis Map-based 패턴 | `BaseService` + XML mapper | 새 도메인 만들 때 동일 패턴 사용 |
|
||||
| Next.js 15 + React 19 + Tailwind v4 + shadcn/ui | `frontend/` 인프라 | 그대로 |
|
||||
|
||||
### 1.5 무엇을 폐기하는가
|
||||
- v5 Cosmic Glassmorphism 디자인 (`frontend/styles/v5-layout.css`, `invion-preview-v5.html`) — 새 빌더에서는 안 씀. 기존 화면은 유지.
|
||||
- 기존 4단계 워크플로우의 메뉴 구조 — 새 빌더 라우트는 기존과 격리
|
||||
- (잠재적으로) `frontend/components/screen/` 의 Screen Designer 147 파일 — **새 빌더가 안정화되면** 점진적 폐기. 첫 마일스톤에선 건드리지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 2. 용어 정의
|
||||
|
||||
| 용어 | 정의 |
|
||||
|---|---|
|
||||
| **Component** | 빌더의 원자 단위. 차트/테이블/폼/카드/컨테이너 등. 재사용 가능한 React 컴포넌트 + 메타데이터(id, 이름, 카테고리, 태그, 컨테이너 여부, 기본 props) |
|
||||
| **Container Component** | 다른 Component 를 자식으로 받을 수 있는 Component. grid-layout / tab-panel / accordion / card-layout / split-panel. 자식 중첩은 1단계 (자식의 자식 X). |
|
||||
| **Template** | Component 들을 조립한 완성품. 개발자 모드에서 만듦. 회사 단위 또는 시스템 기본 제공. (예: "인사 관리", "매출 대시보드") |
|
||||
| **Dashboard** | 사용자가 자기 작업 공간에 만든 캔버스. 여러 Template 을 자유 배치 (absolute position, 드래그/리사이즈). 사용자 1명이 N 개 보유 가능. |
|
||||
| **Dashboard Item** | Dashboard 안에 배치된 한 Template 인스턴스. 위치/크기/접힘여부/카드스타일을 가짐. |
|
||||
| **사용자 모드** | Template 라이브러리에서 골라 Dashboard 에 배치 + 데이터 입력/조회. 컴포넌트 조립은 못 함. |
|
||||
| **개발자 모드** | Component 를 조립해 새 Template 을 만들 수 있음. 권한이 있어야 진입. |
|
||||
| **회사 화이트라벨링** | 회사별로 네비위치/테마컬러/폰트/배경/네비바색을 다르게 설정. (시안의 옵션 패널 그대로) |
|
||||
| **AI 어시스턴트** | 자연어 입력으로 Template 자동 생성, 데이터 입력, 워크플로우 자동화. 첫 마일스톤은 자리만, 실연결은 후속. |
|
||||
|
||||
---
|
||||
|
||||
## 3. 시스템 아키텍처
|
||||
|
||||
### 3.1 컴포넌트 다이어그램 (텍스트)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser (Next.js Client) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||
│ │ (auth)/login │ │ (main)/... │ │ (invyone)/builder │ │
|
||||
│ │ [기존 그대로] │ │ [기존 INVION]│ │ [NEW] 통합 빌더 라우트│ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┴────────────────────┘ │
|
||||
│ │ │
|
||||
│ /api 프록시 │
|
||||
└────────────────────────────┼─────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
│ │
|
||||
┌──────────▼──────────┐ ┌──────────▼──────────┐
|
||||
│ backend-spring 8081 │ │ ai-assistant 3100 │
|
||||
│ ┌─────────────────┐ │ │ (Gemini → 로컬 LLM) │
|
||||
│ │ 기존 컨트롤러 95│ │ │ /api/ai/v1/* │
|
||||
│ │ + NEW 빌더 도메인│ │ └─────────────────────┘
|
||||
│ │ - components │ │ ▲
|
||||
│ │ - templates │ │ │ (M5 에서 연결)
|
||||
│ │ - dashboards │ │ │
|
||||
│ │ - theme │ │ │
|
||||
│ └─────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ PostgreSQL │ ◄────────────────┘
|
||||
│ (testvex / 신규 sch)│ (LLM 이 메타 + 데이터 읽음)
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 데이터 흐름 (사용자 모드 — 첫 마일스톤 기준)
|
||||
|
||||
```
|
||||
1. 로그인 (기존 JWT) → company_code 추출
|
||||
2. /invyone/builder 진입 → Dashboard 목록 GET
|
||||
3. Dashboard 선택 → Dashboard 의 items[] (Template 인스턴스들) GET
|
||||
4. 각 Item 의 templateId 로 Template 메타 + 컴포넌트 트리 GET
|
||||
5. 클라이언트가 트리를 React 로 렌더 (iframe 격리 X)
|
||||
6. 편집 모드 → 위치/크기 변경 → PATCH dashboard items
|
||||
7. 새 Template 추가 → 라이브러리 모달 → 선택 → POST item
|
||||
```
|
||||
|
||||
### 3.3 데이터 흐름 (개발자 모드 — M3)
|
||||
|
||||
```
|
||||
1. 권한 확인 (user_type 이 dev 가능 권한일 때만 토글 활성)
|
||||
2. /invyone/builder/dev 진입
|
||||
3. 좌측: Components GET (시스템 + 회사 커스텀)
|
||||
4. 캔버스: 빌더 상태 (메모리)
|
||||
5. 우측 속성패널: 선택 컴포넌트의 props 편집
|
||||
6. 저장 → POST template (회사 단위 저장)
|
||||
7. 사용자 모드 라이브러리에 즉시 노출
|
||||
```
|
||||
|
||||
### 3.4 데이터 흐름 (AI 어시스턴트 — M5, hook 만 M1 에 박음)
|
||||
|
||||
```
|
||||
1. 사용자: "수주 만들어줘" (자연어 입력)
|
||||
2. /api/ai/v1/builder/generate POST { prompt, context: { dashboardId, companyCode } }
|
||||
3. ai-assistant 가 components / templates 메타 + 회사 데이터 스키마 읽음
|
||||
4. LLM 이 Template 트리 JSON 생성 + 필요시 신규 DB 테이블 제안
|
||||
5. 응답: { template: {...}, suggestions: [...], questions: [...] }
|
||||
6. 클라이언트가 사용자에게 확인 모달 → 승인 시 POST template + POST item
|
||||
7. 후속 단계: 사용자가 답변한 데이터로 필드 채움 → 자동 발주 트리거 (워크플로우 엔진)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 도메인 / 메타 모델 (3계층)
|
||||
|
||||
### 4.1 계층 관계
|
||||
|
||||
```
|
||||
Component (원자, 시스템 + 회사 커스텀)
|
||||
│
|
||||
│ 여러 개 조립
|
||||
▼
|
||||
Template (조립된 mini-app, 회사 단위)
|
||||
│
|
||||
│ N개 배치
|
||||
▼
|
||||
Dashboard (사용자의 작업 캔버스, 사용자 단위)
|
||||
│
|
||||
│ 자유 배치 (위치/크기)
|
||||
▼
|
||||
Dashboard Item (Template 인스턴스 + 위치 메타)
|
||||
```
|
||||
|
||||
### 4.2 Component 메타 모델
|
||||
|
||||
```typescript
|
||||
interface Component {
|
||||
// 식별
|
||||
componentId: string; // 'kpi-card', 'data-table', ...
|
||||
type: string; // 동일 (호환용)
|
||||
companyCode?: string; // null = 시스템 기본, 값 = 회사 커스텀
|
||||
|
||||
// 표시
|
||||
nameKo: string; // '바 차트'
|
||||
nameEn?: string; // 'Bar Chart' (i18n 대비)
|
||||
description: string; // '수직/수평 막대 그래프'
|
||||
icon: string; // emoji 또는 lucide 아이콘 키
|
||||
category: string[]; // ['layout', 'chart', ...] (10 카테고리)
|
||||
tags: string[]; // ['ERP', 'MES', '대시보드', ...]
|
||||
|
||||
// 구조
|
||||
isContainer: boolean;
|
||||
defaultCols?: number; // 컨테이너 전용
|
||||
allowedChildTypes?: string[]; // 컨테이너 전용. null 이면 전체 허용
|
||||
maxNestingDepth: 1; // 자식의 자식 금지 (시안 동일)
|
||||
|
||||
// 기본 속성
|
||||
defaultProps: {
|
||||
width: 'full' | 'half' | 'third' | 'quarter' | 'two-third';
|
||||
height: 'auto' | 'small' | 'medium' | 'large' | 'xlarge';
|
||||
bgColor: string; // hex
|
||||
border: 'solid' | 'dashed' | 'none';
|
||||
};
|
||||
|
||||
// 렌더링
|
||||
reactComponentKey: string; // 프론트 레지스트리에서 lookup 할 키
|
||||
// (예: 'KpiCard' → registry.get('KpiCard'))
|
||||
|
||||
// LLM 친화 슬롯 (M5 hook)
|
||||
semanticDescription?: string; // "이 컴포넌트는 핵심 지표 한 개를 큰 숫자로 표시한다"
|
||||
exampleUseCases?: string[]; // ["월매출 표시", "재고 수량 표시"]
|
||||
embeddingVector?: number[]; // 512차원 등, M5 에서 채움 (지금은 nullable)
|
||||
|
||||
// 메타
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Template 메타 모델
|
||||
|
||||
```typescript
|
||||
interface Template {
|
||||
// 식별
|
||||
templateId: string; // 'tpl-hr-mgmt' 또는 'custom-{ts}'
|
||||
companyCode: string; // 시스템 기본은 'SYSTEM' 회사
|
||||
isSystem: boolean; // 시스템 기본 제공 여부
|
||||
isCustom: boolean; // 사용자가 만든 커스텀 여부
|
||||
|
||||
// 표시
|
||||
nameKo: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: string[]; // ['dashboard', 'erp', 'mes', ...] (Template 카테고리, Component 와 다름)
|
||||
tags: string[];
|
||||
|
||||
// 구조 (트리)
|
||||
layout: TemplateNode[]; // root 레벨 컴포넌트들
|
||||
|
||||
// LLM 친화 슬롯
|
||||
semanticDescription?: string; // "사원 목록과 부서별 통계, 검색/필터를 한 화면에 제공"
|
||||
domainKeywords?: string[]; // ["인사", "사원", "부서", "급여"]
|
||||
embeddingVector?: number[];
|
||||
|
||||
// 권한
|
||||
visibilityScope: 'company' | 'department' | 'private';
|
||||
allowedRoleIds?: string[];
|
||||
|
||||
// 메타
|
||||
createdBy: string; // user_id
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
version: number; // 변경 시 +1
|
||||
}
|
||||
|
||||
interface TemplateNode {
|
||||
componentId: string; // Component.componentId 참조
|
||||
displayName: string; // 사용자가 바꾼 이름
|
||||
props: {
|
||||
width: string;
|
||||
height: string;
|
||||
bgColor: string;
|
||||
border: string;
|
||||
cols?: number; // container 전용
|
||||
gap?: number; // container 전용
|
||||
};
|
||||
children?: TemplateNode[]; // container 전용 (1단계 중첩)
|
||||
|
||||
// LLM 친화 슬롯
|
||||
semanticHint?: string; // "이 영역은 부서별 사원 수를 보여준다"
|
||||
dataBinding?: { // M2~ 에서 채움
|
||||
table?: string;
|
||||
columns?: string[];
|
||||
filter?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Dashboard 메타 모델
|
||||
|
||||
```typescript
|
||||
interface Dashboard {
|
||||
dashboardId: string; // UUID
|
||||
companyCode: string;
|
||||
ownerId: string; // user_id
|
||||
|
||||
nameKo: string;
|
||||
isDefault: boolean; // 사용자별 기본 1개
|
||||
sortOrder: number; // 사이드바/탭 순서
|
||||
|
||||
items: DashboardItem[];
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface DashboardItem {
|
||||
itemId: string;
|
||||
templateId: string; // Template 참조
|
||||
displayName?: string; // 인스턴스 이름 (override)
|
||||
|
||||
// 위치/크기 (시안의 _xp/_wp/_y/_h 그대로 채택)
|
||||
xRatio: number; // 0~1, 컨테이너 폭 대비
|
||||
widthRatio: number; // 0~1
|
||||
yPx: number; // 절대 픽셀
|
||||
heightPx: number; // 절대 픽셀
|
||||
|
||||
// 접힘/펼침 상태 (시안 동일)
|
||||
isCollapsed: boolean;
|
||||
collapsedX?: number;
|
||||
collapsedY?: number;
|
||||
collapsedW?: number;
|
||||
collapsedH?: number;
|
||||
cardStyle?: 'flat' | 'glass' | 'neumorphism' | 'gradient' | 'outline'
|
||||
| 'round' | 'sharp' | 'colortop' | 'sideaccent' | 'solid'
|
||||
| 'dark' | 'minimal' | 'badge' | 'tile';
|
||||
cardBgImage?: string;
|
||||
|
||||
zIndex: number;
|
||||
|
||||
// M5 — AI 가 생성한 인스턴스 표시
|
||||
generatedByAi?: boolean;
|
||||
generatedFromPrompt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 메타 모델 핵심 결정 사항
|
||||
|
||||
1. **Template 은 진짜 React 컴포넌트 트리** — 시안의 iframe srcdoc 패턴 폐기. 각 컴포넌트가 React 컴포넌트로 직접 렌더되며, 부모 캔버스의 스타일/이벤트와 자연스럽게 통합된다.
|
||||
2. **자식 중첩은 1단계만** — 시안과 동일. UI 복잡도와 LLM 추론 난이도 둘 다 잡음.
|
||||
3. **메타데이터에 LLM 친화 슬롯이 1급 시민** — `nameKo`, `description`, `semanticDescription`, `domainKeywords`, `embeddingVector`. 첫 마일스톤에선 채우지 않아도 컬럼은 존재.
|
||||
4. **시스템 기본 / 회사 커스텀 분리** — Component / Template 둘 다 `companyCode == 'SYSTEM'` 이면 시스템 기본. 회사가 만든 커스텀은 자기 `companyCode` 로만 보임.
|
||||
5. **Dashboard 는 사용자 단위, Template 은 회사 단위** — 한 회사 안에서 모든 사용자가 같은 Template 라이브러리를 공유. Dashboard 는 개인 작업 공간.
|
||||
|
||||
---
|
||||
|
||||
## 5. 라우팅 / 페이지 구조
|
||||
|
||||
### 5.1 새 라우트 그룹 — `(invyone)`
|
||||
|
||||
기존 `(main)/(auth)/(admin)/(pop)` 와 격리된 새 라우트 그룹. 자체 layout. 기존 INVION 메뉴 영향 0.
|
||||
|
||||
```
|
||||
frontend/app/
|
||||
(invyone)/
|
||||
layout.tsx # 시안의 헤더 + 사이드바 (기존 v5 layout 안 씀)
|
||||
page.tsx # 자동 → /invyone/dashboard/{defaultDashboardId}
|
||||
dashboard/
|
||||
page.tsx # 빈 상태 (대시보드 없을 때)
|
||||
[dashboardId]/
|
||||
page.tsx # 사용자 모드: 캔버스 + 템플릿 배치
|
||||
builder/
|
||||
page.tsx # 개발자 모드: 컴포넌트 조립
|
||||
settings/
|
||||
profile/page.tsx # 시안의 프로필 패널 → 페이지화
|
||||
company/page.tsx # 시안의 회사 정보 패널 → 페이지화
|
||||
theme/page.tsx # 시안의 옵션 패널 → 페이지화 (회사 화이트라벨링)
|
||||
```
|
||||
|
||||
### 5.2 시안 화면 ↔ 라우트 매핑
|
||||
|
||||
| 시안 (HTML) | 새 라우트 | 비고 |
|
||||
|---|---|---|
|
||||
| `index.html` 기본 화면 | `/invyone/dashboard/[id]` | 사용자 모드 캔버스 |
|
||||
| `index.html` 빈 상태 | `/invyone/dashboard` | 대시보드 0개일 때 |
|
||||
| `index.html` 템플릿 라이브러리 모달 | `/invyone/dashboard/[id]` 의 모달 컴포넌트 | 라우트 분리 X |
|
||||
| `index.html` 사이드바 (대시보드 관리) | layout 의 슬라이드 사이드바 | 라우트 분리 X |
|
||||
| `index.html` 회사 정보 패널 | `/invyone/settings/company` | 패널 → 페이지 |
|
||||
| `index.html` 내 프로필 패널 | `/invyone/settings/profile` | 패널 → 페이지 |
|
||||
| `index.html` 옵션 설정 패널 | `/invyone/settings/theme` | 패널 → 페이지 (회사 화이트라벨링) |
|
||||
| `developer.html` | `/invyone/builder` | 개발자 모드. 별도 창 X, 같은 라우트 |
|
||||
| `templates/hr-management.html` | (M2~) Template 1개의 React 구현 | 시안의 mini-app → React 컴포넌트 트리 |
|
||||
|
||||
### 5.3 layout 설계
|
||||
|
||||
`(invyone)/layout.tsx` 의 구조 (시안 그대로):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Header (50px) │
|
||||
│ [☰] [logo invy.one] [tab nav] [모드토글][...][프로필]│
|
||||
├────┬───────────────────────────────────────────────────┤
|
||||
│ S │ │
|
||||
│ i │ │
|
||||
│ d │ <Outlet /> │
|
||||
│ e │ (dashboard | builder | settings) │
|
||||
│ b │ │
|
||||
│ a │ │
|
||||
│ r │ │
|
||||
│ ▼ │ │
|
||||
└────┴───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 사이드바는 햄버거로 열림/닫힘 (overlay 형태, 시안 그대로)
|
||||
- 회사 화이트라벨링 설정에 따라 사이드바 위치 (top/left/right/bottom) 변경
|
||||
- 헤더의 모드 토글 = 사용자/개발자 라우트 전환
|
||||
|
||||
### 5.4 미들웨어
|
||||
|
||||
`frontend/middleware.ts` 에 새 매처 추가:
|
||||
```ts
|
||||
'/invyone/:path*' → JWT 검증 (기존 로직 재사용)
|
||||
'/invyone/builder' → user_type 권한 추가 검증 (개발자 모드)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 빌더 UX 상태머신
|
||||
|
||||
### 6.1 사용자 모드 (M1 핵심)
|
||||
|
||||
```
|
||||
state: { dashboards, activeDashboardId, isEditMode }
|
||||
|
||||
전환:
|
||||
[INIT]
|
||||
└→ loadDashboards() → [DASHBOARD_VIEW]
|
||||
|
||||
[DASHBOARD_VIEW] (편집 모드 OFF)
|
||||
├→ click(템플릿 추가) → [TEMPLATE_LIBRARY_OPEN]
|
||||
├→ click(편집) → [EDIT_MODE]
|
||||
├→ click(다른 대시보드) → loadItems() → [DASHBOARD_VIEW]
|
||||
└→ click(접기/펴기/전체화면) → 인플레이스 상태 변경
|
||||
|
||||
[TEMPLATE_LIBRARY_OPEN] (모달)
|
||||
├→ filter(category|tag|search) → 결과 갱신
|
||||
├→ click(템플릿 카드) → addItem() → POST item → [DASHBOARD_VIEW]
|
||||
└→ close → [DASHBOARD_VIEW]
|
||||
|
||||
[EDIT_MODE]
|
||||
├→ drag(item header) → 위치 변경 (xRatio, yPx)
|
||||
├→ resize(handle) → 크기 변경 (widthRatio, heightPx)
|
||||
├→ click(추가) → [TEMPLATE_LIBRARY_OPEN]
|
||||
├→ click(저장) → PATCH dashboard items → [DASHBOARD_VIEW]
|
||||
└→ click(삭제) → DELETE item → 즉시 반영
|
||||
```
|
||||
|
||||
### 6.2 개발자 모드 (M3)
|
||||
|
||||
```
|
||||
state: { components, currentTemplate, selectedPath, filter }
|
||||
|
||||
전환:
|
||||
[INIT]
|
||||
└→ loadComponents() → [BUILDER_EMPTY]
|
||||
|
||||
[BUILDER_EMPTY]
|
||||
└→ click(컴포넌트) → addComponent() → [BUILDER_EDITING]
|
||||
|
||||
[BUILDER_EDITING]
|
||||
├→ click(컴포넌트) → addComponent()
|
||||
│ - 선택된 게 컨테이너면 자식으로 추가
|
||||
│ - 아니면 root 에 추가
|
||||
├→ click(캔버스 카드) → select(path) → [BUILDER_SELECTED]
|
||||
├→ click(빈 캔버스) → deselect → [BUILDER_EDITING]
|
||||
└→ click(템플릿 저장) → 이름/태그 검증 → POST template → [BUILDER_EMPTY]
|
||||
|
||||
[BUILDER_SELECTED]
|
||||
├→ 속성 변경 (displayName, width, height, bgColor, border, cols, gap)
|
||||
├→ 순서 이동 (▲▼)
|
||||
├→ 삭제 → splice → [BUILDER_EDITING]
|
||||
└→ 다른 카드 클릭 → select(다른 path) → [BUILDER_SELECTED]
|
||||
```
|
||||
|
||||
### 6.3 모드 토글
|
||||
|
||||
- 헤더 우측에 `[🖥️ 사용자] [🛠️ 개발자]` 토글
|
||||
- 사용자 → 개발자 클릭: 권한 확인 → `/invyone/builder` 로 navigate
|
||||
- 개발자 → 사용자 클릭: 미저장 변경 있으면 confirm → `/invyone/dashboard/{lastId}` 로 navigate
|
||||
- 시안에서는 별도 popup window (`window.open('developer.html')`) 였지만, **새 프로젝트에선 같은 라우트 트리 안에서 navigate**. popup 은 UX 단절 초래.
|
||||
|
||||
---
|
||||
|
||||
## 7. 컴포넌트 카탈로그
|
||||
|
||||
### 7.1 카테고리 (10개, 시안 그대로)
|
||||
|
||||
| ID | 이름 | 아이콘 |
|
||||
|---|---|---|
|
||||
| `layout` | 레이아웃 | 🧩 |
|
||||
| `data` | 데이터 | 🗄️ |
|
||||
| `form` | 입력/폼 | ✏️ |
|
||||
| `chart` | 차트 | 📊 |
|
||||
| `nav` | 네비게이션 | 🧭 |
|
||||
| `media` | 미디어 | 🖼️ |
|
||||
| `commerce` | 커머스 | 🛒 |
|
||||
| `social` | 커뮤니티 | 💬 |
|
||||
| `system` | 시스템 | ⚙️ |
|
||||
|
||||
### 7.2 컴포넌트 목록 (시안 80+ 그대로)
|
||||
|
||||
전체 목록은 `~/다운로드/INVYONE개발/js/app.js:24~110` 의 `ComponentLibrary.components` 배열을 1:1 로 옮긴다. 카테고리별 분포:
|
||||
|
||||
- **layout** (10): header, sidebar, footer, grid-layout, tab-panel, accordion, card-layout, split-panel, modal-popup, wizard
|
||||
- **data** (10): data-table, tree-table, pivot-table, kanban, timeline, gantt, calendar, tree-view, list-view, master-detail
|
||||
- **form** (10): input-form, search-filter, file-upload, rich-editor, date-picker, dropdown-select, code-editor, signature-pad, barcode-scanner, address-input
|
||||
- **chart** (12): bar-chart, line-chart, pie-chart, area-chart, radar-chart, gauge, heatmap, kpi-card, map-chart, scatter-chart, funnel-chart, waterfall-chart
|
||||
- **nav** (5): top-menu, breadcrumb, pagination, step-nav, mega-menu
|
||||
- **media** (5): image-gallery, video-player, carousel, file-viewer, icon-set
|
||||
- **commerce** (8): product-card, cart, checkout, order-list, product-detail, coupon, review, wishlist
|
||||
- **social** (6): board, comment, chat, notification, user-profile, faq
|
||||
- **system** (10): login, user-mgmt, role-permission, settings, audit-log, import-export, print-template, workflow, email-template, dashboard-widget
|
||||
|
||||
총 76개 (M1 시점). 이후 회사가 커스텀 컴포넌트를 추가할 수 있음.
|
||||
|
||||
### 7.3 컨테이너 종류와 자식 허용 규칙
|
||||
|
||||
| 컨테이너 | defaultCols | 자식 허용 |
|
||||
|---|---|---|
|
||||
| `grid-layout` | 2 | 모든 비컨테이너 |
|
||||
| `tab-panel` | 1 | 모든 비컨테이너 (탭별 1개씩) |
|
||||
| `accordion` | 1 | 모든 비컨테이너 |
|
||||
| `card-layout` | 3 | 모든 비컨테이너 |
|
||||
| `split-panel` | 2 | 모든 비컨테이너 |
|
||||
|
||||
**규칙: 자식의 자식 금지** (`maxNestingDepth: 1`).
|
||||
|
||||
### 7.4 M1 에서 실제 렌더되는 컴포넌트 수
|
||||
|
||||
첫 마일스톤은 **모든 80개를 placeholder 카드로 렌더**한다. 즉:
|
||||
- 모든 컴포넌트가 라이브러리에 노출됨
|
||||
- 캔버스에 떨어뜨리면 "아이콘 + 이름 + 설명 + 'placeholder'" 카드가 렌더됨
|
||||
- 실제 동작 (차트 그리기, 테이블 데이터 표시 등) 은 X
|
||||
- M2 이후 컴포넌트별로 진짜 React 구현 우선순위 정해서 차례로 구현
|
||||
|
||||
---
|
||||
|
||||
## 8. 사용자 / 개발자 모드 권한 모델
|
||||
|
||||
### 8.1 권한 원천
|
||||
|
||||
기존 invyone 의 `USER_INFO.user_type` 컬럼 활용. JWT 토큰의 claims 에 이미 포함됨 (`backend-spring/.../security/JwtTokenProvider.java:32~48`).
|
||||
|
||||
### 8.2 권한 정의 (M1 에서 시작 상태)
|
||||
|
||||
| user_type | 사용자 모드 | 개발자 모드 | 회사 설정 | 시스템 관리 |
|
||||
|---|---|---|---|---|
|
||||
| `SUPER` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `ADMIN` | ✅ | ✅ | ✅ | ❌ |
|
||||
| `DEV` | ✅ | ✅ | ❌ | ❌ |
|
||||
| `USER` | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
- 메뉴 / 모드 토글 / 라우트 가드 모두 위 매트릭스 따름
|
||||
- M1 에서는 admin/1234 단일 계정으로 SUPER 시작 (시안의 데모 인증과 동일 효과)
|
||||
|
||||
### 8.3 라우트 가드
|
||||
|
||||
| 라우트 | 최소 권한 |
|
||||
|---|---|
|
||||
| `/invyone/dashboard/*` | USER |
|
||||
| `/invyone/builder` | DEV |
|
||||
| `/invyone/settings/profile` | USER |
|
||||
| `/invyone/settings/company` | ADMIN |
|
||||
| `/invyone/settings/theme` | ADMIN |
|
||||
|
||||
미들웨어에서 JWT claims 의 `user_type` 검증.
|
||||
|
||||
---
|
||||
|
||||
## 9. 인증 / 회사 / 사용자 통합 방식
|
||||
|
||||
### 9.1 재사용 원칙
|
||||
|
||||
- 로그인 화면, JWT 발급, refresh 토큰, 비밀번호 정책 등은 **기존 invyone 그대로**
|
||||
- 새 빌더는 단지 JWT claims 를 읽어서 `company_code`, `user_id`, `user_type` 사용
|
||||
|
||||
### 9.2 데이터 격리
|
||||
|
||||
새로 만드는 모든 빌더 테이블에 `company_code` 컬럼 필수 + 인덱스. 모든 SELECT 에 `WHERE company_code = #{companyCode}` 자동 적용 (기존 `<include refid="common.companyCodeFilter"/>` 패턴 그대로 재사용).
|
||||
|
||||
예외: `companyCode = 'SYSTEM'` 인 행은 모든 회사가 read 가능 (시스템 기본 Component / Template). write 는 SUPER 만.
|
||||
|
||||
### 9.3 사용자 ↔ 회사 정보의 용도
|
||||
|
||||
| 컨텍스트 | 사용 컬럼 |
|
||||
|---|---|
|
||||
| Dashboard 소유자 | `USER_INFO.user_id` → `Dashboard.ownerId` |
|
||||
| Template 가시성 | `USER_INFO.dept_id`, `USER_INFO.role_id` → `Template.visibilityScope`, `allowedRoleIds` |
|
||||
| 회사 화이트라벨링 | `COMPANY_INFO.company_code` → `CompanyTheme.companyCode` |
|
||||
| 멀티테넌시 격리 | 모든 빌더 테이블 `company_code` |
|
||||
|
||||
---
|
||||
|
||||
## 10. AI 어시스턴트 통합 hook (자리만, 실연결 X)
|
||||
|
||||
### 10.1 진입점 정의 (M1 에 박을 stub)
|
||||
|
||||
빌더 헤더에 자연어 입력창 또는 챗 아이콘. 클릭 시 모달 또는 슬라이드 패널.
|
||||
|
||||
```
|
||||
POST /api/ai/v1/builder/intent
|
||||
Body: {
|
||||
prompt: "수주 만들어줘",
|
||||
context: {
|
||||
companyCode: "ABC123",
|
||||
userId: "u001",
|
||||
currentDashboardId: "d042"
|
||||
}
|
||||
}
|
||||
Response (M1 — stub): {
|
||||
status: "not_implemented",
|
||||
message: "AI 어시스턴트는 M5 에서 활성화됩니다"
|
||||
}
|
||||
```
|
||||
|
||||
엔드포인트 자체는 M1 에 만들어둠. ai-assistant 가 응답하지 않더라도 frontend 가 안전하게 처리.
|
||||
|
||||
### 10.2 메타 모델의 LLM 친화 슬롯 (M1 에 컬럼만, 값 채우기는 M5)
|
||||
|
||||
다음 컬럼을 처음부터 스키마에 박아둠. 값은 비워둬도 됨:
|
||||
|
||||
- `Component.semanticDescription` — "이 컴포넌트는 ~를 한다"
|
||||
- `Component.exampleUseCases` — JSON 배열
|
||||
- `Component.embeddingVector` — pgvector `vector(512)` 또는 `vector(768)` (모델 결정 시 차원 확정)
|
||||
- `Template.semanticDescription`
|
||||
- `Template.domainKeywords` — JSON 배열
|
||||
- `Template.embeddingVector`
|
||||
- `TemplateNode.semanticHint`
|
||||
- `TemplateNode.dataBinding` — JSON
|
||||
|
||||
### 10.3 미래 시나리오 (M5 ~)
|
||||
|
||||
> "수주 만들어줘"
|
||||
> 1. /api/ai/v1/builder/intent → ai-assistant
|
||||
> 2. ai-assistant 가 components / templates 의 semanticDescription + embeddingVector 검색 → 후보 추출
|
||||
> 3. 회사 데이터 스키마 (testvex DB 또는 신규 schema) 의 테이블 카탈로그 검색
|
||||
> 4. LLM 이 Template JSON 생성 + 신규 테이블 마이그레이션 제안
|
||||
> 5. 클라이언트가 사용자에게 `[수정] [그대로 적용]` 모달
|
||||
> 6. 적용 → POST template + POST item + (선택) ALTER TABLE
|
||||
> 7. 사용자에게 알림: "수주 화면이 생성됐어요. 첫 수주를 입력하시겠어요?"
|
||||
> 8. 사용자 답변 → 필드 자동 채움 → 후속 워크플로우 (자동 발주) 트리거
|
||||
|
||||
이 시나리오의 끝단까지 모두 첫날부터 SPEC 에 박혀 있어야 메타 모델이 안 흔들림. 그래서 LLM-friendly 컬럼이 1급 시민.
|
||||
|
||||
---
|
||||
|
||||
## 11. DB 스키마 초안
|
||||
|
||||
### 11.1 대상 DB 와 스키마 분리
|
||||
|
||||
- 기존 invyone 데이터: 그대로 (testvex DB)
|
||||
- 새 빌더 테이블: **별도 PostgreSQL schema** `invyone_builder` 로 격리. 같은 DB 안에 있되 `invyone_builder.components`, `invyone_builder.templates` 등의 풀 네임으로 접근.
|
||||
- 이유: 기존 테이블 (수백 개) 과 네임스페이스 충돌 방지 + 통째로 drop / migrate 쉬움 + 권한 분리 가능
|
||||
|
||||
### 11.2 LLM-friendly 컬럼 정책
|
||||
|
||||
모든 새 테이블에 다음 패턴:
|
||||
|
||||
- 한글 이름: `name_ko VARCHAR(255) NOT NULL`
|
||||
- 영문 이름: `name_en VARCHAR(255)` (선택)
|
||||
- 의미 설명: `semantic_description TEXT`
|
||||
- 키워드: `keywords JSONB DEFAULT '[]'::jsonb`
|
||||
- 임베딩: `embedding VECTOR(768)` (차원은 모델 결정 시 확정. M5 까지 nullable)
|
||||
|
||||
테이블/컬럼명 자체는 영문 snake_case (DB 컨벤션 유지), **한글 의미는 컬럼 값**으로.
|
||||
|
||||
### 11.3 신규 테이블 (M1 시작)
|
||||
|
||||
```sql
|
||||
-- pgvector 확장 (없으면 추가)
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. components — 컴포넌트 카탈로그
|
||||
-- ============================================================
|
||||
CREATE SCHEMA IF NOT EXISTS invyone_builder;
|
||||
|
||||
CREATE TABLE invyone_builder.components (
|
||||
component_id VARCHAR(64) PRIMARY KEY,
|
||||
company_code VARCHAR(64) NOT NULL, -- 'SYSTEM' = 기본
|
||||
type VARCHAR(64) NOT NULL,
|
||||
name_ko VARCHAR(255) NOT NULL,
|
||||
name_en VARCHAR(255),
|
||||
description TEXT,
|
||||
icon VARCHAR(64),
|
||||
category JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
is_container BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
default_cols INT,
|
||||
allowed_child_types JSONB,
|
||||
default_props JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
react_component_key VARCHAR(128) NOT NULL,
|
||||
semantic_description TEXT,
|
||||
example_use_cases JSONB DEFAULT '[]'::jsonb,
|
||||
embedding VECTOR(768),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_components_company ON invyone_builder.components(company_code);
|
||||
CREATE INDEX idx_components_category ON invyone_builder.components USING GIN(category);
|
||||
-- 임베딩 검색용 (M5)
|
||||
-- CREATE INDEX idx_components_embedding ON invyone_builder.components USING ivfflat (embedding vector_cosine_ops);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. templates — 템플릿 (Component 트리)
|
||||
-- ============================================================
|
||||
CREATE TABLE invyone_builder.templates (
|
||||
template_id VARCHAR(64) PRIMARY KEY,
|
||||
company_code VARCHAR(64) NOT NULL,
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_custom BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
name_ko VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
icon VARCHAR(64),
|
||||
category JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
layout JSONB NOT NULL, -- TemplateNode[] 트리
|
||||
semantic_description TEXT,
|
||||
domain_keywords JSONB DEFAULT '[]'::jsonb,
|
||||
embedding VECTOR(768),
|
||||
visibility_scope VARCHAR(32) NOT NULL DEFAULT 'company',
|
||||
allowed_role_ids JSONB,
|
||||
created_by VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
version INT NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX idx_templates_company ON invyone_builder.templates(company_code);
|
||||
CREATE INDEX idx_templates_category ON invyone_builder.templates USING GIN(category);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. dashboards — 사용자 작업 공간
|
||||
-- ============================================================
|
||||
CREATE TABLE invyone_builder.dashboards (
|
||||
dashboard_id VARCHAR(64) PRIMARY KEY, -- UUID
|
||||
company_code VARCHAR(64) NOT NULL,
|
||||
owner_id VARCHAR(64) NOT NULL,
|
||||
name_ko VARCHAR(255) NOT NULL,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_dashboards_owner ON invyone_builder.dashboards(company_code, owner_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. dashboard_items — Dashboard 안의 Template 인스턴스
|
||||
-- ============================================================
|
||||
CREATE TABLE invyone_builder.dashboard_items (
|
||||
item_id VARCHAR(64) PRIMARY KEY,
|
||||
dashboard_id VARCHAR(64) NOT NULL REFERENCES invyone_builder.dashboards(dashboard_id) ON DELETE CASCADE,
|
||||
template_id VARCHAR(64) NOT NULL, -- FK 안 검 (시스템/회사 양쪽 가능)
|
||||
display_name VARCHAR(255),
|
||||
-- 위치 (시안의 _xp/_wp/_y/_h)
|
||||
x_ratio REAL NOT NULL DEFAULT 0,
|
||||
width_ratio REAL NOT NULL DEFAULT 0.5,
|
||||
y_px INT NOT NULL DEFAULT 20,
|
||||
height_px INT NOT NULL DEFAULT 500,
|
||||
-- 접힘 상태
|
||||
is_collapsed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
collapsed_x INT,
|
||||
collapsed_y INT,
|
||||
collapsed_w INT,
|
||||
collapsed_h INT,
|
||||
card_style VARCHAR(32) DEFAULT 'flat',
|
||||
card_bg_image TEXT,
|
||||
z_index INT NOT NULL DEFAULT 100,
|
||||
-- AI 흔적
|
||||
generated_by_ai BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
generated_from_prompt TEXT,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_items_dashboard ON invyone_builder.dashboard_items(dashboard_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 5. company_themes — 회사 화이트라벨링
|
||||
-- ============================================================
|
||||
CREATE TABLE invyone_builder.company_themes (
|
||||
company_code VARCHAR(64) PRIMARY KEY,
|
||||
nav_position VARCHAR(16) NOT NULL DEFAULT 'left', -- top|left|right|bottom
|
||||
theme_color VARCHAR(16) NOT NULL DEFAULT '#4a6cf7',
|
||||
nav_bg_color VARCHAR(16) NOT NULL DEFAULT '#ffffff',
|
||||
nav_text_color VARCHAR(16) NOT NULL DEFAULT '#333333',
|
||||
nav_icon_color VARCHAR(16) NOT NULL DEFAULT '#333333',
|
||||
bg_color VARCHAR(16) NOT NULL DEFAULT '#f5f6f8',
|
||||
font_family VARCHAR(255) NOT NULL DEFAULT 'Pretendard',
|
||||
font_size_px INT NOT NULL DEFAULT 14,
|
||||
logo_image_url TEXT,
|
||||
seal_image_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
### 11.4 마이그레이션 정책
|
||||
|
||||
- 신규 schema 라 기존 데이터와 충돌 0
|
||||
- M1 시작 전 위 SQL 1개 스크립트로 일괄 적용 (`db/migrations/V100__invyone_builder_init.sql` 같은 신규 파일)
|
||||
- 시스템 기본 Component 76개는 seed SQL 로 같이 INSERT
|
||||
- 시스템 기본 Template 은 M2 에서 hr-management 등 1~2개 우선 seed
|
||||
|
||||
### 11.5 기존 스키마와의 FK
|
||||
|
||||
- `dashboards.owner_id` → `USER_INFO.user_id` (FK 안 거는 게 안전. 멀티테넌시 / cross-DB 가능성)
|
||||
- `dashboards.company_code` → `COMPANY_INFO.company_code` (동일)
|
||||
- → 무결성은 application layer 에서 보장
|
||||
|
||||
---
|
||||
|
||||
## 12. 마일스톤
|
||||
|
||||
| ID | 이름 | 정의 | 산출물 |
|
||||
|---|---|---|---|
|
||||
| **M1** | **빌더 UX 골격** | HTML 시안 그대로의 사용자 모드 빌더 + 컴포넌트 라이브러리 모달 + placeholder 카드 + 드래그/리사이즈/접기/펴기 + localStorage | `(invyone)/dashboard/[id]` 페이지, ComponentLibrary TS 모듈, Dashboard / Item 메타 (메모리만, DB 미연결) |
|
||||
| **M2** | **DB 연동** | 위 5개 테이블 마이그레이션 + Spring 컨트롤러/서비스/매퍼 + frontend API 클라이언트 + 진짜 저장/로드 | `db/migrations/V100*.sql`, backend-spring 5개 컨트롤러, frontend `lib/api/builder.ts` |
|
||||
| **M3** | **개발자 모드** | `(invyone)/builder` 라우트 + 컴포넌트 조립 캔버스 + 속성 패널 + Template 저장. 권한 가드. | `(invyone)/builder/page.tsx`, builder state store, role 가드 |
|
||||
| **M4** | **컴포넌트 실구현 1차** | placeholder → 실제 React 컴포넌트로 교체. 우선순위: data-table, kpi-card, bar-chart, line-chart, input-form, search-filter (이거 6개로 hr-management 재현) | `frontend/components/invyone/{kpi-card,data-table,...}` |
|
||||
| **M5** | **회사 화이트라벨링 + 설정 페이지 3개** | settings/profile, settings/company, settings/theme. company_themes 테이블 사용. | `(invyone)/settings/*` |
|
||||
| **M6** | **AI 어시스턴트 stub → 실연결** | ai-assistant 의 Gemini → 로컬 LLM 어댑터. semantic_description / embedding 채우기. /api/ai/v1/builder/intent 활성. "수주 만들어줘" 시나리오 PoC | ai-assistant 새 엔드포인트, embedding seed 작업 |
|
||||
| **M7~** | **ERP 도메인 파이프라인 빌드** | BOM, 생산, 출하, 세금계산서, 물류 각각 별도 파이프라인으로 신규 구축. 각 도메인마다 별도 SPEC 작성. | 도메인별 SPEC + 생성 코드 |
|
||||
|
||||
### 12.1 M1 의 정확한 정의 (Done 기준)
|
||||
|
||||
- [ ] `(invyone)` 라우트 그룹 + layout 생성
|
||||
- [ ] `/invyone/dashboard/[id]` 페이지 생성
|
||||
- [ ] ComponentLibrary 데이터 (76개) 를 `frontend/lib/invyone/component-catalog.ts` 로 옮김
|
||||
- [ ] TemplateLibrary 데이터 (시안의 18개 시스템 템플릿) 를 `frontend/lib/invyone/template-catalog.ts` 로 옮김 (placeholder 메타만)
|
||||
- [ ] 헤더 (햄버거 + 로고 + 탭nav + 모드토글 + 사용자드롭다운)
|
||||
- [ ] 사이드바 (대시보드 목록 + 추가 폼) — 시안 그대로
|
||||
- [ ] 빈 상태 + "+ 템플릿 추가하기" 버튼
|
||||
- [ ] 템플릿 라이브러리 모달 (카테고리 탭 + 태그 칩 + 검색 + 그리드)
|
||||
- [ ] Template 클릭 → 캔버스에 placeholder 카드 추가
|
||||
- [ ] 편집 모드 토글
|
||||
- [ ] 카드 드래그 이동 (xRatio/yPx)
|
||||
- [ ] 카드 리사이즈 (4방향 핸들, widthRatio/heightPx)
|
||||
- [ ] 카드 접기/펴기
|
||||
- [ ] 카드 전체화면
|
||||
- [ ] 카드 삭제
|
||||
- [ ] localStorage 에 dashboards 상태 저장/복원
|
||||
- [ ] 인증: 기존 invyone 의 로그인 거쳐서 진입 (별도 구현 X, JWT claims 만 읽음)
|
||||
- [ ] AI 진입점 stub 버튼 (클릭 시 "M5 에서 활성화" 토스트)
|
||||
|
||||
### 12.2 M1 에서 의도적으로 안 하는 것
|
||||
- 진짜 컴포넌트 렌더 (placeholder 만)
|
||||
- DB 저장 (localStorage 만)
|
||||
- 개발자 모드 (M3)
|
||||
- 회사 화이트라벨링 (M5)
|
||||
- AI 실연결 (M6)
|
||||
- 다중 사용자 동시편집
|
||||
|
||||
---
|
||||
|
||||
## 13. 비범위 / 미결정
|
||||
|
||||
### 13.1 비범위 (이번 SPEC 의 책임 X)
|
||||
- ERP 도메인 비즈니스 로직 (각 도메인 별도 SPEC)
|
||||
- 모바일 전용 UX (M7 이후)
|
||||
- 다국어 (M7 이후)
|
||||
- 기존 invyone 데이터 마이그레이션 (별개 프로젝트로 취급)
|
||||
|
||||
### 13.2 미결정 (사용자 결정 필요)
|
||||
1. **M1 의 위치 — `(invyone)` 새 라우트 그룹 vs 기존 `(main)/invyone-builder` 하위** — SPEC 은 새 라우트 그룹 추천. 사용자 확인 필요.
|
||||
2. **로컬 LLM 모델 / 임베딩 모델 / 차원** — M5 까지 결정 필요. 일단 schema 의 `vector(768)` 은 잠정.
|
||||
3. **벡터 DB 위치** — pgvector (같은 DB) 추천. 별도 Qdrant/Weaviate 가도 됨.
|
||||
4. **`templates.layout` 트리의 자식 중첩 깊이** — SPEC 은 1단계만 (시안과 동일). 무한 중첩 허용 시 빌더 UX/LLM 추론 둘 다 폭발적으로 어려워짐.
|
||||
5. **시스템 기본 Template seed 의 첫 묶음** — M2 시점에 어떤 템플릿부터 진짜로 만들지. 시안에는 18개 메타가 있는데 그 중 hr-management 만 templates/hr-management.html 로 실제 mini-app 존재. 우선순위 결정 필요.
|
||||
6. **개발자 모드 권한 — `DEV` 라는 user_type 이 기존 invyone 에 있는가?** — 기존 USER_INFO.user_type 의 가능한 값 확인 필요. 없으면 추가 또는 기존 값에 매핑.
|
||||
7. **AI 어시스턴트 입력 위치** — 헤더 우측 상시 입력창 vs 사이드 채팅 패널 vs 캔버스 위 플로팅 버튼. 시안에 명시 X.
|
||||
|
||||
---
|
||||
|
||||
## 14. 오픈 이슈
|
||||
|
||||
| # | 항목 | 상태 |
|
||||
|---|---|---|
|
||||
| O1 | M1 의 라우트 위치 결정 | OPEN |
|
||||
| O2 | 기존 invyone 의 user_type 가능 값 조사 | OPEN — testvex DB SELECT 필요 |
|
||||
| O3 | 기존 COMPANY_INFO 테이블의 구조와 새 company_themes 의 FK 가능성 | OPEN |
|
||||
| O4 | pgvector 확장이 testvex DB 에 설치 가능한가 | OPEN — DBA 확인 |
|
||||
| O5 | `(invyone)` 라우트 그룹의 layout 이 기존 (main) layout 과 충돌 없는가 | OPEN — Next.js 15 라우트 그룹 격리 동작 확인 |
|
||||
| O6 | invion-preview-v5.html 의 코스믹 디자인을 dark theme 옵션으로 살릴지, 완전 폐기할지 | OPEN |
|
||||
| O7 | M1 에 dashboards / dashboard_items 도 DB 에 박을지, 정말로 localStorage 만 갈지 | OPEN — localStorage 만 가면 M2 에서 마이그레이션 코드 따로 필요 |
|
||||
| O8 | 첫 PR 의 범위 — M1 전체를 한 PR 로 vs 헤더+사이드바, 캔버스+드래그, 모달, 접기/펴기 등으로 분할 | OPEN |
|
||||
| O9 | 컴포넌트 카탈로그 데이터를 코드(TS) vs DB 어디에 둘지 — SPEC 은 M1 에 코드, M2 에 DB 로 옮김. 확정 필요. | OPEN |
|
||||
| O10 | 시안의 18개 시스템 Template 중 templates/hr-management.html 외 17개의 실제 HTML 은 template-html.js 에 있는지, 아니면 메타만인지 | OPEN — template-html.js 1803줄 정밀 확인 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **사용자 검토** — 위 14개 섹션 + 미결정 7개 + 오픈이슈 10개 답변
|
||||
2. 합의된 SPEC 으로 M1 을 task / 파이프라인 입력으로 분해
|
||||
3. M1 코드 작성 시작
|
||||
|
||||
---
|
||||
|
||||
*이 문서는 살아있는 문서. 합의된 항목은 본문에 박고, 새 결정은 변경 이력으로 추가.*
|
||||
|
||||
## 변경 이력
|
||||
- 2026-04-08 v0.1 — 초안 작성 (gbpark + Claude 세션)
|
||||
Reference in New Issue
Block a user