From a5c102743114bbfb8319cbf8687b55fc50f1bc9e Mon Sep 17 00:00:00 2001 From: "icksishu@gmail.com" Date: Mon, 19 Oct 2026 13:36:47 +0900 Subject: [PATCH] =?UTF-8?q?Gemini=EB=A5=BC=20=ED=99=9C=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95,=20=ED=99=94=EB=A9=B4=20refresh=20=ED=95=A0?= =?UTF-8?q?=20=EB=95=8C=20main.html=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=8A=94?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95,=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/certificate/desc-1.md | 372 ++++++ src/main/front/index.html | 4 +- src/main/front/src/App.vue | 45 +- src/main/front/src/assets/app.css | 51 +- src/main/front/src/assets/base.css | 125 +- src/main/front/src/assets/main.css | 516 +++++++- .../front/src/components/AgentConfigView.vue | 1075 +++++++++++------ .../front/src/components/MessageDataView.vue | 123 +- src/main/front/src/components/Pagination.vue | 73 +- .../src/components/SearchParameterDto.js | 39 +- src/main/front/src/components/apiClient.js | 17 +- src/main/front/src/components/userInfo.js | 37 + src/main/front/src/main.js | 12 +- src/main/front/src/router/index.js | 42 +- src/main/front/src/views/AgentManageView.vue | 181 ++- .../front/src/views/CryptoKeyManageView.vue | 275 +++-- src/main/front/src/views/DashboardView.vue | 431 +++++-- src/main/front/src/views/LoginView.vue | 263 +++- src/main/front/src/views/MainView.vue | 266 +++- .../front/src/views/MessageHistoryView.vue | 253 +++- .../framework/config/ForwardController.java | 7 +- 21 files changed, 3292 insertions(+), 915 deletions(-) create mode 100644 src/certificate/desc-1.md diff --git a/src/certificate/desc-1.md b/src/certificate/desc-1.md new file mode 100644 index 0000000..e9194c0 --- /dev/null +++ b/src/certificate/desc-1.md @@ -0,0 +1,372 @@ +# 나중에 삭제할 것 + +--- + +좋습니다. 요청하신 시나리오를 **“dfxconsole가 JSON을 ARIA-GCM으로 암호화해서 전송 → dfxagent가 /listen, /command에서 복호화”**로 딱 맞춰서, 바로 붙여서 테스트 가능한 형태로 예제를 드리겠습니다. (Java/Spring Boot 기준) + +아래 예제는 **키 1개(대칭키)** + **keyId(버전)** + **nonce(IV)** + **ciphertext**(+tag 포함) 구조로 갑니다. + +--- + +## 0) 전송 포맷(권장) + +dfxconsole → dfxagent로 보내는 요청 Body를 “암호화 봉투(envelope)”로 통일합니다. + +```json +{ + "alg": "ARIA-256-GCM", + "kid": "K20251224-01", + "iv": "base64(12 bytes)", + "ct": "base64(ciphertext+tag)" +} +``` + +* **iv(Nonce)**: 12바이트 랜덤 (Base64) +* **ct**: `cipher.doFinal()` 결과를 Base64로 (여기에 **GCM tag**가 자동 포함) +* **kid**: 키 로테이션 대비 (나중에 온라인 배포/교체 때 중요) + +--- + +## 1) 키 생성 & 오프라인 배포 파일 + +### 1-1. dfxconsole에서 키 생성(예: CLI/유틸) + +```java +import java.nio.file.*; +import java.security.SecureRandom; +import java.util.Base64; + +public class AriaKeyGen { + public static void main(String[] args) throws Exception { + // ARIA-256 = 32 bytes + byte[] key = new byte[32]; + new SecureRandom().nextBytes(key); + + String kid = "K20251224-01"; + String b64 = Base64.getEncoder().encodeToString(key); + + String json = """ + { + "kid": "%s", + "kty": "oct", + "alg": "ARIA-256-GCM", + "k": "%s" + } + """.formatted(kid, b64); + + Files.writeString(Path.of("dfx-aria-key.json"), json); + System.out.println("Wrote dfx-aria-key.json"); + } +} +``` + +생성된 `dfx-aria-key.json`를 **오프라인(USB 등)으로 dfxagent 서버에 배포**합니다. + +### 1-2. dfxagent에서 키 로딩(서버 시작 시) + +`application.yml`: + +```yml +crypto: + keyFile: /opt/dfx/keys/dfx-aria-key.json +``` + +키 파일 DTO: + +```java +public record KeyFile(String kid, String kty, String alg, String k) {} +``` + +로더: + +```java +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; + +@Component +public class KeyManager { + private final ObjectMapper om = new ObjectMapper(); + + @Value("${crypto.keyFile}") + private String keyFile; + + private volatile String currentKid; + private volatile SecretKey currentKey; + + @PostConstruct + public void load() throws Exception { + String json = Files.readString(Path.of(keyFile)); + KeyFile kf = om.readValue(json, KeyFile.class); + + byte[] keyBytes = Base64.getDecoder().decode(kf.k()); + this.currentKid = kf.kid(); + this.currentKey = new SecretKeySpec(keyBytes, "ARIA"); + } + + public String kid() { return currentKid; } + public SecretKey keyByKid(String kid) { + // 지금은 단일 키만 운용한다고 가정 + if (!currentKid.equals(kid)) throw new IllegalArgumentException("Unknown kid: " + kid); + return currentKey; + } +} +``` + +--- + +## 2) ARIA-GCM 암/복호화 공통 유틸(양쪽 동일) + +> **중요:** ARIA 알고리즘 지원은 JCE 기본 Provider에 없을 수 있어, 보통 **BouncyCastle Provider**를 추가합니다. + +Gradle: + +```gradle +implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") +``` + +Provider 등록(앱 시작 시 1회): + +```java +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import java.security.Security; + +public class CryptoProviders { + static { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } +} +``` + +암호화/복호화 서비스: + +```java +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import java.security.SecureRandom; + +public class AriaGcm { + private static final int NONCE_LEN = 12; // 96-bit + private static final int TAG_LEN_BITS = 128; + + private final SecureRandom rnd = new SecureRandom(); + + public byte[] randomNonce() { + byte[] nonce = new byte[NONCE_LEN]; + rnd.nextBytes(nonce); + return nonce; + } + + public byte[] encrypt(SecretKey key, byte[] nonce, byte[] plaintext, byte[] aad) throws Exception { + Cipher cipher = Cipher.getInstance("ARIA/GCM/NoPadding", "BC"); + GCMParameterSpec spec = new GCMParameterSpec(TAG_LEN_BITS, nonce); + cipher.init(Cipher.ENCRYPT_MODE, key, spec); + if (aad != null && aad.length > 0) cipher.updateAAD(aad); + return cipher.doFinal(plaintext); // ciphertext + tag + } + + public byte[] decrypt(SecretKey key, byte[] nonce, byte[] ciphertextAndTag, byte[] aad) throws Exception { + Cipher cipher = Cipher.getInstance("ARIA/GCM/NoPadding", "BC"); + GCMParameterSpec spec = new GCMParameterSpec(TAG_LEN_BITS, nonce); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + if (aad != null && aad.length > 0) cipher.updateAAD(aad); + return cipher.doFinal(ciphertextAndTag); // tag 검증 실패 시 예외 + } +} +``` + +--- + +## 3) dfxconsole: JSON을 암호화해서 dfxagent로 POST 전송 + +Envelope DTO: + +```java +public record EncryptedEnvelope(String alg, String kid, String iv, String ct) {} +``` + +전송 서비스(예: RestTemplate/WebClient 어느 쪽이든 OK — 여기선 WebClient): + +```java +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Service +public class DfxConsoleSender extends CryptoProviders { + private final WebClient webClient = WebClient.builder().build(); + private final ObjectMapper om = new ObjectMapper(); + private final AriaGcm aria = new AriaGcm(); + + // dfxconsole이 보유한 송신용 키(오프라인 배포 전제) + private final String kid = "K20251224-01"; + private final SecretKey key; // 구성/주입으로 가져오세요 + + public DfxConsoleSender(SecretKey key) { + this.key = key; + } + + public void sendEncryptedJson(String url, Object jsonPayload, String endpointPathForAAD) throws Exception { + byte[] plain = om.writeValueAsBytes(jsonPayload); + byte[] nonce = aria.randomNonce(); + + // AAD(추가 인증 데이터): 경로/메소드 등을 넣으면 “다른 엔드포인트로 재전송” 같은 것을 일부 완화 + byte[] aad = ("POST:" + endpointPathForAAD).getBytes(StandardCharsets.UTF_8); + + byte[] ct = aria.encrypt(key, nonce, plain, aad); + + EncryptedEnvelope env = new EncryptedEnvelope( + "ARIA-256-GCM", + kid, + Base64.getEncoder().encodeToString(nonce), + Base64.getEncoder().encodeToString(ct) + ); + + webClient.post() + .uri(url) + .bodyValue(env) + .retrieve() + .toBodilessEntity() + .block(); + } +} +``` + +사용 예: + +```java +// /listen 으로 보낼 JSON +Map payload = Map.of("type","MESSAGE","data", Map.of("a",1,"b","x")); +sender.sendEncryptedJson("https://dfxagent-host/listen", payload, "/listen"); + +// /command 으로 보낼 JSON +Map cmd = Map.of("cmd","PING","ts", System.currentTimeMillis()); +sender.sendEncryptedJson("https://dfxagent-host/command", cmd, "/command"); +``` + +--- + +## 4) dfxagent: /listen, /command에서 수신 후 복호화 + +dfxagent는 컨트롤러에서 `EncryptedEnvelope`를 받아서 복호화한 뒤 **원래 JSON(Map/DTO)** 로 변환합니다. + +```java +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@RestController +public class DfxAgentReceiver extends CryptoProviders { + private final ObjectMapper om = new ObjectMapper(); + private final AriaGcm aria = new AriaGcm(); + private final KeyManager km; + + public DfxAgentReceiver(KeyManager km) { + this.km = km; + } + + @PostMapping("/listen") + public Map listen(@RequestBody EncryptedEnvelope env) throws Exception { + Map data = decryptEnvelope(env, "POST:/listen"); + // TODO: 기존 /listen 처리 로직에 data 넘기기 + return Map.of("ok", true, "receivedType", data.get("type")); + } + + @PostMapping("/command") + public Map command(@RequestBody EncryptedEnvelope env) throws Exception { + Map data = decryptEnvelope(env, "POST:/command"); + // TODO: 기존 /command 처리 로직에 data 넘기기 + return Map.of("ok", true, "cmd", data.get("cmd")); + } + + private Map decryptEnvelope(EncryptedEnvelope env, String aadStr) throws Exception { + byte[] nonce = Base64.getDecoder().decode(env.iv()); + byte[] ct = Base64.getDecoder().decode(env.ct()); + + byte[] aad = aadStr.getBytes(StandardCharsets.UTF_8); + + var key = km.keyByKid(env.kid()); + + byte[] plain = aria.decrypt(key, nonce, ct, aad); // tag 검증 실패 시 예외 발생 + return om.readValue(plain, Map.class); + } +} +``` + +### 복호화 실패 처리(권장) + +GCM tag가 틀리면 `AEADBadTagException`류 예외가 납니다. 이걸 400/401로 정리해 두면 운영이 편합니다. + +```java +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; +import javax.crypto.AEADBadTagException; + +@RestControllerAdvice +public class CryptoExceptionHandler { + + @ExceptionHandler(AEADBadTagException.class) + public ResponseEntity badTag(AEADBadTagException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("ok", false, "error", "INVALID_TAG")); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity badReq(IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("ok", false, "error", e.getMessage())); + } +} +``` + +--- + +## 5) 운영에서 꼭 넣어야 하는 체크포인트 + +1. **Nonce 재사용 금지** + + * 위 예제는 매번 랜덤 생성 → OK + * (대량 트래픽이면) “랜덤 충돌” 가능성이 이론상 있으니, 트래픽이 매우 크면 **카운터 기반(CTR-style) nonce 전략**도 고려 + +2. **키는 코드/DB에 박지 말고 파일/환경변수/KMS** + + * 지금은 오프라인 파일 OK + * 추후 온라인 배포 시에는 `kid → key` 다중 보관 + 롤링 + +3. **AAD 사용 권장** + + * 예제처럼 `POST:/listen` 등을 넣으면, 암호문을 다른 엔드포인트로 그대로 옮겨 재전송하는 류를 일부 줄입니다. + +4. **ct에 tag 포함** + + * GCM은 `doFinal()` 결과에 tag가 붙는 구현이 일반적 + * “ct / tag”를 분리 저장하고 싶으면 분리 포맷으로 바꿔도 됩니다. + +--- + +## 6) 다음 단계(원하시면 바로 붙여드릴 것) + +* dfxagent에서 **“암호화 envelope 자동 복호화”**: + 컨트롤러마다 decrypt 호출 안 하고, `@RequestBody` 앞단에서 필터/어드바이스로 자동화 +* 키 로테이션: `kid`별 키 다중 로딩 + “현재 kid로 암호화, 과거 kid도 복호화 지원” +* 온라인 배포(추후): dfxconsole → dfxagent 키 갱신 API + 서명(키 배포용 공개키 기반) + +원하시는 방향이 **“/listen, /command는 기존 그대로 두고(평문 JSON 파라미터), 네트워크 구간만 암호화”**인지, 아니면 **“바디 자체를 envelope로 바꿔도 된다”**인지에 따라 구현 위치(필터/어드바이스/컨트롤러)가 달라집니다. +지금 예제는 가장 단순한 **envelope 방식**인데, dfxagent 기존 API 바디 형태를 유지해야 한다면 그 방식으로도 바로 변형 예제를 드릴게요. diff --git a/src/main/front/index.html b/src/main/front/index.html index 6ed1a19..b69b1e0 100644 --- a/src/main/front/index.html +++ b/src/main/front/index.html @@ -4,10 +4,10 @@ - DFXConsole + DFX Console — Data Flow eXchange -
+
diff --git a/src/main/front/src/App.vue b/src/main/front/src/App.vue index 7d28d8d..71df79b 100644 --- a/src/main/front/src/App.vue +++ b/src/main/front/src/App.vue @@ -1,25 +1,34 @@ - + diff --git a/src/main/front/src/assets/app.css b/src/main/front/src/assets/app.css index 26225af..4d7b226 100644 --- a/src/main/front/src/assets/app.css +++ b/src/main/front/src/assets/app.css @@ -1,42 +1,29 @@ @import './base.css'; +/* Vue 마운트 루트 컨테이너: 가로 최대폭 제한 없이 전체 화면(Full-Width)을 사용하도록 설정 */ #app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - font-weight: normal; - flex-wrap: wrap; - flex-direction: row; - align-items: stretch; + width: 100%; min-height: 100vh; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + background-color: var(--dfx-bg-base); } -a, -.green { +/* 기본 링크 스타일 초기화 */ +a { text-decoration: none; - color: hsla(160, 100%, 37%, 1); - transition: 0.4s; - padding: 3px; + color: inherit; + transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; } -@media (hover: hover) { - a:hover { - background-color: hsla(160, 100%, 37%, 0.2); - } -} - -@media (min-width: 1024px) { - body { - display: flex; - place-items: center; - } - - #app { - padding: 0 2rem; - } -} - -footer { - min-height: 2rem; - max-height: 2rem; +/* 하단 공통 푸터 바 스타일 */ +footer.dfx-footer { + height: 36px; + min-height: 36px; + background-color: var(--dfx-surface-1); + border-top: 1px solid var(--dfx-border); + color: var(--dfx-text-muted); + font-size: 12px; } diff --git a/src/main/front/src/assets/base.css b/src/main/front/src/assets/base.css index eeee89a..b0fe280 100644 --- a/src/main/front/src/assets/base.css +++ b/src/main/front/src/assets/base.css @@ -1,91 +1,72 @@ -/* color palette from +/** + * DFX Console 전역 디자인 토큰 (base.css) + * - 플랫(Flat) 다크 테마에 사용되는 배경색, 표면(Surface) 계층색, 테두리, 상태 강조색 및 폰트를 정의합니다. + */ :root { - --vt-c-white: #ffffff; - --vt-c-white-soft: #f8f8f8; - --vt-c-white-mute: #f2f2f2; + /* 배경 및 표면(Surface) 계층 컬러 */ + --dfx-bg-base: #0b0f17; /* 최하단 기본 배경색 */ + --dfx-bg-canvas: #0f1520; /* 메인 컨텐츠 영역 캔버스 배경색 */ + --dfx-surface-1: #151d2a; /* 카드, 사이드바, 헤더, 모달의 기본 배경색 */ + --dfx-surface-2: #1c2636; /* 카드 헤더, 테이블 헤더, 입력창 읽기전용 배경색 */ + --dfx-surface-3: #243146; /* 마우스 오버(Hover) 및 활성 보조 배경색 */ - --vt-c-black: #181818; - --vt-c-black-soft: #222222; - --vt-c-black-mute: #282828; + /* 직각 경계선(Border) 컬러 */ + --dfx-border: #2a374b; /* 기본 1px 실선 테두리 */ + --dfx-border-strong: #384963; /* 강조 구분선 및 모달/테이블 헤더 하단 테두리 */ - --vt-c-indigo: #2c3e50; + /* 의미론적(Semantic) 상태 및 포인트 컬러 */ + --dfx-primary: #2563eb; /* 기본 액센트 블루 */ + --dfx-primary-hover: #1d4ed8; /* 기본 버튼 호버 블루 */ + --dfx-primary-subtle: rgba(37, 99, 235, 0.15); + --dfx-accent: #06b6d4; /* 보조 포인트 시안(Cyan) */ + --dfx-success: #10b981; /* 정상/성공(STATUS_OK, PROCESS_DONE) 그린 */ + --dfx-success-subtle: rgba(16, 185, 129, 0.14); + --dfx-warning: #f59e0b; /* 경고/강조 앰버(Amber) */ + --dfx-warning-subtle: rgba(245, 158, 11, 0.14); + --dfx-danger: #ef4444; /* 오류/삭제(PROCESS_FAIL) 레드 */ + --dfx-danger-subtle: rgba(239, 68, 68, 0.14); - --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); - --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); - --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); - --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + /* 텍스트 계층 컬러 */ + --dfx-text-primary: #f1f5f9; /* 본문 및 주요 타이틀 텍스트 */ + --dfx-text-secondary: #94a3b8; /* 보조 라벨 및 부제목 텍스트 */ + --dfx-text-muted: #64748b; /* 비활성 또는 힌트 텍스트 */ - --vt-c-text-light-1: var(--vt-c-indigo); - --vt-c-text-light-2: rgba(60, 60, 60, 0.66); - --vt-c-text-dark-1: var(--vt-c-white); - --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); -} -*/ - -/* semantic color variables for this project -:root { - --color-background: var(--vt-c-white); - --color-background-soft: var(--vt-c-white-soft); - --color-background-mute: var(--vt-c-white-mute); - - --color-border: var(--vt-c-divider-light-2); - --color-border-hover: var(--vt-c-divider-light-1); - - --color-heading: var(--vt-c-text-light-1); - --color-text: var(--vt-c-text-light-1); - - --section-gap: 160px; -} - -@media (prefers-color-scheme: dark) { - :root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); - - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); - - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); - } + /* 타이포그래피: 본문용 산세리프 & 식별자/코드/수치용 고정폭(Monospace) 폰트 */ + --dfx-font-sans: + 'Pretendard', + '맑은 고딕', + 'Malgun Gothic', + Inter, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; + --dfx-font-mono: + 'JetBrains Mono', + 'Consolas', + 'Courier New', + monospace; } -*/ -/* 모든 엘리먼트 선언 */ +/* 모든 요소의 박스 모델을 border-box로 통일하고 기본 여백 초기화 */ *, *::before, *::after { box-sizing: border-box; margin: 0; - font-weight: normal; } +/* 문서 전체 기본 높이, 배경색, 폰트 렌더링 설정 */ +html, body { - /* - color: var(--color-text); - background: var(--color-background); - */ + width: 100%; min-height: 100vh; - transition: - color 0.5s, - background-color 0.5s; - line-height: 1.6; - font-family: - '맑은 고딕', - Inter, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - Roboto, - Oxygen, - Ubuntu, - Cantarell, - 'Fira Sans', - 'Droid Sans', - 'Helvetica Neue', - sans-serif; - font-size: 15px; + background-color: var(--dfx-bg-base); + color: var(--dfx-text-primary); + font-family: var(--dfx-font-sans); + font-size: 14px; + line-height: 1.5; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; diff --git a/src/main/front/src/assets/main.css b/src/main/front/src/assets/main.css index 270a480..474c75b 100644 --- a/src/main/front/src/assets/main.css +++ b/src/main/front/src/assets/main.css @@ -1,45 +1,505 @@ -/* 상단 타이틀 및 바로가기 영역 */ -.dfx-top-title-bar { - /* - */ - background-color: rebeccapurple; - min-height: 2rem; -} - -/* 왼쪽 사이드바 영역 */ -.dfx-left-side { - background-color: aqua; - min-width: 14rem; -} - -/* 메뉴 내비게이션 영역 */ -.dfx-left-menu-nav { - background-color: cornflowerblue; -} - -/* 메인 컨텐츠 전체 영역 */ -.dfx-main { - background-color: var(--bs-tertiary-bg); - min-height: calc(100vh - 6rem); -} - -:root { - /* 기본 radius */ +/** + * DFX Console 공통 컴포넌트 및 레이아웃 스타일 (main.css) + * - 기본 디자인 원칙: 플랫 디자인(Flat Design) + 모든 사각 요소 라운드 제거(border-radius: 0) + */ +:root, +[data-bs-theme='dark'] { + /* Bootstrap 전역 border-radius 변수를 모두 0(직각)으로 재정의 */ --bs-border-radius: 0; --bs-border-radius-sm: 0; --bs-border-radius-lg: 0; --bs-border-radius-xl: 0; --bs-border-radius-xxl: 0; + --bs-border-radius-2xl: 0; --bs-border-radius-pill: 0; - /* 컴포넌트별 radius */ + /* 버튼, 카드, 모달, 드롭다운, 배지, 입력창의 개별 radius 변수도 모두 0으로 설정 */ --bs-btn-border-radius: 0; --bs-btn-border-radius-sm: 0; --bs-btn-border-radius-lg: 0; --bs-card-border-radius: 0; + --bs-card-inner-border-radius: 0; --bs-modal-border-radius: 0; --bs-dropdown-border-radius: 0; --bs-badge-border-radius: 0; --bs-input-border-radius: 0; + + /* Bootstrap 다크 테마 기본 색상 변수를 DFX 디자인 토큰과 연동 */ + --bs-body-bg: var(--dfx-bg-base); + --bs-body-color: var(--dfx-text-primary); + --bs-border-color: var(--dfx-border); + --bs-tertiary-bg: var(--dfx-bg-canvas); + --bs-secondary-bg: var(--dfx-surface-1); +} + +/* 모든 사각형 UI 컴포넌트에 대해 모서리 둥글기(border-radius)를 0으로 강제 적용 */ +.card, +.card-header, +.card-footer, +.btn, +.btn-sm, +.btn-lg, +.form-control, +.form-select, +.input-group-text, +.modal-content, +.modal-header, +.modal-footer, +.dropdown-menu, +.dropdown-item, +.nav-link, +.nav-tabs .nav-link, +.nav-pills .nav-link, +.badge, +.page-link, +.page-item:first-child .page-link, +.page-item:last-child .page-link, +.alert, +.list-group-item, +.table, +.form-check-input[type='checkbox'], +.rounded, +.rounded-1, +.rounded-2, +.rounded-3, +.rounded-4, +.rounded-top, +.rounded-bottom, +.rounded-start, +.rounded-end { + border-radius: 0 !important; +} + +/* 플랫 디자인: 불필요한 입체 그림자(box-shadow)와 배경 그라데이션 제거 */ +.btn, +.card, +.form-control, +.form-select, +.input-group-text, +.page-link { + box-shadow: none !important; + background-image: none; +} + +/* 입력창(input, select) 기본 플랫 스타일 */ +.form-control, +.form-select { + background-color: var(--dfx-bg-base); + border: 1px solid var(--dfx-border); + color: var(--dfx-text-primary); + font-size: 13px; + padding: 0.42rem 0.65rem; +} + +/* 입력창 포커스 시 번지는 그림자 대신 선명한 1px 파란색 테두리 강조 */ +.form-control:focus, +.form-select:focus { + background-color: var(--dfx-bg-base); + border-color: var(--dfx-primary) !important; + color: var(--dfx-text-primary); + outline: 1px solid var(--dfx-primary); + outline-offset: -1px; +} + +/* 읽기 전용(readonly) 및 비활성(disabled) 입력창 스타일 */ +.form-control[readonly], +.form-control:disabled { + background-color: var(--dfx-surface-2); + border-color: var(--dfx-border); + color: var(--dfx-text-secondary); +} + +/* 입력 그룹 라벨(input-group-text) 스타일 */ +.input-group-text { + background-color: var(--dfx-surface-2); + border: 1px solid var(--dfx-border); + color: var(--dfx-text-secondary); + font-size: 12.5px; + font-weight: 600; +} + +/* 사각 체크박스 스타일 */ +.form-check-input[type='checkbox'] { + background-color: var(--dfx-bg-base); + border: 1px solid var(--dfx-border-strong); + cursor: pointer; +} + +.form-check-input[type='checkbox']:checked { + background-color: var(--dfx-primary); + border-color: var(--dfx-primary); +} + +.form-check-input:focus { + box-shadow: none !important; + border-color: var(--dfx-primary); +} + +/* 플랫 버튼 공통 및 색상별 스타일 */ +.btn { + font-weight: 600; + font-size: 13px; + letter-spacing: 0.01em; + padding: 0.42rem 0.85rem; + border-width: 1px; + transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.btn-sm { + font-size: 12px; + padding: 0.28rem 0.65rem; +} + +.btn-primary { + background-color: var(--dfx-primary); + border-color: var(--dfx-primary); + color: #ffffff; +} + +.btn-primary:hover, +.btn-primary:focus { + background-color: var(--dfx-primary-hover); + border-color: var(--dfx-primary-hover); + color: #ffffff; +} + +.btn-success { + background-color: #059669; + border-color: #059669; + color: #ffffff; +} + +.btn-success:hover, +.btn-success:focus { + background-color: #047857; + border-color: #047857; + color: #ffffff; +} + +.btn-danger { + background-color: #dc2626; + border-color: #dc2626; + color: #ffffff; +} + +.btn-danger:hover, +.btn-danger:focus { + background-color: #b91c1c; + border-color: #b91c1c; + color: #ffffff; +} + +.btn-info { + background-color: #0284c7; + border-color: #0284c7; + color: #ffffff; +} + +.btn-info:hover, +.btn-info:focus { + background-color: #0369a1; + border-color: #0369a1; + color: #ffffff; +} + +/* 보조(Ghost) 버튼 스타일 (새로고침, 계정 버튼 등) */ +.btn-outline-secondary, +.dfx-btn-ghost { + background-color: var(--dfx-surface-2); + border: 1px solid var(--dfx-border); + color: var(--dfx-text-primary); +} + +.btn-outline-secondary:hover, +.dfx-btn-ghost:hover { + background-color: var(--dfx-surface-3); + border-color: var(--dfx-border-strong); + color: #ffffff; +} + +/* 상단 타이틀 및 바로가기 헤더 영역 */ +.dfx-top-title-bar { + background-color: var(--dfx-surface-1); + border-bottom: 1px solid var(--dfx-border); + height: 52px; + min-height: 52px; + padding: 0 1.25rem; + z-index: 1020; +} + +/* 좌측 사이드바 메뉴 영역 */ +.dfx-left-side { + background-color: var(--dfx-surface-1); + border-right: 1px solid var(--dfx-border); + width: 230px; + min-width: 230px; + max-width: 230px; +} + +/* 우측 메인 컨텐츠 전체 영역 */ +.dfx-main { + background-color: var(--dfx-bg-canvas); + min-height: calc(100vh - 88px); + flex: 1 1 auto; + padding: 1.5rem 1.75rem; + max-width: 100%; +} + +/* 각 화면 상단 제목(Page Header) 영역 */ +.dfx-page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 1rem; + margin-bottom: 1.25rem; + border-bottom: 1px solid var(--dfx-border); +} + +/* 화면 제목 텍스트 (좌측에 4px 수직 포인트 바 표시) */ +.dfx-page-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--dfx-text-primary); + margin: 0; + display: flex; + align-items: center; + gap: 0.6rem; + letter-spacing: -0.01em; +} + +.dfx-page-title::before { + content: ''; + display: inline-block; + width: 4px; + height: 18px; + background-color: var(--dfx-primary); +} + +/* 화면 제목 하단 부연 설명 텍스트 */ +.dfx-page-subtitle { + font-size: 12px; + color: var(--dfx-text-secondary); + margin-top: 2px; +} + +/* 플랫 카드 및 패널 컨테이너 */ +.card, +.dfx-panel { + background-color: var(--dfx-surface-1); + border: 1px solid var(--dfx-border); + border-radius: 0 !important; +} + +.card-header, +.dfx-panel-header { + background-color: var(--dfx-surface-2); + border-bottom: 1px solid var(--dfx-border); + padding: 0.65rem 1rem; + font-weight: 600; + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-body, +.dfx-panel-body { + padding: 1rem; +} + +/* 대시보드 상단 요약 지표(KPI) 플랫 카드 (상단 3px 컬러 보더 강조) */ +.dfx-kpi-card { + background-color: var(--dfx-surface-1); + border: 1px solid var(--dfx-border); + border-top: 3px solid var(--dfx-primary); + padding: 1rem 1.15rem; + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; +} + +.dfx-kpi-card.kpi-cyan { + border-top-color: var(--dfx-accent); +} + +.dfx-kpi-card.kpi-emerald { + border-top-color: var(--dfx-success); +} + +.dfx-kpi-card.kpi-amber { + border-top-color: var(--dfx-warning); +} + +.dfx-kpi-label { + font-size: 11.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--dfx-text-secondary); +} + +.dfx-kpi-value { + font-size: 1.65rem; + font-weight: 700; + font-family: var(--dfx-font-mono); + color: var(--dfx-text-primary); + line-height: 1.2; + margin-top: 0.35rem; +} + +.dfx-kpi-meta { + font-size: 12px; + color: var(--dfx-text-muted); + margin-top: 0.35rem; +} + +/* 데이터 테이블(Table) 플랫 다크 스타일 */ +.table { + --bs-table-bg: var(--dfx-surface-1); + --bs-table-striped-bg: rgba(28, 38, 54, 0.55); + --bs-table-hover-bg: rgba(37, 99, 235, 0.08); + --bs-table-border-color: var(--dfx-border); + color: var(--dfx-text-primary); + margin-bottom: 0; + font-size: 13px; +} + +.table > thead > tr > th { + background-color: var(--dfx-surface-2); + color: var(--dfx-text-secondary); + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.65rem 0.75rem; + border-bottom: 2px solid var(--dfx-border-strong); + vertical-align: middle; +} + +.table > tbody > tr > td { + padding: 0.6rem 0.75rem; + vertical-align: middle; + border-color: var(--dfx-border); +} + +.table-group-divider { + border-top-color: var(--dfx-border-strong) !important; +} + +/* 직각 상태 배지(.dfx-badge) 및 상태별 컬러 스타일 */ +.dfx-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.55rem; + font-size: 11.5px; + font-weight: 600; + font-family: var(--dfx-font-mono); + letter-spacing: 0.02em; + border: 1px solid transparent; + border-radius: 0 !important; + line-height: 1.3; +} + +/* 배지 내부의 작은 사각 상태 인디케이터 점 */ +.dfx-badge-dot { + width: 6px; + height: 6px; + display: inline-block; +} + +.dfx-badge-success { + background-color: var(--dfx-success-subtle); + color: #34d399; + border-color: rgba(16, 185, 129, 0.35); +} + +.dfx-badge-success .dfx-badge-dot { + background-color: #10b981; +} + +.dfx-badge-danger { + background-color: var(--dfx-danger-subtle); + color: #f87171; + border-color: rgba(239, 68, 68, 0.35); +} + +.dfx-badge-danger .dfx-badge-dot { + background-color: #ef4444; +} + +.dfx-badge-info { + background-color: rgba(6, 182, 212, 0.14); + color: #22d3ee; + border-color: rgba(6, 182, 212, 0.35); +} + +.dfx-badge-info .dfx-badge-dot { + background-color: #06b6d4; +} + +.dfx-badge-neutral { + background-color: var(--dfx-surface-2); + color: var(--dfx-text-secondary); + border-color: var(--dfx-border); +} + +.dfx-badge-neutral .dfx-badge-dot { + background-color: var(--dfx-text-muted); } + +/* ID, 호스트명, 포트, 시간, UUID 등에 사용하는 고정폭 폰트 유틸리티 클래스 */ +.dfx-mono { + font-family: var(--dfx-font-mono); + font-size: 12.5px; +} + +/* 플랫 모달(Modal) 팝업창 스타일 */ +.modal-content { + background-color: var(--dfx-surface-1); + border: 1px solid var(--dfx-border-strong); + box-shadow: none !important; +} + +.modal-header { + background-color: var(--dfx-surface-2); + border-bottom: 1px solid var(--dfx-border); + padding: 0.75rem 1.25rem; +} + +.modal-footer { + background-color: var(--dfx-surface-2); + border-top: 1px solid var(--dfx-border); +} + +/* 플랫 탭(Nav Tabs) 스타일 (활성 탭 상단 2px 파란색 라인 강조) */ +.nav-tabs { + border-bottom: 1px solid var(--dfx-border); + gap: 2px; +} + +.nav-tabs .nav-link { + background-color: var(--dfx-surface-2); + border: 1px solid var(--dfx-border); + border-bottom: none; + color: var(--dfx-text-secondary); + font-weight: 600; + font-size: 13px; + padding: 0.55rem 1.15rem; + margin-bottom: 0; +} + +.nav-tabs .nav-link:hover { + background-color: var(--dfx-surface-3); + color: var(--dfx-text-primary); + border-color: var(--dfx-border-strong); +} + +.nav-tabs .nav-link.active { + background-color: var(--dfx-surface-1); + color: #ffffff; + border-color: var(--dfx-border-strong); + border-top: 2px solid var(--dfx-primary); +} + diff --git a/src/main/front/src/components/AgentConfigView.vue b/src/main/front/src/components/AgentConfigView.vue index d0a30b1..fa3cb1b 100644 --- a/src/main/front/src/components/AgentConfigView.vue +++ b/src/main/front/src/components/AgentConfigView.vue @@ -1,30 +1,43 @@ diff --git a/src/main/front/src/components/MessageDataView.vue b/src/main/front/src/components/MessageDataView.vue index 138bdb9..2de3221 100644 --- a/src/main/front/src/components/MessageDataView.vue +++ b/src/main/front/src/components/MessageDataView.vue @@ -1,28 +1,35 @@ diff --git a/src/main/front/src/components/Pagination.vue b/src/main/front/src/components/Pagination.vue index 4a6cd70..9297628 100644 --- a/src/main/front/src/components/Pagination.vue +++ b/src/main/front/src/components/Pagination.vue @@ -1,15 +1,28 @@ - + diff --git a/src/main/front/src/components/SearchParameterDto.js b/src/main/front/src/components/SearchParameterDto.js index dc725eb..8cb8913 100644 --- a/src/main/front/src/components/SearchParameterDto.js +++ b/src/main/front/src/components/SearchParameterDto.js @@ -1,18 +1,33 @@ +/** + * 목록 조회 및 페이징 처리를 위한 검색 파라미터 DTO 생성자 + * - 백엔드 목록 조회 API와 연동하여 현재 페이지, 페이지당 행 수, 전체 건수 및 검색 조건(parameters)을 관리합니다. + */ function SearchParameterDto() { - this.page = 1 - this.itemCountPerPage = 20 - this.totalItemCount = 0 - this.pageList = [1] - this.parameters = {} + this.page = 1 // 현재 선택된 페이지 번호 (1부터 시작) + this.itemCountPerPage = 20 // 페이지당 표시할 항목 개수 (기본값: 20) + this.totalItemCount = 0 // 전체 검색 결과 항목 수 + this.pageList = [1] // 하단 페이지네이션 바에 표시할 페이지 번호 배열 (최대 10개 단위 블록) + this.parameters = {} // 검색 필터 조건을 담는 Key-Value 객체 } +/** + * 전체 데이터 건수를 기반으로 페이지네이션 번호 목록(pageList)을 계산합니다. + * @param {number} currentPage 이동하려는 현재 페이지 번호 + * @param {number} currentItemCountPerPage 페이지당 표시할 데이터 개수 (기본값: 20) + * @param {number} currentTotalItemCount 서버에서 조회된 전체 데이터 건수 + */ SearchParameterDto.prototype.calculatePageList = function (currentPage, currentItemCountPerPage = 20, currentTotalItemCount) { this.itemCountPerPage = currentItemCountPerPage this.totalItemCount = currentTotalItemCount if (this.totalItemCount > 0) { + // 전체 페이지 수 산출 let pageSize = Math.ceil(this.totalItemCount / this.itemCountPerPage) + 1 pageSize = pageSize < 1 ? 1 : pageSize + + // 유효 범위 내로 현재 페이지 번호 보정 this.page = currentPage >= 1 && currentPage <= pageSize ? currentPage : 1 + + // 10페이지 단위 블록의 시작 페이지와 끝 페이지 계산 (예: 1~10, 11~20) let startPage = Math.floor((this.page - 1) / 10) * 10 + 1 let lastPage = Math.min(startPage + 9, pageSize) let pageArray = [] @@ -21,19 +36,33 @@ SearchParameterDto.prototype.calculatePageList = function (currentPage, currentI } this.pageList = pageArray } else { + // 데이터가 없을 경우 기본 1페이지만 표시 this.pageList = [1] this.page = 1 } } +/** + * 검색 조건 파라미터 설정 + * @param {string} key 검색 조건 키 (예: 'processStatusCode') + * @param {*} value 검색 조건 값 + */ SearchParameterDto.prototype.setParameter = function (key, value) { this.parameters[key] = value } +/** + * 설정된 검색 조건 파라미터 조회 + * @param {string} key 검색 조건 키 + * @returns {*} 설정된 검색 조건 값 + */ SearchParameterDto.prototype.getParameter = function (key) { return this.parameters[key] } +/** + * 모든 검색 조건 파라미터 초기화 + */ SearchParameterDto.prototype.clearParameter = function () { this.parameters = {} } diff --git a/src/main/front/src/components/apiClient.js b/src/main/front/src/components/apiClient.js index 6ddd8cd..7cfcde9 100644 --- a/src/main/front/src/components/apiClient.js +++ b/src/main/front/src/components/apiClient.js @@ -1,9 +1,16 @@ import axios from 'axios' import router from '@/router' +// JSON 통신을 기본으로 하는 공통 Axios 클라이언트 인스턴스 생성 const apiClient = axios.create({ headers: { 'Content-Type': 'application/json' } }) +// 중복 인증 오류 처리를 방지하기 위한 플래그 let isHandlingAuthError = false + +/** + * 401(미인증) 또는 403(권한 없음) 발생 시 로그아웃 처리 후 로그인 화면으로 이동 + * @param {number} status HTTP 응답 상태 코드 + */ async function handleAuthError(status) { if (isHandlingAuthError) { return @@ -13,11 +20,12 @@ async function handleAuthError(status) { try { await apiClient.post('/public-api/logoutProcess') } catch (e) { - // 로그아웃 안되도 무시 + // 서버 세션 만료 등으로 로그아웃 요청이 실패하더라도 무시하고 진행 } - if (router.currentRoute.value.path != '/login') { + // 현재 화면이 로그인 페이지가 아니면 로그인 페이지(/login.html)로 이동 + if (router.currentRoute.value.path != '/login.html') { router.replace({ - path: '/login', + path: '/login.html', query: { reason: String(status), }, @@ -30,11 +38,12 @@ async function handleAuthError(status) { } } +// 응답 인터셉터: API 호출 중 세션 만료(401/403) 감지 시 자동 로그인 페이지 리다이렉트 apiClient.interceptors.response.use( (response) => response, async (error) => { const status = error?.response?.status - // 기타 오류 처리시 로직 추가 + // 인증/인가 오류 발생 시 공통 처리 if (status === 401 || status === 403) { await handleAuthError(status) } diff --git a/src/main/front/src/components/userInfo.js b/src/main/front/src/components/userInfo.js index c6250a4..b8eeadf 100644 --- a/src/main/front/src/components/userInfo.js +++ b/src/main/front/src/components/userInfo.js @@ -1,14 +1,30 @@ import { reactive } from 'vue' import axios from 'axios' +/** + * 전역 사용자 인증 상태 (Reactive State) + * - isLogin: 현재 세션이 로그인(인증) 상태인지 여부 + * - userId : 로그인한 사용자의 계정 ID (예: 이메일 또는 계정명) + */ const userInfo = reactive({ isLogin: false, userId: '', }) +/** + * 사용자 인증 및 세션 관리 API 모음 + * - 로그인, 로그아웃, 세션 유효성 확인, 비밀번호 암호화 유틸리티 제공 + */ export const userApi = { + /** + * 로그인 처리 요청 + * @param {string} inputUsername 사용자 계정 ID + * @param {string} inputUserPassword 사용자 비밀번호 + * @returns {Promise} 로그인 성공 여부 + */ loginProcess: async function (inputUsername, inputUserPassword) { const data = { username: inputUsername, password: inputUserPassword } + // 세션 쿠키(JSESSIONID 등)를 포함하여 로그인 API 호출 const response = await axios.post('/public-api/loginProcess', data, { withCredentials: true }) if (response.status == 200 && response.data && response.data.success == true) { userInfo.isLogin = true @@ -18,6 +34,12 @@ export const userApi = { return false } }, + + /** + * 로그아웃 처리 요청 + * - 서버 세션을 만료시키고 프론트엔드 인증 상태를 초기화합니다. + * @returns {Promise} 로그아웃 성공 여부 + */ logoutProcess: async function () { const response = await axios.post('/public-api/logoutProcess') if (response.status == 200 && response.data && response.data.success == true) { @@ -28,6 +50,12 @@ export const userApi = { return false } }, + + /** + * 현재 브라우저 세션의 로그인 유효성 확인 + * - 화면 새로고침(F5) 또는 라우트 이동 시 서버 세션이 유지되고 있는지 검증합니다. + * @returns {Promise} 유효한 로그인 세션 존재 여부 + */ loginCheck: async function () { const response = await axios.get('/public-api/sessionCheck', { withCredentials: true }) if (response.status == 200 && response.data && response.data.success == true) { @@ -40,11 +68,20 @@ export const userApi = { return false } }, + + /** + * 평문 비밀번호를 서버 암호화 유틸리티를 통해 암호문으로 변환 + * @param {string} plainText 평문 문자열 + * @returns {Promise} 암호화된 문자열 + */ getEncryptText: async function (plainText) { const response = await axios.post('/public-api/passwordGenerator', { password: plainText }) return response.data.password }, } + +// 디버깅 및 전역 참조를 위해 window 객체에 노출 window.userApi = userApi window.userInfo = userInfo + export default userInfo diff --git a/src/main/front/src/main.js b/src/main/front/src/main.js index 4ca36df..4e21289 100644 --- a/src/main/front/src/main.js +++ b/src/main/front/src/main.js @@ -1,14 +1,24 @@ +/** + * DFX Console 프론트엔드 엔트리 포인트 (main.js) + * - Bootstrap CSS/JS, Bootstrap Icons 및 DFX Flat Design 전역 스타일시트 로드 + * - Vue 3 애플리케이션 인스턴스 생성, Vue Router 연결, 전역 API 클라이언트 등록 및 DOM 마운트 + */ import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap' import 'bootstrap-icons/font/bootstrap-icons.css' import './assets/app.css' import './assets/main.css' -import { createApp, getCurrentInstance } from 'vue' +import { createApp } from 'vue' import App from './App.vue' import router from './router' import apiClient from './components/apiClient' +// 루트 컴포넌트(App.vue)를 기반으로 Vue 앱 생성 const app = createApp(App) + +// 모든 컴포넌트에서 $apiClient로 Axios 인스턴스에 접근할 수 있도록 전역 속성에 등록 app.config.globalProperties.$apiClient = apiClient + +// SPA 라우터 플러그인 등록 후 #app 엘리먼트에 마운트 app.use(router) app.mount('#app') diff --git a/src/main/front/src/router/index.js b/src/main/front/src/router/index.js index d489b21..f5a5f5f 100644 --- a/src/main/front/src/router/index.js +++ b/src/main/front/src/router/index.js @@ -1,18 +1,31 @@ import { createRouter, createWebHistory } from 'vue-router' import LoginView from '@/views/LoginView.vue' import MainView from '@/views/MainView.vue' -import userInfo from '@/components/userInfo' +import userInfo, { userApi } from '@/components/userInfo' +/** + * Vue Router 설정 + * - 각 메뉴 URL(/.html)과 MainView에 전달할 화면 식별자(contentId)를 매핑합니다. + * - meta.isRequiredAuth 값으로 로그인 인증이 필요한 화면인지 구분합니다. + */ const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { + // 루트('/') 접속 시 기본 메인(대시보드) 화면으로 리다이렉트 + path: '/', + redirect: '/main.html', + }, + { + // 로그인 화면 (/login.html 및 /login 별칭 지원) path: '/login.html', + alias: ['/login'], name: 'login', component: LoginView, meta: { isRequiredAuth: false }, }, { + // 대시보드 화면 path: '/main.html', name: 'main', component: MainView, @@ -20,6 +33,7 @@ const router = createRouter({ meta: { isRequiredAuth: true }, }, { + // 에이전트 관리 화면 path: '/agent-manage.html', name: 'agent-manage', component: MainView, @@ -27,6 +41,7 @@ const router = createRouter({ meta: { isRequiredAuth: true }, }, { + // 암호화 키 관리 화면 path: '/key-manage.html', name: 'key-manage', component: MainView, @@ -34,6 +49,7 @@ const router = createRouter({ meta: { isRequiredAuth: true }, }, { + // 송수신 메시지 이력 조회 화면 path: '/message-history.html', name: 'message-history', component: MainView, @@ -43,10 +59,30 @@ const router = createRouter({ ], }) -router.beforeEach((to, from, next) => { +// 브라우저 최초 진입 또는 새로고침(F5) 시 서버 세션 확인 여부를 추적하는 플래그 +let isInitialSessionChecked = false + +/** + * 전역 라우터 가드 (화면 이동 전 인증 상태 확인) + * 1. 최초 진입/새로고침이거나, 인증이 필요한 화면인데 클라이언트 로그인 상태(userInfo.isLogin)가 false인 경우 + * 서버의 /public-api/sessionCheck API를 호출하여 실제 세션 유효 여부를 먼저 동기화합니다. + * 2. 인증이 필요한 화면에 비로그인 상태로 접근 시 '/login.html'로 이동시킵니다. + * 3. 이미 로그인된 상태에서 로그인 페이지('/login.html')나 루트('/')로 접근 시 '/main.html'로 이동시킵니다. + * 4. 그 외(로그인 상태에서 '/agent-manage.html' 등 새로고침 포함)에는 현재 요청한 URL을 그대로 유지합니다. + */ +router.beforeEach(async (to, from, next) => { + if (!isInitialSessionChecked || (!userInfo.isLogin && to.meta.isRequiredAuth)) { + isInitialSessionChecked = true + try { + await userApi.loginCheck() + } catch (e) { + // 세션 확인 중 네트워크 오류 등이 발생하면 비로그인 상태로 간주 + } + } + if (to.meta.isRequiredAuth && !userInfo.isLogin) { next('/login.html') - } else if ((to.path === '/login.html' || to.path === '/') && userInfo.isLogin) { + } else if ((to.path === '/login.html' || to.path === '/login' || to.path === '/') && userInfo.isLogin) { next('/main.html') } else { next() diff --git a/src/main/front/src/views/AgentManageView.vue b/src/main/front/src/views/AgentManageView.vue index 3dc8bf6..a0f3cf1 100644 --- a/src/main/front/src/views/AgentManageView.vue +++ b/src/main/front/src/views/AgentManageView.vue @@ -1,70 +1,181 @@ diff --git a/src/main/front/src/views/CryptoKeyManageView.vue b/src/main/front/src/views/CryptoKeyManageView.vue index 346d095..c9bface 100644 --- a/src/main/front/src/views/CryptoKeyManageView.vue +++ b/src/main/front/src/views/CryptoKeyManageView.vue @@ -1,4 +1,8 @@