디자인 수정

This commit is contained in:
2026-04-08 02:27:27 +09:00
parent db8df83b31
commit 4603ac7fd6
20 changed files with 4314 additions and 307 deletions
@@ -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 &gt; NOW() - (#{lockout_window_minutes}::text || ' minutes')::interval
</select>
<!-- 로그인 접근 로그 기록 -->
<insert id="insertLoginLog" parameterType="map">
INSERT INTO LOGIN_ACCESS_LOG (
+11
View File
@@ -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
+4 -2
View File
@@ -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
+21 -24
View File
@@ -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">
+101
View File
@@ -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);
}
}
+3 -3
View File
@@ -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",
},
+21 -23
View File
@@ -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
View File
@@ -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 -57
View File
@@ -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;
}
},
};
// ============================================
// 토큰 갱신 로직 (중복 요청 방지)
// ============================================
+66
View File
@@ -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;
}
},
};
+96
View File
@@ -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&amp;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 세션)