Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19c17f4e91 | |||
| c2d84bb024 | |||
| 6434d48b05 | |||
| d5f0f3aab4 | |||
| 24c669b0be | |||
| 3eedc522e2 | |||
| a361cc3dec | |||
| 1ac9fa5407 | |||
| 9981459691 | |||
| a2878f9cee | |||
| 1f10dda993 | |||
| a6807a37c2 | |||
| 6c10ae12c7 | |||
| 01c4a80182 | |||
| a5611cf775 | |||
| c733e6b200 | |||
| a2813f391c | |||
| f221c20ece | |||
| 4a3b5b43f3 | |||
| 8de0d94063 | |||
| 57d4efb4eb | |||
| c865359819 | |||
| 41391c8df0 | |||
| 9f4874fe44 | |||
| 43d915342e | |||
| ba00b80bfe | |||
| 87763ba325 | |||
| 5b7a4a108d | |||
| 9b9bcb5be4 | |||
| bdb249b74b | |||
| 93f7c210bc | |||
| bb79b55f08 | |||
| 23bd0c1207 | |||
| 28a9ff1a7f | |||
| 045f354e5c | |||
| 4f577c8c14 | |||
| cc4c1a6b53 | |||
| 551a2da717 | |||
| 1c57d348e6 | |||
| 2c29c8a466 | |||
| d2052d0e16 | |||
| 9c4f3cfb04 |
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
|
|||||||
)
|
)
|
||||||
;
|
;
|
||||||
|
|
||||||
create table DVPOWN.PT_TOKEN
|
create table PT_TOKEN
|
||||||
|
(
|
||||||
(
|
(
|
||||||
TOKEN VARCHAR2(255) not null
|
TOKEN VARCHAR2(255) not null
|
||||||
primary key,
|
primary key,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
|
|||||||
|
|
||||||
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
||||||
|
|
||||||
|
public PortalApplication() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|
||||||
portal_logger.info("##### PortalApplication Start #####");
|
portal_logger.info("##### PortalApplication Start #####");
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package com.eactive.apim.portal.apps.apis.filter;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.TextNode;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 테스트베드(/api/call-api) 감사(audit) 로그 기록기.
|
||||||
|
*
|
||||||
|
* <p>logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다.
|
||||||
|
* 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.</p>
|
||||||
|
*
|
||||||
|
* <p>마스킹 정책:</p>
|
||||||
|
* <ul>
|
||||||
|
* <li>secret 계열 키(client_secret, password, api_key, authorization 등)의 값은 전체 마스킹</li>
|
||||||
|
* <li>그 외 파라미터/JSON 값은 앞 일부만 남기고 마스킹</li>
|
||||||
|
* <li>JSON 이 아닌 본문은 전체 길이(byte)와 앞 {@value #NON_JSON_PREVIEW_LENGTH}글자만 남기고 마스킹</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class ApiTesterAuditLogger {
|
||||||
|
|
||||||
|
private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit");
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/** 값 전체를 마스킹할 키(소문자 비교) */
|
||||||
|
private static final Set<String> SECRET_KEYS = new HashSet<>(Arrays.asList(
|
||||||
|
"client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
|
||||||
|
"api_key", "apikey", "access_token", "refresh_token", "authorization"));
|
||||||
|
|
||||||
|
/** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */
|
||||||
|
private static final String[] AUDIT_HEADERS = {
|
||||||
|
"content-type", "accept", "referer", "origin", "x-forwarded-for",
|
||||||
|
"original-api-id", "authorization"};
|
||||||
|
|
||||||
|
/** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */
|
||||||
|
private static final int JSON_LOG_MAX_LENGTH = 2000;
|
||||||
|
|
||||||
|
/** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */
|
||||||
|
private static final int NON_JSON_PREVIEW_LENGTH = 8;
|
||||||
|
|
||||||
|
private ApiTesterAuditLogger() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** REQ/RES 두 줄을 연결하는 짧은 감사 ID */
|
||||||
|
public static String newAuditId() {
|
||||||
|
return UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다.
|
||||||
|
*
|
||||||
|
* @param targetUrl original-url 헤더 값 (없으면 null)
|
||||||
|
* @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-")
|
||||||
|
* @param tokenRequest OAuth 토큰 발급 요청 여부
|
||||||
|
* @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열)
|
||||||
|
*/
|
||||||
|
public static void logRequest(String auditId, HttpServletRequest request, String targetUrl,
|
||||||
|
String gatewayMode, boolean tokenRequest, String body) {
|
||||||
|
try {
|
||||||
|
StringBuilder sb = new StringBuilder(256);
|
||||||
|
sb.append("REQ [").append(auditId).append(']');
|
||||||
|
sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request));
|
||||||
|
sb.append(" proxied=").append(HttpRequestUtil.isProxied(request));
|
||||||
|
sb.append(" user=").append(currentUser());
|
||||||
|
sb.append(" method=").append(request.getMethod());
|
||||||
|
sb.append(" mode=").append(gatewayMode);
|
||||||
|
sb.append(" token=").append(tokenRequest);
|
||||||
|
sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl)));
|
||||||
|
sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"');
|
||||||
|
sb.append(" headers=").append(buildHeaderSummary(request));
|
||||||
|
sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body));
|
||||||
|
auditLogger.info(sb.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */
|
||||||
|
public static void logResult(String auditId, int status, String type, long elapsedMillis) {
|
||||||
|
auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 요청 정보 구성
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */
|
||||||
|
private static String currentUser() {
|
||||||
|
try {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) {
|
||||||
|
return "anonymous";
|
||||||
|
}
|
||||||
|
String name = auth.getName();
|
||||||
|
return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */
|
||||||
|
private static String buildHeaderSummary(HttpServletRequest request) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
boolean first = true;
|
||||||
|
for (String name : AUDIT_HEADERS) {
|
||||||
|
String value = request.getHeader(name);
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!first) {
|
||||||
|
sb.append(", ");
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"');
|
||||||
|
}
|
||||||
|
return sb.append('}').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */
|
||||||
|
private static String maskHeaderValue(String name, String value) {
|
||||||
|
if (!SECRET_KEYS.contains(name.toLowerCase())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
int space = value.indexOf(' ');
|
||||||
|
if (space > 0) {
|
||||||
|
return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim());
|
||||||
|
}
|
||||||
|
return partialMask(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 본문 마스킹
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private static String buildBodySummary(String contentType, boolean tokenRequest, String body) {
|
||||||
|
if (body == null || body.isEmpty()) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
// 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹)
|
||||||
|
if (tokenRequest) {
|
||||||
|
return "\"" + maskFormBody(body) + "\"";
|
||||||
|
}
|
||||||
|
// 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만
|
||||||
|
if (contentType != null && contentType.toLowerCase().contains("json")) {
|
||||||
|
String maskedJson = tryMaskJson(body);
|
||||||
|
if (maskedJson != null) {
|
||||||
|
return maskedJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nonJsonSummary(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */
|
||||||
|
private static String maskFormBody(String body) {
|
||||||
|
StringBuilder sb = new StringBuilder(body.length());
|
||||||
|
String[] pairs = body.split("&");
|
||||||
|
for (int i = 0; i < pairs.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append('&');
|
||||||
|
}
|
||||||
|
int eq = pairs[i].indexOf('=');
|
||||||
|
if (eq < 0) {
|
||||||
|
sb.append(partialMask(pairs[i]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = pairs[i].substring(0, eq);
|
||||||
|
String value = pairs[i].substring(eq + 1);
|
||||||
|
sb.append(key).append('=');
|
||||||
|
sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value));
|
||||||
|
}
|
||||||
|
return sanitize(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */
|
||||||
|
private static String maskQueryValues(String url) {
|
||||||
|
int qs = url.indexOf('?');
|
||||||
|
if (qs < 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */
|
||||||
|
private static String tryMaskJson(String body) {
|
||||||
|
try {
|
||||||
|
JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body));
|
||||||
|
String out = OBJECT_MAPPER.writeValueAsString(masked);
|
||||||
|
if (out.length() > JSON_LOG_MAX_LENGTH) {
|
||||||
|
out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */
|
||||||
|
private static JsonNode maskJsonNode(JsonNode node) {
|
||||||
|
if (node.isObject()) {
|
||||||
|
ObjectNode obj = (ObjectNode) node;
|
||||||
|
Iterator<String> names = obj.fieldNames();
|
||||||
|
Set<String> fieldNames = new HashSet<>();
|
||||||
|
while (names.hasNext()) {
|
||||||
|
fieldNames.add(names.next());
|
||||||
|
}
|
||||||
|
for (String field : fieldNames) {
|
||||||
|
if (SECRET_KEYS.contains(field.toLowerCase())) {
|
||||||
|
obj.set(field, TextNode.valueOf("*****"));
|
||||||
|
} else {
|
||||||
|
obj.set(field, maskJsonNode(obj.get(field)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
ArrayNode arr = (ArrayNode) node;
|
||||||
|
for (int i = 0; i < arr.size(); i++) {
|
||||||
|
arr.set(i, maskJsonNode(arr.get(i)));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
if (node.isNull() || node.isMissingNode()) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
return TextNode.valueOf(partialMask(node.asText()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */
|
||||||
|
private static String nonJsonSummary(String body) {
|
||||||
|
int bytes = body.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
String preview = body.length() <= NON_JSON_PREVIEW_LENGTH
|
||||||
|
? body : body.substring(0, NON_JSON_PREVIEW_LENGTH);
|
||||||
|
return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")";
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 공통 helper
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */
|
||||||
|
private static String partialMask(String value) {
|
||||||
|
if (value == null || value.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int len = value.length();
|
||||||
|
if (len <= 2) {
|
||||||
|
return stars(len);
|
||||||
|
}
|
||||||
|
int visible = Math.min(4, Math.max(1, len / 3));
|
||||||
|
return value.substring(0, visible) + "***";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stars(int count) {
|
||||||
|
char[] arr = new char[count];
|
||||||
|
Arrays.fill(arr, '*');
|
||||||
|
return new String(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */
|
||||||
|
private static String sanitize(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder(value.length());
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
char c = value.charAt(i);
|
||||||
|
if (c == '"') {
|
||||||
|
sb.append('\'');
|
||||||
|
} else if (c == '\r' || c == '\n' || c == '\t') {
|
||||||
|
sb.append(' ');
|
||||||
|
} else if (c < 0x20) {
|
||||||
|
sb.append('?');
|
||||||
|
} else {
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,8 +68,16 @@ public class ApiTesterFilter implements Filter {
|
|||||||
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
||||||
String url = httpServletRequest.getHeader("original-url");
|
String url = httpServletRequest.getHeader("original-url");
|
||||||
|
|
||||||
|
// 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관)
|
||||||
|
String auditId = ApiTesterAuditLogger.newAuditId();
|
||||||
|
long auditStart = System.currentTimeMillis();
|
||||||
|
String auditType = "-";
|
||||||
|
|
||||||
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||||
if (url == null || url.trim().isEmpty()) {
|
if (url == null || url.trim().isEmpty()) {
|
||||||
|
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null);
|
||||||
|
ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST",
|
||||||
|
System.currentTimeMillis() - auditStart);
|
||||||
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -84,16 +92,15 @@ public class ApiTesterFilter implements Filter {
|
|||||||
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||||
|| url.contains(gatewayProperty.tokenPath());
|
|| url.contains(gatewayProperty.tokenPath());
|
||||||
|
|
||||||
|
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
|
||||||
|
String requestBody = readBody(httpServletRequest);
|
||||||
|
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
|
||||||
|
|
||||||
if (tokenRequest) {
|
if (tokenRequest) {
|
||||||
StringBuilder sb = new StringBuilder();
|
String body = requestBody;
|
||||||
BufferedReader reader = httpServletRequest.getReader();
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
sb.append(line);
|
|
||||||
}
|
|
||||||
String body = sb.toString();
|
|
||||||
|
|
||||||
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||||
|
auditType = "TOKEN_MOCK";
|
||||||
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||||
Map<String, String> params = new HashMap<>();
|
Map<String, String> params = new HashMap<>();
|
||||||
String[] pairs = body.split("&");
|
String[] pairs = body.split("&");
|
||||||
@@ -106,7 +113,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
String scope = params.getOrDefault("scope", "default");
|
String scope = params.getOrDefault("scope", "default");
|
||||||
|
|
||||||
String token = "{\n" +
|
String token = "{\n" +
|
||||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
" \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
|
||||||
" \"token_type\": \"bearer\",\n" +
|
" \"token_type\": \"bearer\",\n" +
|
||||||
" \"expires_in\": 86400,\n" +
|
" \"expires_in\": 86400,\n" +
|
||||||
" \"scope\": \""+scope +"\",\n" +
|
" \"scope\": \""+scope +"\",\n" +
|
||||||
@@ -117,6 +124,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
response.getWriter().println(token);
|
response.getWriter().println(token);
|
||||||
} else {
|
} else {
|
||||||
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||||
|
auditType = "TOKEN_GW";
|
||||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||||
Map<String, String> headers = new HashMap<>();
|
Map<String, String> headers = new HashMap<>();
|
||||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||||
@@ -133,6 +141,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
|
|
||||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||||
if (apiSpecInfoDto == null) {
|
if (apiSpecInfoDto == null) {
|
||||||
|
auditType = "SPEC_NOT_FOUND";
|
||||||
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||||
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||||
return;
|
return;
|
||||||
@@ -142,6 +151,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
|
|
||||||
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||||
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||||
|
auditType = "SAMPLE";
|
||||||
response.setContentType("application/json");
|
response.setContentType("application/json");
|
||||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||||
return;
|
return;
|
||||||
@@ -151,6 +161,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||||
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||||
boolean gw = "gw".equalsIgnoreCase(responseType);
|
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||||
|
auditType = gw ? "GW" : "MOCK";
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
Map<String, String> headers = new HashMap<>();
|
||||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||||
@@ -160,6 +171,10 @@ public class ApiTesterFilter implements Filter {
|
|||||||
}
|
}
|
||||||
headers.remove("original-url");
|
headers.remove("original-url");
|
||||||
headers.remove("original-api-id");
|
headers.remove("original-api-id");
|
||||||
|
// readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고,
|
||||||
|
// WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는
|
||||||
|
// 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정).
|
||||||
|
headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k));
|
||||||
|
|
||||||
String targetUri;
|
String targetUri;
|
||||||
Map<String, String[]> paramMap;
|
Map<String, String[]> paramMap;
|
||||||
@@ -179,7 +194,7 @@ public class ApiTesterFilter implements Filter {
|
|||||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||||
String responseStr;
|
String responseStr;
|
||||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
|
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||||
} else {
|
} else {
|
||||||
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||||
}
|
}
|
||||||
@@ -202,6 +217,9 @@ public class ApiTesterFilter implements Filter {
|
|||||||
logger.error("테스트베드 프록시 처리 오류", e);
|
logger.error("테스트베드 프록시 처리 오류", e);
|
||||||
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||||
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||||
|
} finally {
|
||||||
|
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
|
||||||
|
System.currentTimeMillis() - auditStart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.web.csrf.CsrfToken;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -25,6 +26,8 @@ import java.util.Optional;
|
|||||||
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||||
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||||
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
||||||
|
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
|
||||||
|
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -109,6 +112,37 @@ public class SessionApiController {
|
|||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 익명(비로그인) 페이지용 세션 keepalive ping.
|
||||||
|
* 요청이 기존 세션에 접근하는 것만으로 컨테이너의 세션 비활성 타이머가 리셋되어
|
||||||
|
* 익명 세션(세션 저장 CSRF 토큰, 회원가입 본인인증 상태 포함)이 유지된다.
|
||||||
|
* 세션이 없으면 새로 만들지 않는다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/ping")
|
||||||
|
public ResponseEntity<Void> ping(HttpServletRequest request) {
|
||||||
|
request.getSession(false);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망).
|
||||||
|
* 세션 만료로 토큰이 사라진 경우 CsrfFilter가 새 토큰을 생성하고,
|
||||||
|
* 이 핸들러가 토큰 값을 읽는 시점에 새 세션에 저장된다(LazyCsrfTokenRepository).
|
||||||
|
* 회원가입 절차는 세션에 본인인증 상태를 들고 있어 토큰 재발급만으로는 복구가 안 되므로
|
||||||
|
* 로그인 페이지 안전망으로만 사용한다.
|
||||||
|
*/
|
||||||
|
@GetMapping("/csrf")
|
||||||
|
public ResponseEntity<Map<String, String>> csrfToken(HttpServletRequest request) {
|
||||||
|
CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
|
||||||
|
Map<String, String> result = new HashMap<>();
|
||||||
|
if (token != null) {
|
||||||
|
result.put("headerName", token.getHeaderName());
|
||||||
|
result.put("parameterName", token.getParameterName());
|
||||||
|
result.put("token", token.getToken());
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
* IP 주소 마스킹 (3번째 옥텟을 ***로 치환)
|
||||||
* 예: 192.168.240.178 → 192.168.***.178
|
* 예: 192.168.240.178 → 192.168.***.178
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import java.util.Optional;
|
|||||||
public class UserSessionService {
|
public class UserSessionService {
|
||||||
|
|
||||||
private static final String PROPERTY_GROUP = "Portal";
|
private static final String PROPERTY_GROUP = "Portal";
|
||||||
private static final String PROPERTY_NAME = "session.timeout.minutes";
|
|
||||||
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
|
/** 세션 타임아웃(분) 고정값. application.yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일하게 유지한다. */
|
||||||
|
public static final int SESSION_TIMEOUT_MINUTES = 10;
|
||||||
|
|
||||||
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
|
||||||
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
|
||||||
@@ -120,21 +121,10 @@ public class UserSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DB(PortalProperty)에서 세션 타임아웃 값 조회 (분)
|
* 세션 타임아웃(분). {@value #SESSION_TIMEOUT_MINUTES}분 고정 (DB property 관리 폐지).
|
||||||
*/
|
*/
|
||||||
public int getSessionTimeoutMinutes() {
|
public int getSessionTimeoutMinutes() {
|
||||||
String value = portalPropertyService.getOrCreateProperty(
|
return SESSION_TIMEOUT_MINUTES;
|
||||||
PROPERTY_GROUP,
|
|
||||||
PROPERTY_NAME,
|
|
||||||
DEFAULT_TIMEOUT_MINUTES,
|
|
||||||
"세션 타임아웃 시간 (분)"
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
return Integer.parseInt(value.trim());
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
log.warn("세션 타임아웃 값 파싱 실패: {}, 기본값 {}분 사용", value, DEFAULT_TIMEOUT_MINUTES);
|
|
||||||
return Integer.parseInt(DEFAULT_TIMEOUT_MINUTES);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class GlobalControllerAdvice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
|
* 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
|
||||||
*/
|
*/
|
||||||
@ModelAttribute("sessionTimeoutMinutes")
|
@ModelAttribute("sessionTimeoutMinutes")
|
||||||
public int sessionTimeoutMinutes() {
|
public int sessionTimeoutMinutes() {
|
||||||
|
|||||||
+9
@@ -7,6 +7,7 @@ import java.util.Map;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
import com.eactive.apim.portal.config.PortalProperties;
|
import com.eactive.apim.portal.config.PortalProperties;
|
||||||
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -54,8 +55,16 @@ public class PortalGlobalExceptionHandler {
|
|||||||
|
|
||||||
@ExceptionHandler(value = AccessDeniedException.class)
|
@ExceptionHandler(value = AccessDeniedException.class)
|
||||||
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
|
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
|
||||||
|
// 미로그인 사용자는 로그인 페이지로 유도, 로그인 상태에서의 권한 부족은 오류 안내 페이지로 표시한다.
|
||||||
|
if (!SecurityUtil.isAuthenticated()) {
|
||||||
return new ModelAndView("redirect:/login");
|
return new ModelAndView("redirect:/login");
|
||||||
}
|
}
|
||||||
|
log.warn("접근 권한 없음: loginId={}, uri={}", SecurityUtil.getCurrentLoginId(), request.getRequestURI());
|
||||||
|
ModelAndView modelAndView = new ModelAndView("error");
|
||||||
|
modelAndView.addObject("errorTitle", "페이지 접근 권한이 없습니다.");
|
||||||
|
modelAndView.addObject("errorDescription", "해당 페이지를 이용할 수 있는 권한이 없는 계정입니다.\n권한이 필요한 경우 관리자에게 문의해 주세요.");
|
||||||
|
return modelAndView;
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(value = PortalRedirectException.class)
|
@ExceptionHandler(value = PortalRedirectException.class)
|
||||||
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
|
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
|
|||||||
persistenceUnit = "gateway";
|
persistenceUnit = "gateway";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 개발 환경용
|
// 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
|
||||||
if (env.matchesProfiles("local")) {
|
if (env.matchesProfiles("local")) {
|
||||||
properties.put("hibernate.hbm2ddl.auto", "validate");
|
properties.put("hibernate.hbm2ddl.auto", "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -122,8 +122,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
|||||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||||
clientIp, request.getHeader("User-Agent"));
|
clientIp, request.getHeader("User-Agent"));
|
||||||
|
|
||||||
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
|
// 물리 세션 타임아웃 10분 고정. yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일 값이지만
|
||||||
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
|
// 컨테이너 설정(콘솔 override 등)과 무관하게 보장하기 위해 명시 적용 → 물리=논리 단일화(CSRF 수명 포함).
|
||||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||||
|
|
||||||
// 로그인 성공 시 세션 정보 로깅
|
// 로그인 성공 시 세션 정보 로깅
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
|||||||
|
|
||||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||||
import org.aspectj.lang.JoinPoint;
|
import org.aspectj.lang.JoinPoint;
|
||||||
import org.aspectj.lang.annotation.Aspect;
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
import org.aspectj.lang.annotation.Before;
|
import org.aspectj.lang.annotation.Before;
|
||||||
@@ -32,7 +33,7 @@ public class PortalConfigLog {
|
|||||||
String query = request.getQueryString();
|
String query = request.getQueryString();
|
||||||
String ip = HttpRequestUtil.getClientIpAddress(request);
|
String ip = HttpRequestUtil.getClientIpAddress(request);
|
||||||
|
|
||||||
String user = SecurityUtil.getCurrentLoginId();
|
String user = StringMaskingUtil.maskEmail(SecurityUtil.getCurrentLoginId());
|
||||||
|
|
||||||
String message = String.format("Request: %s %s?%s from %s by %s", method, path, query, ip, user);
|
String message = String.format("Request: %s %s?%s from %s by %s", method, path, query, ip, user);
|
||||||
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
|
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ public class PortalConfigSecurity {
|
|||||||
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
||||||
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
||||||
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
||||||
|
// 세션에 저장되므로 CSRF 토큰 수명은 세션 타임아웃(10분)과 동일하다.
|
||||||
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
||||||
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
||||||
|
|
||||||
|
|||||||
+10
@@ -32,6 +32,7 @@ public class DjbTestbedGatewayProperty {
|
|||||||
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
|
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
|
||||||
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
|
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
|
||||||
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
|
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
|
||||||
|
public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
|
||||||
|
|
||||||
public static final String DEFAULT_BASE_URL = "PortalMock";
|
public static final String DEFAULT_BASE_URL = "PortalMock";
|
||||||
public static final String DEFAULT_TIMEOUT_SEC = "10";
|
public static final String DEFAULT_TIMEOUT_SEC = "10";
|
||||||
@@ -47,6 +48,9 @@ public class DjbTestbedGatewayProperty {
|
|||||||
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
|
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
|
||||||
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
|
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
|
||||||
|
|
||||||
|
/** PortalMock 모드 토큰 응답의 access_token 기본값. */
|
||||||
|
public static final String DEFAULT_MOCK_ACCESS_TOKEN = "djbank_gw_sample_token";
|
||||||
|
|
||||||
public String baseUrl() {
|
public String baseUrl() {
|
||||||
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
|
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
|
||||||
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
|
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
|
||||||
@@ -86,6 +90,12 @@ public class DjbTestbedGatewayProperty {
|
|||||||
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PortalMock 모드 토큰 응답에 넣을 access_token 값. */
|
||||||
|
public String mockAccessToken() {
|
||||||
|
return resolve(KEY_MOCK_ACCESS_TOKEN, DEFAULT_MOCK_ACCESS_TOKEN,
|
||||||
|
"PortalMock 모드 토큰 발급 응답의 access_token 값");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
|
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
|
||||||
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
|
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
|
||||||
|
|||||||
+25
-8
@@ -25,8 +25,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
|
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
|
||||||
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에
|
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 실주소로 치환해 반환한다.
|
||||||
* 따른 실주소로 치환해 반환한다.
|
* <ul>
|
||||||
|
* <li>{@code swagger.json}/{@code swagger.yaml} : 외부 공개/다운로드용 — 항상 GW 주소로 치환</li>
|
||||||
|
* <li>{@code swagger-ui.json} : Swagger UI 전용 — API SPEC 설정(responseType: sample/mock/gw)에 따라 치환</li>
|
||||||
|
* </ul>
|
||||||
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
|
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@@ -45,22 +48,34 @@ public class DjbTestbedSpecController {
|
|||||||
|
|
||||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||||
String json = buildSpecJson(id, request);
|
String json = buildSpecJson(id, request, true);
|
||||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||||
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||||
String json = buildSpecJson(id, request);
|
String json = buildSpecJson(id, request, true);
|
||||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */
|
/** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
|
||||||
private String buildSpecJson(String id, HttpServletRequest request) throws IOException {
|
@GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||||
|
String json = buildSpecJson(id, request, false);
|
||||||
|
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null.
|
||||||
|
* @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용)
|
||||||
|
*/
|
||||||
|
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException {
|
||||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||||
return serverRewriter.rewriteServer(content, null, request);
|
return alwaysGateway
|
||||||
|
? serverRewriter.rewriteServerToGateway(content, request)
|
||||||
|
: serverRewriter.rewriteServer(content, null, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||||
@@ -70,6 +85,8 @@ public class DjbTestbedSpecController {
|
|||||||
|
|
||||||
DjbAuthType authType = authService.resolveAuthType(id);
|
DjbAuthType authType = authService.resolveAuthType(id);
|
||||||
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
|
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
|
||||||
return serverRewriter.rewriteServer(enriched, spec.get(), request);
|
return alwaysGateway
|
||||||
|
? serverRewriter.rewriteServerToGateway(enriched, request)
|
||||||
|
: serverRewriter.rewriteServer(enriched, spec.get(), request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -50,6 +50,22 @@ public class DjbTestbedSpecServerRewriter {
|
|||||||
return specJson.replace(SERVER_SENTINEL, base);
|
return specJson.replace(SERVER_SENTINEL, base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환.
|
||||||
|
* responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml)
|
||||||
|
* 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.)
|
||||||
|
*/
|
||||||
|
public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
|
||||||
|
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
|
||||||
|
return specJson;
|
||||||
|
}
|
||||||
|
String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request)));
|
||||||
|
if (!StringUtils.hasText(base)) {
|
||||||
|
return specJson; // 실주소 미확정 시 sentinel 유지
|
||||||
|
}
|
||||||
|
return specJson.replace(SERVER_SENTINEL, base);
|
||||||
|
}
|
||||||
|
|
||||||
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
|
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
|
||||||
public String toYaml(String specJson) {
|
public String toYaml(String specJson) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.controller;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||||
|
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||||
|
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||||
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.access.annotation.Secured;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.validation.BindingResult;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
import org.springframework.web.bind.annotation.SessionAttributes;
|
||||||
|
import org.springframework.web.bind.support.SessionStatus;
|
||||||
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청/관리 (기업 사용자, ROLE_APP).
|
||||||
|
*
|
||||||
|
* App API Key 신청의 3-Step + 세션 + 비밀번호 재인증 패턴을 답습하되, 승인 워크플로우 없이 즉시 발급한다.
|
||||||
|
* 참조: {@code apps/app/controller/MyAppController}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/webhook")
|
||||||
|
@Secured("ROLE_API_KEY_REQUEST")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@SessionAttributes({"webhookRegistration", "webhookModification"})
|
||||||
|
public class WebhookController {
|
||||||
|
// 클래스 기본: 법인 관리자(ROLE_API_KEY_REQUEST)만 신청/수정/삭제/Secret 관리 가능.
|
||||||
|
// 조회(index)만 메서드 레벨에서 ROLE_APP 으로 완화(같은 기관 일반 사용자도 열람).
|
||||||
|
|
||||||
|
private static final int TOTAL_STEPS = 3;
|
||||||
|
|
||||||
|
private final WebhookService webhookService;
|
||||||
|
private final WebhookEventTypeProvider eventTypeProvider;
|
||||||
|
private final ApiServiceService apiServiceService;
|
||||||
|
private final AppServiceFacade appServiceFacade;
|
||||||
|
|
||||||
|
@ModelAttribute("webhookRegistration")
|
||||||
|
public WebhookRegistrationDTO webhookRegistration() {
|
||||||
|
return new WebhookRegistrationDTO();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModelAttribute("webhookModification")
|
||||||
|
public WebhookRegistrationDTO webhookModification() {
|
||||||
|
return new WebhookRegistrationDTO();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 조회 ============================
|
||||||
|
|
||||||
|
@Secured("ROLE_APP")
|
||||||
|
@GetMapping
|
||||||
|
public ModelAndView index() {
|
||||||
|
String orgId = currentOrgId();
|
||||||
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(orgId);
|
||||||
|
if (webhook.isPresent()) {
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookList");
|
||||||
|
mav.addObject("webhook", webhook.get());
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
return new ModelAndView("apps/webhook/webhookEmpty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================= 신규 신청 플로우 =======================
|
||||||
|
|
||||||
|
@GetMapping("/register/step1")
|
||||||
|
public ModelAndView registerStep1(
|
||||||
|
@RequestParam(value = "clear", required = false, defaultValue = "false") boolean clear,
|
||||||
|
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||||
|
SessionStatus sessionStatus,
|
||||||
|
Model model) {
|
||||||
|
|
||||||
|
if (webhookService.existsByOrg(currentOrgId())) {
|
||||||
|
return new ModelAndView("redirect:/webhook");
|
||||||
|
}
|
||||||
|
if (clear) {
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
registration = new WebhookRegistrationDTO();
|
||||||
|
model.addAttribute("webhookRegistration", registration);
|
||||||
|
}
|
||||||
|
registration.setRequestType("NEW");
|
||||||
|
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||||
|
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||||
|
addStepModel(mav, 1);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register/step1")
|
||||||
|
public ModelAndView processStep1(
|
||||||
|
@Valid @ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||||
|
BindingResult bindingResult,
|
||||||
|
Model model) {
|
||||||
|
|
||||||
|
validateStep1(registration, bindingResult);
|
||||||
|
if (bindingResult.hasErrors()) {
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||||
|
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||||
|
addStepModel(mav, 1);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/register/step2")
|
||||||
|
public ModelAndView registerStep2(
|
||||||
|
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||||
|
RedirectAttributes redirectAttributes) {
|
||||||
|
|
||||||
|
if (!registration.isStep1Complete()) {
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step1");
|
||||||
|
}
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep2");
|
||||||
|
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||||
|
addStepModel(mav, 2);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀 (App 신청 saveStep2 답습). */
|
||||||
|
@PostMapping("/register/step2/save")
|
||||||
|
public ModelAndView saveStep2(
|
||||||
|
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||||
|
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration) {
|
||||||
|
registration.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register/step2")
|
||||||
|
public ModelAndView processStep2(
|
||||||
|
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||||
|
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||||
|
SessionStatus sessionStatus,
|
||||||
|
RedirectAttributes redirectAttributes) {
|
||||||
|
|
||||||
|
registration.setSelectedApis(selectedApis);
|
||||||
|
if (!registration.isStep2Complete()) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step2");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 평문 Secret 은 화면에 노출하지 않는다 — 목록에서 비밀번호 재인증 후 조회.
|
||||||
|
webhookService.create(registration, currentOrgId());
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
redirectAttributes.addFlashAttribute("registrationSuccess", true);
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step3");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||||
|
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/register/step3")
|
||||||
|
public ModelAndView registerStep3(Model model) {
|
||||||
|
if (!Boolean.TRUE.equals(model.getAttribute("registrationSuccess"))) {
|
||||||
|
return new ModelAndView("redirect:/webhook");
|
||||||
|
}
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep3");
|
||||||
|
addStepModel(mav, 3);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/register/cancel")
|
||||||
|
public String cancelRegistration(SessionStatus sessionStatus) {
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
return "redirect:/webhook";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================= 수정 플로우 =========================
|
||||||
|
|
||||||
|
@GetMapping("/modify/step1")
|
||||||
|
public ModelAndView modifyStep1(
|
||||||
|
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||||
|
Model model) {
|
||||||
|
|
||||||
|
Optional<WebhookDTO> current = webhookService.getByOrg(currentOrgId());
|
||||||
|
if (!current.isPresent()) {
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step1?clear=true");
|
||||||
|
}
|
||||||
|
WebhookDTO webhook = current.get();
|
||||||
|
// 세션에 아직 채워지지 않았으면 현재 등록값으로 초기화
|
||||||
|
if (modification.getId() == null || !webhook.getId().equals(modification.getId())) {
|
||||||
|
modification.setId(webhook.getId());
|
||||||
|
modification.setRequestType("MODIFY");
|
||||||
|
modification.setTargetUrl(webhook.getTargetUrl());
|
||||||
|
modification.setEventTypes(webhook.getEventTypes().stream()
|
||||||
|
.map(e -> e.getCode()).collect(java.util.stream.Collectors.toList()));
|
||||||
|
modification.setSelectedApis(new java.util.ArrayList<>(webhook.getApiIds()));
|
||||||
|
model.addAttribute("webhookModification", modification);
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||||
|
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||||
|
addStepModel(mav, 1);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/modify/step1")
|
||||||
|
public ModelAndView processModifyStep1(
|
||||||
|
@Valid @ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||||
|
BindingResult bindingResult,
|
||||||
|
Model model) {
|
||||||
|
|
||||||
|
validateStep1(modification, bindingResult);
|
||||||
|
if (bindingResult.hasErrors()) {
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||||
|
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||||
|
addStepModel(mav, 1);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/modify/step2")
|
||||||
|
public ModelAndView modifyStep2(
|
||||||
|
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||||
|
|
||||||
|
if (modification.getId() == null || !modification.isStep1Complete()) {
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||||
|
}
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep2");
|
||||||
|
mav.addObject("apiServices", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||||
|
addStepModel(mav, 2);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 수정 Step2 "이전" — 현재 선택을 세션에 저장하고 Step1 로 복귀. */
|
||||||
|
@PostMapping("/modify/step2/save")
|
||||||
|
public ModelAndView saveModifyStep2(
|
||||||
|
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||||
|
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||||
|
modification.setSelectedApis(selectedApis != null ? selectedApis : new java.util.ArrayList<>());
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/modify/step2")
|
||||||
|
public ModelAndView processModifyStep2(
|
||||||
|
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||||
|
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||||
|
SessionStatus sessionStatus,
|
||||||
|
RedirectAttributes redirectAttributes) {
|
||||||
|
|
||||||
|
modification.setSelectedApis(selectedApis);
|
||||||
|
if (modification.getId() == null || !modification.isStep2Complete()) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
webhookService.update(modification.getId(), modification, currentOrgId());
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
redirectAttributes.addFlashAttribute("modifySuccess", true);
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step3");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||||
|
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/modify/step3")
|
||||||
|
public ModelAndView modifyStep3(Model model) {
|
||||||
|
if (!Boolean.TRUE.equals(model.getAttribute("modifySuccess"))) {
|
||||||
|
return new ModelAndView("redirect:/webhook");
|
||||||
|
}
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep3");
|
||||||
|
addStepModel(mav, 3);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/modify/cancel")
|
||||||
|
public String cancelModification(SessionStatus sessionStatus) {
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
return "redirect:/webhook";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AJAX (비밀번호 재인증) ====================
|
||||||
|
|
||||||
|
@PostMapping("/verify-secret")
|
||||||
|
@ResponseBody
|
||||||
|
public Map<String, Object> verifySecret(@RequestParam String password) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
if (!verifyPassword(password)) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||||
|
if (!webhook.isPresent()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "등록된 Webhook이 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/regenerate-secret")
|
||||||
|
@ResponseBody
|
||||||
|
public Map<String, Object> regenerateSecret(@RequestParam String password) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
if (!verifyPassword(password)) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||||
|
if (!webhook.isPresent()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "등록된 Webhook이 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
String secret = webhookService.regenerateSecret(webhook.get().getId(), currentOrgId());
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("secret", secret);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/delete")
|
||||||
|
@ResponseBody
|
||||||
|
public Map<String, Object> delete(@RequestParam String password) {
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
if (!verifyPassword(password)) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "비밀번호가 일치하지 않습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||||
|
if (!webhook.isPresent()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "등록된 Webhook이 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
webhookService.delete(webhook.get().getId(), currentOrgId());
|
||||||
|
result.put("success", true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ helper ============================
|
||||||
|
|
||||||
|
private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) {
|
||||||
|
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
|
||||||
|
if (!bindingResult.hasFieldErrors("targetUrl")
|
||||||
|
&& !url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
bindingResult.rejectValue("targetUrl", "invalid.url",
|
||||||
|
"URL은 http:// 또는 https:// 로 시작해야 합니다.");
|
||||||
|
}
|
||||||
|
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
|
||||||
|
bindingResult.rejectValue("eventTypes", "empty.eventTypes",
|
||||||
|
"EventType을 1개 이상 선택해주세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean verifyPassword(String password) {
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
return appServiceFacade.verifyUserPassword(user, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String currentOrgId() {
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addStepModel(ModelAndView mav, int currentStep) {
|
||||||
|
mav.addObject("currentStep", currentStep);
|
||||||
|
mav.addObject("totalSteps", TOTAL_STEPS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 신청 결과. 평문 {@code secret} 은 발급 직후 1회 노출 목적으로만 전달된다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebhookCreatedResult {
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final String secret;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록된 Webhook 상세 표시용. SECRET 은 마스킹 값만 담고 평문은 별도 재인증 조회로만 노출한다.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class WebhookDTO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String targetUrl;
|
||||||
|
private String secretMasked;
|
||||||
|
private String createdDate;
|
||||||
|
|
||||||
|
/** 구독 API ID 목록. */
|
||||||
|
private List<String> apiIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 구독 EventType(코드+한글명) 목록. */
|
||||||
|
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventType 코드/한글명 쌍. TSEAIRM28(CODEGROUP='EVENT_TYPE') 에서 로드.
|
||||||
|
* {@code selected} 는 신청 화면에서 현재 구독 여부 표시용.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class WebhookEventTypeDTO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String code;
|
||||||
|
private String name;
|
||||||
|
private boolean selected;
|
||||||
|
|
||||||
|
public WebhookEventTypeDTO(String code, String name) {
|
||||||
|
this.code = code;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
import org.hibernate.validator.constraints.NotBlank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청/수정 스텝 간 세션 보존 데이터 (App 신청 {@code ApiKeyRegistrationDTO} 답습).
|
||||||
|
*
|
||||||
|
* Step1: {@code targetUrl} + {@code eventTypes}. Step2: {@code selectedApis}.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class WebhookRegistrationDTO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 수정 시 대상 신청 ID. 신규 신청이면 null. */
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** "NEW" 또는 "MODIFY" */
|
||||||
|
private String requestType;
|
||||||
|
|
||||||
|
@NotBlank(message = "Webhook 수신 URL을 입력해주세요.")
|
||||||
|
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
|
||||||
|
private String targetUrl;
|
||||||
|
|
||||||
|
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
|
||||||
|
private List<String> eventTypes = new ArrayList<>();
|
||||||
|
|
||||||
|
/** Step2: 알림 대상 API ID 목록. */
|
||||||
|
private List<String> selectedApis = new ArrayList<>();
|
||||||
|
|
||||||
|
public boolean isStep1Complete() {
|
||||||
|
return targetUrl != null && !targetUrl.trim().isEmpty()
|
||||||
|
&& eventTypes != null && !eventTypes.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isStep2Complete() {
|
||||||
|
return selectedApis != null && !selectedApis.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isComplete() {
|
||||||
|
return isStep1Complete() && isStep2Complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org 당 Webhook 1건 정책 위반(이미 등록됨).
|
||||||
|
*/
|
||||||
|
public class WebhookAlreadyExistsException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
public WebhookAlreadyExistsException(String orgId) {
|
||||||
|
super("이미 등록된 Webhook이 있습니다. orgId=" + orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대상 Webhook 신청을 찾을 수 없음.
|
||||||
|
*/
|
||||||
|
public class WebhookNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
public WebhookNotFoundException(Long id) {
|
||||||
|
super("Webhook 신청을 찾을 수 없습니다. id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.mapper;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||||
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.Mapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebhookRequest → WebhookDTO 기본 필드 매핑.
|
||||||
|
* secretMasked/apiIds/eventTypes 는 연관 테이블 조립이 필요하므로 서비스에서 채운다.
|
||||||
|
*/
|
||||||
|
@Mapper(componentModel = "spring")
|
||||||
|
public interface WebhookMapper {
|
||||||
|
|
||||||
|
@Mapping(target = "secretMasked", ignore = true)
|
||||||
|
@Mapping(target = "apiIds", ignore = true)
|
||||||
|
@Mapping(target = "eventTypes", ignore = true)
|
||||||
|
WebhookDTO toDto(WebhookRequest entity);
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApiId;
|
||||||
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PTL_WEBHOOK_REQ_API — 신청별 구독 API 목록.
|
||||||
|
*/
|
||||||
|
@EMSDataSource
|
||||||
|
public interface WebhookRequestApiRepository extends JpaRepository<WebhookRequestApi, WebhookRequestApiId> {
|
||||||
|
|
||||||
|
List<WebhookRequestApi> findByWebhookReqId(Long webhookReqId);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void deleteByWebhookReqId(Long webhookReqId);
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEventId;
|
||||||
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PTL_WEBHOOK_REQ_EVENT — 신청별 구독 EventType 목록.
|
||||||
|
*/
|
||||||
|
@EMSDataSource
|
||||||
|
public interface WebhookRequestEventRepository extends JpaRepository<WebhookRequestEvent, WebhookRequestEventId> {
|
||||||
|
|
||||||
|
List<WebhookRequestEvent> findByWebhookReqId(Long webhookReqId);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void deleteByWebhookReqId(Long webhookReqId);
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||||
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PTL_WEBHOOK_REQ 조회/저장. Org 당 1건 정책이라 orgId 단건 조회를 제공한다.
|
||||||
|
*/
|
||||||
|
@EMSDataSource
|
||||||
|
public interface WebhookRequestRepository extends JpaRepository<WebhookRequest, Long> {
|
||||||
|
|
||||||
|
Optional<WebhookRequest> findByOrgId(String orgId);
|
||||||
|
|
||||||
|
boolean existsByOrgId(String orgId);
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.GenerationType;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.PrePersist;
|
||||||
|
import javax.persistence.SequenceGenerator;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청 마스터 (EMSAPP.PTL_WEBHOOK_REQ).
|
||||||
|
*
|
||||||
|
* admin(eapim-admin) 발송엔진이 이 행을 읽어 TARGET_URL 로 HMAC-SHA256 서명 발송한다.
|
||||||
|
* SECRET 은 서명 키로 그대로 사용되므로 암호화하지 않고 평문 저장한다.
|
||||||
|
* CREATED_DATE 는 VARCHAR2(14) yyyyMMddHHmmss 문자열이라 AbstractAuditingEntity 를 쓰지 않고
|
||||||
|
* {@link #onCreate()} 에서 직접 세팅한다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "PTL_WEBHOOK_REQ")
|
||||||
|
public class WebhookRequest implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "webhookReqSeq")
|
||||||
|
@SequenceGenerator(name = "webhookReqSeq", sequenceName = "SEQ_PTL_WEBHOOK_REQ_ID", allocationSize = 1)
|
||||||
|
@Column(name = "ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "ORG_ID", length = 36)
|
||||||
|
private String orgId;
|
||||||
|
|
||||||
|
@Column(name = "TARGET_URL", length = 255)
|
||||||
|
private String targetUrl;
|
||||||
|
|
||||||
|
@Column(name = "SECRET", length = 500)
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_BY", length = 200)
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_DATE", length = 14)
|
||||||
|
private String createdDate;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void onCreate() {
|
||||||
|
if (createdBy == null) {
|
||||||
|
createdBy = SecurityUtil.getCurrentLoginId();
|
||||||
|
}
|
||||||
|
if (createdDate == null) {
|
||||||
|
createdDate = LocalDateTime.now().format(TS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.PrePersist;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청이 알림을 받을 API 목록 (EMSAPP.PTL_WEBHOOK_REQ_API).
|
||||||
|
* 복합키(WEBHOOK_REQ_ID + API_ID).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "PTL_WEBHOOK_REQ_API")
|
||||||
|
@IdClass(WebhookRequestApiId.class)
|
||||||
|
public class WebhookRequestApi implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "WEBHOOK_REQ_ID")
|
||||||
|
private Long webhookReqId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "API_ID", length = 30)
|
||||||
|
private String apiId;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_BY", length = 200)
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_DATE", length = 14)
|
||||||
|
private String createdDate;
|
||||||
|
|
||||||
|
public WebhookRequestApi() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebhookRequestApi(Long webhookReqId, String apiId) {
|
||||||
|
this.webhookReqId = webhookReqId;
|
||||||
|
this.apiId = apiId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void onCreate() {
|
||||||
|
if (createdBy == null) {
|
||||||
|
createdBy = SecurityUtil.getCurrentLoginId();
|
||||||
|
}
|
||||||
|
if (createdDate == null) {
|
||||||
|
createdDate = LocalDateTime.now().format(TS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WebhookRequestApi} 복합키 (WEBHOOK_REQ_ID + API_ID).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EqualsAndHashCode
|
||||||
|
public class WebhookRequestApiId implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long webhookReqId;
|
||||||
|
private String apiId;
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.PrePersist;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청이 구독하는 EventType 목록 (EMSAPP.PTL_WEBHOOK_REQ_EVENT).
|
||||||
|
* EVENT_TYPE 코드값은 EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE') 과 동일 집합.
|
||||||
|
* 복합키(WEBHOOK_REQ_ID + EVENT_TYPE).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "PTL_WEBHOOK_REQ_EVENT")
|
||||||
|
@IdClass(WebhookRequestEventId.class)
|
||||||
|
public class WebhookRequestEvent implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "WEBHOOK_REQ_ID")
|
||||||
|
private Long webhookReqId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "EVENT_TYPE", length = 36)
|
||||||
|
private String eventType;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_BY", length = 200)
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_DATE", length = 14)
|
||||||
|
private String createdDate;
|
||||||
|
|
||||||
|
public WebhookRequestEvent() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebhookRequestEvent(Long webhookReqId, String eventType) {
|
||||||
|
this.webhookReqId = webhookReqId;
|
||||||
|
this.eventType = eventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void onCreate() {
|
||||||
|
if (createdBy == null) {
|
||||||
|
createdBy = SecurityUtil.getCurrentLoginId();
|
||||||
|
}
|
||||||
|
if (createdDate == null) {
|
||||||
|
createdDate = LocalDateTime.now().format(TS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WebhookRequestEvent} 복합키 (WEBHOOK_REQ_ID + EVENT_TYPE).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EqualsAndHashCode
|
||||||
|
public class WebhookRequestEventId implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long webhookReqId;
|
||||||
|
private String eventType;
|
||||||
|
}
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import javax.persistence.EntityManager;
|
||||||
|
import javax.persistence.PersistenceContext;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventType 코드/한글명 제공자. EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE', USEYN='Y') 를 조회한다.
|
||||||
|
* admin 발송엔진과 동일 공통코드 테이블을 단일 소스로 사용하므로 코드 추가/변경 시 DB 만 수정하면 된다.
|
||||||
|
*
|
||||||
|
* TSEAIRM28 은 elink-portal-common 의 {@code MonitoringCode} 엔티티가 이미 매핑하고 있어(같은 테이블 중복 @Entity 금지),
|
||||||
|
* 여기서는 별도 엔티티 없이 EMS EntityManager 네이티브 쿼리로 필요한 두 컬럼만 조회한다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WebhookEventTypeProvider {
|
||||||
|
|
||||||
|
private static final String EVENT_TYPE_SQL =
|
||||||
|
"SELECT CODE, CODENAME FROM TSEAIRM28 "
|
||||||
|
+ "WHERE CODEGROUP = 'EVENT_TYPE' AND USEYN = 'Y' "
|
||||||
|
+ "ORDER BY SEQ, CODE";
|
||||||
|
|
||||||
|
/** EMS 데이터소스가 @Primary 이므로 기본 EntityManager 는 EMS 를 가리킨다. */
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<WebhookEventTypeDTO> getAll() {
|
||||||
|
List<WebhookEventTypeDTO> list = new ArrayList<>();
|
||||||
|
for (Object[] row : rows()) {
|
||||||
|
list.add(new WebhookEventTypeDTO(asString(row[0]), asString(row[1])));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<String, String> asMap() {
|
||||||
|
Map<String, String> map = new LinkedHashMap<>();
|
||||||
|
for (Object[] row : rows()) {
|
||||||
|
map.put(asString(row[0]), asString(row[1]));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isValid(String code) {
|
||||||
|
if (code == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (Object[] row : rows()) {
|
||||||
|
if (code.equals(asString(row[0]))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String getName(String code) {
|
||||||
|
return asMap().getOrDefault(code, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<Object[]> rows() {
|
||||||
|
return entityManager.createNativeQuery(EVENT_TYPE_SQL).getResultList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String asString(Object value) {
|
||||||
|
return value == null ? null : value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.service;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook HMAC Secret 생성기.
|
||||||
|
*
|
||||||
|
* admin 발송엔진이 이 값을 그대로 HMAC-SHA256 키로 사용하므로(암복호화 없음),
|
||||||
|
* 128자 영숫자 랜덤 문자열을 평문으로 발급한다(admin 기존 데이터와 동일 형태).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WebhookSecretGenerator {
|
||||||
|
|
||||||
|
private static final char[] ALPHANUM =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
|
||||||
|
private static final int LENGTH = 128;
|
||||||
|
|
||||||
|
private final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
public String generate() {
|
||||||
|
StringBuilder sb = new StringBuilder(LENGTH);
|
||||||
|
for (int i = 0; i < LENGTH; i++) {
|
||||||
|
sb.append(ALPHANUM[random.nextInt(ALPHANUM.length)]);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.exception.WebhookAlreadyExistsException;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.exception.WebhookNotFoundException;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.mapper.WebhookMapper;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청/조회/수정/삭제 및 Secret 발급 오케스트레이션.
|
||||||
|
*
|
||||||
|
* 단일 EMS 데이터소스만 사용하므로 기본 {@code @Transactional} 로 충분하다(JTA 분산 트랜잭션 불필요).
|
||||||
|
* 모든 수정/삭제/Secret 조회는 소유 Org 검증({@link #loadOwned})을 거친다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebhookService {
|
||||||
|
|
||||||
|
private static final String SECRET_MASK = "••••••••••••";
|
||||||
|
|
||||||
|
private final WebhookRequestRepository requestRepository;
|
||||||
|
private final WebhookRequestApiRepository apiRepository;
|
||||||
|
private final WebhookRequestEventRepository eventRepository;
|
||||||
|
private final WebhookSecretGenerator secretGenerator;
|
||||||
|
private final WebhookEventTypeProvider eventTypeProvider;
|
||||||
|
private final WebhookMapper webhookMapper;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean existsByOrg(String orgId) {
|
||||||
|
return requestRepository.existsByOrgId(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<WebhookDTO> getByOrg(String orgId) {
|
||||||
|
return requestRepository.findByOrgId(orgId).map(this::toDetailDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 신청. Org 당 1건 정책 위반 시 예외. 평문 Secret 을 1회 반환한다.
|
||||||
|
*/
|
||||||
|
public WebhookCreatedResult create(WebhookRegistrationDTO dto, String orgId) {
|
||||||
|
if (requestRepository.existsByOrgId(orgId)) {
|
||||||
|
throw new WebhookAlreadyExistsException(orgId);
|
||||||
|
}
|
||||||
|
validate(dto);
|
||||||
|
|
||||||
|
String secret = secretGenerator.generate();
|
||||||
|
WebhookRequest request = new WebhookRequest();
|
||||||
|
request.setOrgId(orgId);
|
||||||
|
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||||
|
request.setSecret(secret);
|
||||||
|
WebhookRequest saved = requestRepository.save(request);
|
||||||
|
|
||||||
|
persistChildren(saved.getId(), dto);
|
||||||
|
log.info("Webhook 신규 등록 orgId={} id={}", orgId, saved.getId());
|
||||||
|
return new WebhookCreatedResult(saved.getId(), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
|
||||||
|
*/
|
||||||
|
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
|
||||||
|
WebhookRequest request = loadOwned(id, orgId);
|
||||||
|
validate(dto);
|
||||||
|
|
||||||
|
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||||
|
requestRepository.save(request);
|
||||||
|
|
||||||
|
apiRepository.deleteByWebhookReqId(id);
|
||||||
|
eventRepository.deleteByWebhookReqId(id);
|
||||||
|
apiRepository.flush();
|
||||||
|
eventRepository.flush();
|
||||||
|
persistChildren(id, dto);
|
||||||
|
|
||||||
|
log.info("Webhook 수정 orgId={} id={}", orgId, id);
|
||||||
|
return toDetailDto(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secret 재발급(교체). 새 평문 Secret 반환.
|
||||||
|
*/
|
||||||
|
public String regenerateSecret(Long id, String orgId) {
|
||||||
|
WebhookRequest request = loadOwned(id, orgId);
|
||||||
|
String secret = secretGenerator.generate();
|
||||||
|
request.setSecret(secret);
|
||||||
|
requestRepository.save(request);
|
||||||
|
log.info("Webhook Secret 재발급 orgId={} id={}", orgId, id);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3개 테이블 HardDelete.
|
||||||
|
*/
|
||||||
|
public void delete(Long id, String orgId) {
|
||||||
|
WebhookRequest request = loadOwned(id, orgId);
|
||||||
|
apiRepository.deleteByWebhookReqId(id);
|
||||||
|
eventRepository.deleteByWebhookReqId(id);
|
||||||
|
requestRepository.delete(request);
|
||||||
|
log.info("Webhook 삭제 orgId={} id={}", orgId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 평문 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String getPlainSecret(Long id, String orgId) {
|
||||||
|
return loadOwned(id, orgId).getSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
private WebhookRequest loadOwned(Long id, String orgId) {
|
||||||
|
WebhookRequest request = requestRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new WebhookNotFoundException(id));
|
||||||
|
if (!Objects.equals(request.getOrgId(), orgId)) {
|
||||||
|
throw new AccessDeniedException("해당 Webhook에 대한 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistChildren(Long reqId, WebhookRegistrationDTO dto) {
|
||||||
|
for (String apiId : dedup(dto.getSelectedApis())) {
|
||||||
|
apiRepository.save(new WebhookRequestApi(reqId, apiId));
|
||||||
|
}
|
||||||
|
for (String eventType : dedup(dto.getEventTypes())) {
|
||||||
|
if (!eventTypeProvider.isValid(eventType)) {
|
||||||
|
throw new IllegalArgumentException("유효하지 않은 EventType 코드입니다: " + eventType);
|
||||||
|
}
|
||||||
|
eventRepository.save(new WebhookRequestEvent(reqId, eventType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(WebhookRegistrationDTO dto) {
|
||||||
|
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
throw new IllegalArgumentException("URL은 http:// 또는 https:// 로 시작해야 합니다.");
|
||||||
|
}
|
||||||
|
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("EventType을 1개 이상 선택해주세요.");
|
||||||
|
}
|
||||||
|
if (dto.getSelectedApis() == null || dto.getSelectedApis().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("알림 대상 API를 1개 이상 선택해주세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> dedup(List<String> values) {
|
||||||
|
if (values == null) {
|
||||||
|
return java.util.Collections.emptyList();
|
||||||
|
}
|
||||||
|
return new java.util.ArrayList<>(new LinkedHashSet<>(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebhookDTO toDetailDto(WebhookRequest request) {
|
||||||
|
WebhookDTO dto = webhookMapper.toDto(request);
|
||||||
|
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
||||||
|
|
||||||
|
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
|
||||||
|
.map(WebhookRequestApi::getApiId)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
|
Map<String, String> names = eventTypeProvider.asMap();
|
||||||
|
dto.setEventTypes(eventRepository.findByWebhookReqId(request.getId()).stream()
|
||||||
|
.map(e -> new WebhookEventTypeDTO(e.getEventType(),
|
||||||
|
names.getOrDefault(e.getEventType(), e.getEventType())))
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,10 @@ server:
|
|||||||
servlet:
|
servlet:
|
||||||
context-path: /
|
context-path: /
|
||||||
session:
|
session:
|
||||||
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
|
# 세션 타임아웃 10분 고정 (DB property 관리 폐지).
|
||||||
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
|
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
|
||||||
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
|
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
|
||||||
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
|
timeout: 10m
|
||||||
# timeout: 10m
|
|
||||||
cookie:
|
cookie:
|
||||||
name: JSESSIONID_PORTAL
|
name: JSESSIONID_PORTAL
|
||||||
encoding:
|
encoding:
|
||||||
@@ -30,6 +29,8 @@ spring:
|
|||||||
default-page-size: '10'
|
default-page-size: '10'
|
||||||
jpa:
|
jpa:
|
||||||
open-in-view: false
|
open-in-view: false
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: none
|
||||||
|
|
||||||
web:
|
web:
|
||||||
resources:
|
resources:
|
||||||
@@ -203,6 +204,10 @@ portal:
|
|||||||
method: GET
|
method: GET
|
||||||
view-name: apps/service/oauth2-guide
|
view-name: apps/service/oauth2-guide
|
||||||
|
|
||||||
|
- path-pattern: /service/webhook-dev-guide
|
||||||
|
method: GET
|
||||||
|
view-name: apps/service/webhook-dev-guide
|
||||||
|
|
||||||
- path-pattern: /dashboard
|
- path-pattern: /dashboard
|
||||||
method: GET
|
method: GET
|
||||||
view-name: apps/mypage/dashboard
|
view-name: apps/mypage/dashboard
|
||||||
@@ -295,6 +300,9 @@ page:
|
|||||||
oauth2_guide:
|
oauth2_guide:
|
||||||
name: "OAuth2 개발가이드"
|
name: "OAuth2 개발가이드"
|
||||||
path: "/service/oauth2-guide"
|
path: "/service/oauth2-guide"
|
||||||
|
webhook_dev_guide:
|
||||||
|
name: "웹훅 개발가이드"
|
||||||
|
path: "/service/webhook-dev-guide"
|
||||||
apis:
|
apis:
|
||||||
name: "API"
|
name: "API"
|
||||||
path: "#"
|
path: "#"
|
||||||
@@ -388,6 +396,27 @@ page:
|
|||||||
api_statistics:
|
api_statistics:
|
||||||
name: "이용 통계"
|
name: "이용 통계"
|
||||||
path: "/statistics/api"
|
path: "/statistics/api"
|
||||||
|
webhook:
|
||||||
|
name: "Webhook 관리"
|
||||||
|
path: "/webhook"
|
||||||
|
webhook_register_step1:
|
||||||
|
name: "Webhook 신청 (기본 정보)"
|
||||||
|
path: "/webhook/register/step1"
|
||||||
|
webhook_register_step2:
|
||||||
|
name: "Webhook 신청 (API 선택)"
|
||||||
|
path: "/webhook/register/step2"
|
||||||
|
webhook_register_step3:
|
||||||
|
name: "Webhook 신청 완료"
|
||||||
|
path: "/webhook/register/step3"
|
||||||
|
webhook_modify_step1:
|
||||||
|
name: "Webhook 수정 (기본 정보)"
|
||||||
|
path: "/webhook/modify/step1"
|
||||||
|
webhook_modify_step2:
|
||||||
|
name: "Webhook 수정 (API 선택)"
|
||||||
|
path: "/webhook/modify/step2"
|
||||||
|
webhook_modify_step3:
|
||||||
|
name: "Webhook 수정 완료"
|
||||||
|
path: "/webhook/modify/step3"
|
||||||
|
|
||||||
# 에디터 이미지 설정 (약관 이미지 표시용)
|
# 에디터 이미지 설정 (약관 이미지 표시용)
|
||||||
editor:
|
editor:
|
||||||
|
|||||||
@@ -56,6 +56,19 @@
|
|||||||
</encoder>
|
</encoder>
|
||||||
</appender>
|
</appender>
|
||||||
|
|
||||||
|
<!-- API 테스트베드(/api/call-api) 감사 로그: 요청지/헤더/마스킹된 본문 기록, 1년(365일) 보관 -->
|
||||||
|
<appender name="API_TESTER_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_PATH}/apitester-audit.log</file>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_PATH}/backup/apitester-audit.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||||
|
<maxFileSize>200MB</maxFileSize>
|
||||||
|
<maxHistory>365</maxHistory>
|
||||||
|
</rollingPolicy>
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||||
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
||||||
@@ -68,6 +81,10 @@
|
|||||||
<appender-ref ref="HTTP_SESSION" />
|
<appender-ref ref="HTTP_SESSION" />
|
||||||
</logger>
|
</logger>
|
||||||
|
|
||||||
|
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
|
||||||
|
<appender-ref ref="API_TESTER_AUDIT" />
|
||||||
|
</logger>
|
||||||
|
|
||||||
|
|
||||||
<root level="INFO">
|
<root level="INFO">
|
||||||
<appender-ref ref="ROLLING"/>
|
<appender-ref ref="ROLLING"/>
|
||||||
|
|||||||
+1352
-309
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,467 @@
|
|||||||
|
/**
|
||||||
|
* API 선택 공용 모듈 (fragment/api_selector.html 전용, figma s2 디자인)
|
||||||
|
*
|
||||||
|
* 사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
|
||||||
|
*
|
||||||
|
* 계약:
|
||||||
|
* - 폼: #apiSelectorForm (data-save-action = "이전" 저장 POST 경로)
|
||||||
|
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
|
||||||
|
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
|
||||||
|
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
|
||||||
|
*
|
||||||
|
* design(figma s2) 인라인 스크립트 대비 패치 3건:
|
||||||
|
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
|
||||||
|
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
|
||||||
|
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
|
||||||
|
*/
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('apiSelectorForm');
|
||||||
|
if (!form) {
|
||||||
|
return; // 모듈 미사용 페이지
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
const searchInput = document.getElementById('apiSearch');
|
||||||
|
const menuTitles = document.querySelectorAll('.s2-category-tab');
|
||||||
|
const apiCardGrid = document.getElementById('apiCardGrid');
|
||||||
|
const loadingState = document.getElementById('loadingState');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
|
||||||
|
let currentFilter = ''; // Empty string means "all"
|
||||||
|
let currentServiceName = '전체';
|
||||||
|
let allApis = [];
|
||||||
|
let selectedApis = new Set();
|
||||||
|
|
||||||
|
// Restore selected APIs from session (fragment 인라인 주입)
|
||||||
|
const sessionSelectedApis = window.API_SELECTOR_SELECTED;
|
||||||
|
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
|
||||||
|
sessionSelectedApis.forEach(function(apiId) {
|
||||||
|
selectedApis.add(apiId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load APIs via AJAX
|
||||||
|
function loadApis(groupId) {
|
||||||
|
loadingState.style.display = 'block';
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
|
||||||
|
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
|
||||||
|
|
||||||
|
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
|
||||||
|
let url = baseUrl;
|
||||||
|
if (groupId) {
|
||||||
|
url += '?groupIds=' + encodeURIComponent(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url).then(response => response.json()).then(apis => {
|
||||||
|
allApis = apis;
|
||||||
|
loadingState.style.display = 'none';
|
||||||
|
|
||||||
|
if (apis.length === 0) {
|
||||||
|
emptyState.style.display = 'block';
|
||||||
|
document.getElementById('apiResultCount').textContent = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('apiResultCount').textContent = apis.length;
|
||||||
|
renderApiCards(apis);
|
||||||
|
updateSelectAllUI();
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Failed to load APIs:', error);
|
||||||
|
loadingState.style.display = 'none';
|
||||||
|
emptyState.querySelector('h3').textContent = 'API 로드 실패';
|
||||||
|
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
||||||
|
emptyState.style.display = 'block';
|
||||||
|
document.getElementById('apiResultCount').textContent = '0';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render API cards
|
||||||
|
function renderApiCards(apis) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
apis.forEach(api => {
|
||||||
|
fragment.appendChild(createApiCard(api));
|
||||||
|
});
|
||||||
|
|
||||||
|
apiCardGrid.appendChild(fragment);
|
||||||
|
attachCardEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create API card element (figma s2 card)
|
||||||
|
function createApiCard(api) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 's2-api-card';
|
||||||
|
card.setAttribute('data-group', api.apiGroupId || '');
|
||||||
|
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
|
||||||
|
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
|
||||||
|
card.setAttribute('data-api-id', api.apiId);
|
||||||
|
|
||||||
|
const isSelected = selectedApis.has(api.apiId);
|
||||||
|
if (isSelected) {
|
||||||
|
card.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainIconHtml = api.mainIcon
|
||||||
|
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
|
||||||
|
: `<i class="fas fa-cube"></i>`;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="s2-api-card-badge">
|
||||||
|
<span>${api.apiGroupName || api.service || '카테고리'}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Checkbox container with visible custom design -->
|
||||||
|
<label class="s2-checkbox-wrapper">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="selectedApis"
|
||||||
|
value="${api.apiId}"
|
||||||
|
id="api-${api.apiId}"
|
||||||
|
class="s2-api-checkbox visually-hidden"
|
||||||
|
${isSelected ? 'checked' : ''}>
|
||||||
|
<span class="s2-checkbox-custom"></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
|
||||||
|
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
|
||||||
|
|
||||||
|
<div class="s2-api-card-image">
|
||||||
|
${mainIconHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listeners to cards
|
||||||
|
function attachCardEventListeners() {
|
||||||
|
const apiCards = document.querySelectorAll('.s2-api-card');
|
||||||
|
|
||||||
|
apiCards.forEach(card => {
|
||||||
|
card.addEventListener('click', function(e) {
|
||||||
|
const checkbox = card.querySelector('.s2-api-checkbox');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
updateCardSelection(checkbox);
|
||||||
|
updateSelectedCount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent double toggle when clicking the checkbox wrapper
|
||||||
|
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
|
||||||
|
checkboxWrappers.forEach(wrapper => {
|
||||||
|
wrapper.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation(); // Stop click from bubbling to card!
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkbox = wrapper.querySelector('.s2-api-checkbox');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
updateCardSelection(this);
|
||||||
|
updateSelectedCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update card visual state
|
||||||
|
function updateCardSelection(checkbox) {
|
||||||
|
const card = checkbox.closest('.s2-api-card');
|
||||||
|
if (checkbox.checked) {
|
||||||
|
card.classList.add('selected');
|
||||||
|
selectedApis.add(checkbox.value);
|
||||||
|
} else {
|
||||||
|
card.classList.remove('selected');
|
||||||
|
selectedApis.delete(checkbox.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selected count
|
||||||
|
function updateSelectedCount() {
|
||||||
|
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||||
|
const cartCount = document.querySelector('.s2-cart-count');
|
||||||
|
|
||||||
|
if (selectedApis.size > 0) {
|
||||||
|
floatingCartBtn.style.display = 'flex';
|
||||||
|
cartCount.textContent = selectedApis.size;
|
||||||
|
} else {
|
||||||
|
floatingCartBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateModalList();
|
||||||
|
updateSelectAllCheckboxState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update select all UI visibility and text
|
||||||
|
function updateSelectAllUI() {
|
||||||
|
const selectAllWrapper = document.getElementById('selectAllWrapper');
|
||||||
|
const selectAllText = document.getElementById('selectAllText');
|
||||||
|
|
||||||
|
if (currentFilter === '') {
|
||||||
|
selectAllWrapper.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
selectAllWrapper.style.display = 'flex';
|
||||||
|
selectAllText.textContent = currentServiceName + ' API 전체 선택';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectAllCheckboxState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update select all checkbox state based on visible cards
|
||||||
|
function updateSelectAllCheckboxState() {
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||||
|
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
||||||
|
|
||||||
|
if (visibleCards.length === 0) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
|
||||||
|
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
|
||||||
|
|
||||||
|
if (checkedCount === 0) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else if (checkedCount === visibleCheckboxes.length) {
|
||||||
|
selectAllCheckbox.checked = true;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
|
||||||
|
function updateModalList() {
|
||||||
|
const modalSelectedList = document.getElementById('modalSelectedList');
|
||||||
|
modalSelectedList.innerHTML = '';
|
||||||
|
|
||||||
|
if (selectedApis.size === 0) {
|
||||||
|
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedApis.forEach(function(apiId) {
|
||||||
|
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
|
||||||
|
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
|
||||||
|
|
||||||
|
const apiPill = document.createElement('div');
|
||||||
|
apiPill.className = 's2-api-pill';
|
||||||
|
apiPill.innerHTML = `
|
||||||
|
<span class="s2-api-pill-name">${apiName}</span>
|
||||||
|
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">✕</button>
|
||||||
|
`;
|
||||||
|
modalSelectedList.appendChild(apiPill);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const value = this.getAttribute('data-value');
|
||||||
|
selectedApis.delete(value);
|
||||||
|
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
updateCardSelection(checkbox);
|
||||||
|
}
|
||||||
|
updateSelectedCount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', function() {
|
||||||
|
const searchTerm = this.value.toLowerCase();
|
||||||
|
|
||||||
|
const apiCards = document.querySelectorAll('.s2-api-card');
|
||||||
|
let visibleCount = 0;
|
||||||
|
apiCards.forEach(function(card) {
|
||||||
|
const apiName = card.getAttribute('data-name');
|
||||||
|
const apiDesc = card.getAttribute('data-desc');
|
||||||
|
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
|
||||||
|
|
||||||
|
if (matchesSearch) {
|
||||||
|
card.style.display = '';
|
||||||
|
visibleCount++;
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('apiResultCount').textContent = visibleCount;
|
||||||
|
updateSelectAllCheckboxState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category tab selection
|
||||||
|
menuTitles.forEach(function(title) {
|
||||||
|
title.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
menuTitles.forEach(t => t.classList.remove('active'));
|
||||||
|
this.classList.add('active');
|
||||||
|
|
||||||
|
const groupId = this.getAttribute('data-group');
|
||||||
|
currentFilter = groupId;
|
||||||
|
currentServiceName = this.textContent.trim();
|
||||||
|
|
||||||
|
loadApis(groupId);
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Category Carousel Scroll
|
||||||
|
const categoryListWrapper = document.getElementById('categoryListWrapper');
|
||||||
|
const btnPrevCategory = document.getElementById('btnPrevCategory');
|
||||||
|
const btnNextCategory = document.getElementById('btnNextCategory');
|
||||||
|
|
||||||
|
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
|
||||||
|
const scrollAmount = 200;
|
||||||
|
|
||||||
|
btnPrevCategory.addEventListener('click', function() {
|
||||||
|
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
btnNextCategory.addEventListener('click', function() {
|
||||||
|
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle buttons visibility/disabled state based on scroll position
|
||||||
|
function updateCarouselButtons() {
|
||||||
|
const scrollLeft = categoryListWrapper.scrollLeft;
|
||||||
|
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
|
||||||
|
|
||||||
|
btnPrevCategory.disabled = scrollLeft <= 0;
|
||||||
|
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
|
||||||
|
window.addEventListener('resize', updateCarouselButtons);
|
||||||
|
|
||||||
|
// Initial check after loading categories
|
||||||
|
setTimeout(updateCarouselButtons, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal control
|
||||||
|
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
||||||
|
const selectedApisModal = document.getElementById('selectedApisModal');
|
||||||
|
const modalOverlay = document.getElementById('modalOverlay');
|
||||||
|
const modalCloseBtn = document.getElementById('modalCloseBtn');
|
||||||
|
const modalCancelBtn = document.getElementById('modalCancelBtn');
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
updateModalList(); // 열 때마다 최신 선택 상태로 재빌드 (패치 1)
|
||||||
|
selectedApisModal.style.display = 'flex'; // .s2-modal은 flex 중앙정렬 → block 금지
|
||||||
|
setTimeout(function() {
|
||||||
|
if (modalOverlay) {
|
||||||
|
modalOverlay.classList.add('show');
|
||||||
|
}
|
||||||
|
selectedApisModal.classList.add('show');
|
||||||
|
}, 10);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
if (modalOverlay) {
|
||||||
|
modalOverlay.classList.remove('show');
|
||||||
|
}
|
||||||
|
selectedApisModal.classList.remove('show');
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
selectedApisModal.style.display = 'none';
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (floatingCartBtn) {
|
||||||
|
floatingCartBtn.addEventListener('click', openModal);
|
||||||
|
}
|
||||||
|
if (modalOverlay) {
|
||||||
|
modalOverlay.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
if (modalCloseBtn) {
|
||||||
|
modalCloseBtn.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
if (modalCancelBtn) {
|
||||||
|
modalCancelBtn.addEventListener('click', closeModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape' && selectedApisModal.style.display === 'flex') {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select All checkbox event
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.addEventListener('change', function() {
|
||||||
|
const isChecked = this.checked;
|
||||||
|
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
||||||
|
|
||||||
|
visibleCards.forEach(function(card) {
|
||||||
|
const checkbox = card.querySelector('.s2-api-checkbox');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = isChecked;
|
||||||
|
updateCardSelection(checkbox);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSelectedCount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM에 렌더되지 않은 선택분을 hidden input으로 주입 — 전송 유실 방지 (패치 3)
|
||||||
|
function syncHiddenSelected() {
|
||||||
|
document.querySelectorAll('input.hidden-selected-api').forEach(el => el.remove());
|
||||||
|
selectedApis.forEach(function(apiId) {
|
||||||
|
if (!document.querySelector('.s2-api-checkbox[value="' + apiId + '"]')) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'selectedApis';
|
||||||
|
input.value = apiId;
|
||||||
|
input.className = 'hidden-selected-api';
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previous step button — 선택 저장 후 step1 복귀
|
||||||
|
const btnPrevStep = document.getElementById('btnPrevStep');
|
||||||
|
if (btnPrevStep) {
|
||||||
|
btnPrevStep.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const saveAction = form.getAttribute('data-save-action');
|
||||||
|
if (saveAction) {
|
||||||
|
form.action = saveAction;
|
||||||
|
}
|
||||||
|
syncHiddenSelected();
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
if (selectedApis.size === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (window.customPopups && customPopups.showAlert) {
|
||||||
|
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
|
||||||
|
} else {
|
||||||
|
alert('최소 1개 이상의 API를 선택해주세요.');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
syncHiddenSelected();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
updateSelectedCount();
|
||||||
|
loadApis('');
|
||||||
|
});
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
|
|
||||||
var startTs = 0;
|
var startTs = 0;
|
||||||
var targetOpblock = null;
|
var targetOpblock = null;
|
||||||
|
// Execute 클릭 후에만 true. spec 로딩 등 실행 외 응답이 responseInterceptor 로
|
||||||
|
// 들어와도 그리드를 만들지 않기 위한 게이트 (실행 전 UI 미노출 보장).
|
||||||
|
var pendingExecute = false;
|
||||||
|
|
||||||
function now() {
|
function now() {
|
||||||
return (global.performance && performance.now) ? performance.now() : Date.now();
|
return (global.performance && performance.now) ? performance.now() : Date.now();
|
||||||
@@ -206,6 +209,7 @@
|
|||||||
|
|
||||||
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
|
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
|
||||||
function renderNetworkError(msg) {
|
function renderNetworkError(msg) {
|
||||||
|
pendingExecute = false;
|
||||||
var grid = gridFor(targetOpblock);
|
var grid = gridFor(targetOpblock);
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
grid.classList.remove("djb-empty");
|
grid.classList.remove("djb-empty");
|
||||||
@@ -250,6 +254,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
startTs = now();
|
startTs = now();
|
||||||
|
pendingExecute = true;
|
||||||
var grid = gridFor(targetOpblock);
|
var grid = gridFor(targetOpblock);
|
||||||
if (grid) {
|
if (grid) {
|
||||||
grid.classList.add("djb-empty");
|
grid.classList.add("djb-empty");
|
||||||
@@ -264,10 +269,13 @@
|
|||||||
*/
|
*/
|
||||||
function renderResponse(res) {
|
function renderResponse(res) {
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
|
// Execute 를 누른 적 없으면 무시 — spec 로딩(/…/swagger.json) 응답도
|
||||||
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
|
// responseInterceptor 로 들어오는데, 이때 렌더하면 실행 전 응답 본문에
|
||||||
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
|
// OpenAPI spec 이 그려진다. url 검사는 버전에 따라 res.url 이 비어 무력화될
|
||||||
|
// 수 있어 실행 게이트를 1차 방어로 둔다.
|
||||||
|
if (!pendingExecute) return;
|
||||||
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
|
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
|
||||||
|
pendingExecute = false;
|
||||||
var ms = Math.max(0, Math.round(now() - startTs));
|
var ms = Math.max(0, Math.round(now() - startTs));
|
||||||
var status = res.status || 0;
|
var status = res.status || 0;
|
||||||
var statusText = res.statusText || "";
|
var statusText = res.statusText || "";
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ html {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #fafafa;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,8 +101,9 @@ $z-index-dropdown: 100;
|
|||||||
$z-index-sticky: 200;
|
$z-index-sticky: 200;
|
||||||
$z-index-fixed: 300;
|
$z-index-fixed: 300;
|
||||||
$z-index-header: 350;
|
$z-index-header: 350;
|
||||||
$z-index-modal-backdrop: 400;
|
// 모달은 헤더(.global-header z-index:1000 하드코딩)보다 항상 위에 떠야 한다
|
||||||
$z-index-modal: 500;
|
$z-index-modal-backdrop: 1040;
|
||||||
|
$z-index-modal: 1050;
|
||||||
$z-index-popover: 600;
|
$z-index-popover: 600;
|
||||||
$z-index-tooltip: 700;
|
$z-index-tooltip: 700;
|
||||||
$z-index-notification: 800;
|
$z-index-notification: 800;
|
||||||
|
|||||||
@@ -8,20 +8,39 @@
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
// FAQ Accordion - Figma Design
|
// FAQ Accordion - Figma Design
|
||||||
|
|
||||||
|
.faq-container {
|
||||||
|
|
||||||
|
|
||||||
|
.table-controls {
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
display: contents;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.faq-accordion {
|
.faq-accordion {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: $spacing-2xl;
|
margin-bottom: $spacing-2xl;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.faq-item {
|
.faq-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
transition: $transition-base;
|
transition: $transition-base;
|
||||||
|
|
||||||
// Active state - when accordion is open
|
&:hover {
|
||||||
&.active {
|
|
||||||
.faq-question {
|
.faq-question {
|
||||||
background: #4685ef;
|
background: #4685ef;
|
||||||
border-color: #e8dddd;
|
border-color: #e8dddd;
|
||||||
@@ -33,14 +52,8 @@
|
|||||||
|
|
||||||
.faq-icon {
|
.faq-icon {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-answer {
|
|
||||||
display: block;
|
|
||||||
animation: slideDown 0.3s ease;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -598,6 +598,11 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.md {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 11px 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@use '../abstracts/variables' as *;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// DJBank Custom — Q&A 댓글 영역 (DJPGPT0002)
|
// DJBank Custom — Q&A 댓글 영역 (DJPGPT0002)
|
||||||
// 설계서: DJPCSQ0300P 참조
|
// 설계서: DJPCSQ0300P 참조
|
||||||
@@ -160,7 +162,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.djb-comment-form {
|
.djb-comment-form {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +205,17 @@ textarea.djb-comment-input {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
// Let the checkbox area and counter align naturally
|
||||||
|
.djb-comment-private-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.djb-comment-char-counter {
|
.djb-comment-char-counter {
|
||||||
@@ -223,6 +237,13 @@ button.djb-comment-submit {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-end; // Float to the right under flex-direction: column
|
||||||
|
margin-top: 12px;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
width: 100%; // Full width button on mobile
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f8faff;
|
background-color: #f8faff;
|
||||||
|
|||||||
@@ -71,12 +71,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치.
|
// 가로 여유가 있을 때만 노출.
|
||||||
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px
|
// 헤더 .container(max-width:1280px, 중앙정렬)의 우측 여백에 위젯(폭 ~200px)이 들어갈 만큼
|
||||||
@media (max-width: $breakpoint-sm) {
|
// 넓은 화면(약 1700px 이상)에서만 표시 → 그 외에는 헤더 우측 메뉴(사용자명·로그아웃·마이페이지)를 가리므로 숨김.
|
||||||
|
// (PC 전용: 모바일 ≤768px 도 당연히 숨김)
|
||||||
|
@media (max-width: 1699px) {
|
||||||
.session-float {
|
.session-float {
|
||||||
top: 12px;
|
display: none;
|
||||||
right: 72px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,7 +366,9 @@
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|
||||||
@media (max-width: $breakpoint-sm) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
&::before { display: none; }
|
&::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-title-link {
|
.notice-title-link {
|
||||||
@@ -378,7 +380,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile number prefix [1] [2] etc
|
// Mobile number prefix [1] [2] etc
|
||||||
@@ -404,6 +406,126 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Inquiry List Specific Overrides (Djb Q&A List Design)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
.inquiry-list-container {
|
||||||
|
.table-controls {
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: stretch !important;
|
||||||
|
gap: 24px !important;
|
||||||
|
|
||||||
|
.search-field {
|
||||||
|
order: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-count {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
order: 2 !important;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #000;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1.5px solid #212529;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
.inquiry-visibility-notice {
|
||||||
|
margin-left: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-list-container {
|
||||||
|
|
||||||
|
.board-table {
|
||||||
|
.board-table-row {
|
||||||
|
.row-cell {
|
||||||
|
&--number {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 500px;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 6px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-private-label {
|
||||||
|
color: #8c959f;
|
||||||
|
font-style: italic;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-subject-text {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 240px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inquiry-comment-count {
|
||||||
|
color: #0049b4;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--writer {
|
||||||
|
width: 120px;
|
||||||
|
|
||||||
|
.inquiry-writer-org {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--status {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--views {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--date {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// List Table - Modern design with colored header (Figma: 984-2173)
|
// List Table - Modern design with colored header (Figma: 984-2173)
|
||||||
// Used in: User management, API key list, etc.
|
// Used in: User management, API key list, etc.
|
||||||
@@ -813,6 +935,7 @@
|
|||||||
// 공지사항 모바일 디자인 - 검색창, 목록, 페이지네이션
|
// 공지사항 모바일 디자인 - 검색창, 목록, 페이지네이션
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@media (max-width: $breakpoint-sm) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
|
||||||
// 검색 필드 - Figma: 335px × 40px, border-radius 8px
|
// 검색 필드 - Figma: 335px × 40px, border-radius 8px
|
||||||
.search-field {
|
.search-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -995,7 +1118,8 @@
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|
||||||
i, svg {
|
i,
|
||||||
|
svg {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
--accent-orange: #FF6B6B;
|
--accent-orange: #FF6B6B;
|
||||||
--accent-green: #6BCF7F;
|
--accent-green: #6BCF7F;
|
||||||
--accent-purple: #A78BFA;
|
--accent-purple: #A78BFA;
|
||||||
--accent-color:#efdcb2;
|
--accent-color: #efdcb2;
|
||||||
--accent-light: #E9F9FF;
|
--accent-light: #E9F9FF;
|
||||||
|
|
||||||
// Base colors
|
// Base colors
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
--shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2);
|
--shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2);
|
||||||
|
|
||||||
// border
|
// border
|
||||||
--border-color : #E2E8F0;
|
--border-color: #E2E8F0;
|
||||||
|
|
||||||
//transition
|
//transition
|
||||||
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
@@ -225,7 +225,7 @@ body.design-survey-active {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size:24px;
|
font-size: 24px;
|
||||||
|
|
||||||
.logo-text-bold {
|
.logo-text-bold {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -999,7 +999,7 @@ body.design-survey-active {
|
|||||||
.nav-menu {
|
.nav-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
gap: 36px;
|
gap: 28px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
@@ -1064,7 +1064,7 @@ body.design-survey-active {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-gray);
|
color: var(--text-gray);
|
||||||
padding: 8px 0;
|
padding: 8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: var(--transition-smooth);
|
transition: var(--transition-smooth);
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
@use 'pages/terms-agreements' as *;
|
@use 'pages/terms-agreements' as *;
|
||||||
@use 'pages/service' as *;
|
@use 'pages/service' as *;
|
||||||
@use 'pages/api-statistics' as *;
|
@use 'pages/api-statistics' as *;
|
||||||
|
@use 'pages/webhook' as *;
|
||||||
|
|
||||||
// 6. Themes
|
// 6. Themes
|
||||||
@use 'themes/dark' as *;
|
@use 'themes/dark' as *;
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar overrides for mobile toggle menu inside market
|
// Sidebar overrides for mobile toggle menu inside market
|
||||||
|
// Commented out to allow horizontal scrollbar on mobile screens as well
|
||||||
|
/*
|
||||||
.service-sidebar {
|
.service-sidebar {
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hero Section
|
// Hero Section
|
||||||
@@ -311,6 +314,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
// padding-left: $spacing-sm;
|
// padding-left: $spacing-sm;
|
||||||
// padding-right: $spacing-sm;
|
// padding-right: $spacing-sm;
|
||||||
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -334,37 +338,7 @@
|
|||||||
|
|
||||||
// Mobile Menu Toggle
|
// Mobile Menu Toggle
|
||||||
.api-mobile-toggle {
|
.api-mobile-toggle {
|
||||||
display: none;
|
display: none !important;
|
||||||
position: fixed;
|
|
||||||
bottom: $spacing-lg;
|
|
||||||
right: $spacing-lg;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
|
|
||||||
border-radius: $border-radius-circle;
|
|
||||||
border: none;
|
|
||||||
color: $white;
|
|
||||||
font-size: 24px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: $shadow-lg;
|
|
||||||
z-index: $z-index-modal + 1;
|
|
||||||
transition: $transition-base;
|
|
||||||
background-color: $primary-blue;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: $shadow-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: $breakpoint-sm) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header Section
|
// Header Section
|
||||||
@@ -697,6 +671,139 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Testbed 탭: 앱 선택 패널과 Swagger UI 사이 여백 제거
|
||||||
|
#testbed-tab {
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
#swagger-ui .info {
|
||||||
|
margin: $spacing-lg 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testbed 앱 선택 패널 (DJPGPT0001)
|
||||||
|
.testbed-app-panel {
|
||||||
|
margin: $spacing-sm 0 0;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
background: $white;
|
||||||
|
border: 1px solid $border-gray;
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
.testbed-app-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $spacing-xs $spacing-sm;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testbed-app-panel__title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: $spacing-md;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $text-dark;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 15px;
|
||||||
|
background: $primary-blue;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.testbed-app-panel__desc {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $text-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testbed-app-field {
|
||||||
|
position: relative;
|
||||||
|
max-width: 380px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: $spacing-md;
|
||||||
|
top: 50%;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
margin-top: -6px;
|
||||||
|
border-right: 2px solid $text-gray;
|
||||||
|
border-bottom: 2px solid $text-gray;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#apps {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0 42px 0 $input-padding-x;
|
||||||
|
font-family: $font-family-primary;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $text-dark;
|
||||||
|
background: $gray-bg;
|
||||||
|
border: 1px solid $border-gray;
|
||||||
|
border-radius: $border-radius-md;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: $transition-fast;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: $text-light;
|
||||||
|
background: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
background: $white;
|
||||||
|
border-color: $primary-blue;
|
||||||
|
box-shadow: 0 0 0 3px rgba($primary-blue, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: $text-light;
|
||||||
|
background: $gray-bg;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.testbed-app-notice {
|
||||||
|
margin: $spacing-md 0 0;
|
||||||
|
padding: 11px $spacing-md;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
line-height: $line-height-normal;
|
||||||
|
color: $text-gray;
|
||||||
|
background: $light-bg;
|
||||||
|
border: 1px solid rgba($primary-blue, 0.18);
|
||||||
|
border-left: 3px solid $primary-blue;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "ⓘ ";
|
||||||
|
color: $primary-blue;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
padding: $spacing-md;
|
||||||
|
|
||||||
|
.testbed-app-field {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// API Overview Card (Flat Style)
|
// API Overview Card (Flat Style)
|
||||||
.api-overview-card {
|
.api-overview-card {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -803,7 +910,11 @@
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-content {
|
// [editor-content 정렬] Summernote(관리자) 작성 콘텐츠는 editor-content.css 가 전담 스타일링하므로
|
||||||
|
// 자체 테이블/폰트 스타일은 .editor-content 미적용 영역(샘플 pre/code)에만 건다.
|
||||||
|
// 과거 회귀: :not(.editor-content) 를 지워 .detail-content 로 되돌리고,
|
||||||
|
// mainApiDetail.html 의 editor-content 클래스 3곳을 제거하면 기존 스타일로 복원됨
|
||||||
|
.detail-content:not(.editor-content) {
|
||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
color: $text-gray;
|
color: $text-gray;
|
||||||
line-height: $line-height-normal;
|
line-height: $line-height-normal;
|
||||||
@@ -1013,8 +1124,16 @@
|
|||||||
margin-bottom: $spacing-sm;
|
margin-bottom: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [editor-content 정렬] Summernote 콘텐츠는 editor-content.css 모바일 규칙 사용,
|
||||||
|
// 넘치는 표 대비 가로 스크롤만 부여. 과거 회귀 시 이 블록 삭제
|
||||||
|
.detail-content.editor-content {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
// Detail Content - 모바일에서 가로 스크롤 지원
|
// Detail Content - 모바일에서 가로 스크롤 지원
|
||||||
.detail-content {
|
// 과거 회귀: :not(.editor-content) 제거
|
||||||
|
.detail-content:not(.editor-content) {
|
||||||
font-size: $font-size-xs;
|
font-size: $font-size-xs;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -1097,7 +1216,11 @@
|
|||||||
|
|
||||||
// API Market Content - Mobile (화면 폭 제한)
|
// API Market Content - Mobile (화면 폭 제한)
|
||||||
.api-market-content {
|
.api-market-content {
|
||||||
max-width: 100vw;
|
width: 100%;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-card {
|
||||||
|
height: 285px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -255,11 +255,19 @@
|
|||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 40px auto 100px;
|
margin: 40px auto 100px;
|
||||||
font-family: 'Spoqa Han Sans Neo', 'Noto Sans CJK KR', sans-serif;
|
font-family: 'Spoqa Han Sans Neo', 'Noto Sans CJK KR', sans-serif;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 20px auto 50px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-management-content {
|
.app-management-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-management-header {
|
.app-management-header {
|
||||||
@@ -268,6 +276,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 20px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -318,6 +334,14 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
height: auto;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0px 6px 15px rgba(192, 192, 192, 0.35);
|
box-shadow: 0px 6px 15px rgba(192, 192, 192, 0.35);
|
||||||
@@ -335,12 +359,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
img,
|
img,
|
||||||
svg {
|
svg {
|
||||||
width: 46px;
|
width: 46px;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +385,10 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card-header {
|
.app-card-header {
|
||||||
@@ -366,6 +405,10 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card-badge-container {
|
.app-card-badge-container {
|
||||||
@@ -391,6 +434,11 @@
|
|||||||
color: #ef9546;
|
color: #ef9546;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.badge-requested {
|
||||||
|
background: #ffebeb;
|
||||||
|
color: #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
&.badge-approved {
|
&.badge-approved {
|
||||||
background: #1b4ab7;
|
background: #1b4ab7;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
@@ -411,6 +459,10 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card-footer-row {
|
.app-card-footer-row {
|
||||||
@@ -419,6 +471,12 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card-expected-date {
|
.app-card-expected-date {
|
||||||
@@ -427,7 +485,13 @@
|
|||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
margin-left: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-card-expected-date {
|
.app-card-expected-date {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|||||||
@@ -2938,6 +2938,10 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
font-family: 'Spoqa Han Sans Neo', 'Noto Sans KR', sans-serif;
|
font-family: 'Spoqa Han Sans Neo', 'Noto Sans KR', sans-serif;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
font-size: 23px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 진행 단계 카드 ──
|
// ── 진행 단계 카드 ──
|
||||||
@@ -3015,6 +3019,16 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #b0bec5; // SVG currentColor 비활성 색상
|
color: #b0bec5; // SVG currentColor 비활성 색상
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@@ -3032,13 +3046,13 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
width: 20px;
|
width: 24px !important;
|
||||||
height: 20px;
|
height: 24px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
width: 16px;
|
width: 18px !important;
|
||||||
height: 16px;
|
height: 18px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3467,6 +3481,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
margin-top: 40px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3707,7 +3722,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
transform: translateY(-5px);
|
transform: translateY(-5px);
|
||||||
box-shadow: 0px 8px 12px rgba(194, 194, 194, 0.4);
|
box-shadow: 0px 8px 12px rgba(194, 194, 194, 0.4);
|
||||||
background: linear-gradient(to bottom, #50caff, #2088ff);
|
background: linear-gradient(to bottom, #50caff, #2088ff);
|
||||||
border-color: transparent;
|
border: none;
|
||||||
|
|
||||||
.s2-api-card-badge {
|
.s2-api-card-badge {
|
||||||
background-color: #d8f0ff;
|
background-color: #d8f0ff;
|
||||||
@@ -4027,7 +4042,8 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Floating Cart Button ──
|
// ── Floating Cart Button ──
|
||||||
.s2-floating-cart {
|
// @at-root: body 직속(pagePopups)으로 렌더되므로 step2-wrap 스코프 밖 전역 규칙으로 컴파일
|
||||||
|
@at-root .s2-floating-cart {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 80px;
|
bottom: 80px;
|
||||||
right: 40px;
|
right: 40px;
|
||||||
@@ -4114,7 +4130,8 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Selected APIs Modal ──
|
// ── Selected APIs Modal ──
|
||||||
.s2-modal {
|
// @at-root: body 직속(pagePopups)으로 렌더되므로 step2-wrap 스코프 밖 전역 규칙으로 컴파일
|
||||||
|
@at-root .s2-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -828,6 +828,13 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
flex-direction: column; // Stack vertically on mobile
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-cancel {
|
.btn-cancel {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
@@ -844,6 +851,11 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
width: 100%;
|
||||||
|
order: 2; // Position Cancel at the bottom
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-submit {
|
.btn-submit {
|
||||||
@@ -860,6 +872,12 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
background-color: darken(#2a69de, 5%);
|
background-color: darken(#2a69de, 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: $breakpoint-sm) {
|
||||||
|
width: 100%;
|
||||||
|
order: 1; // Position Submit at the top
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
gap: 15px;
|
gap: 15px;
|
||||||
|
|
||||||
@media (max-width: $breakpoint-md) {
|
@media (max-width: $breakpoint-md) {
|
||||||
flex-direction: column;
|
// flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -158,6 +158,28 @@
|
|||||||
color: #0a4ea3;
|
color: #0a4ea3;
|
||||||
border: 1px solid #b3d4ff;
|
border: 1px solid #b3d4ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--test {
|
||||||
|
background: #e6fffa;
|
||||||
|
color: #0d9488;
|
||||||
|
border: 1px solid #99f6e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--normal {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&.lg {
|
||||||
|
height: 28px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 50px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-detail-title {
|
.notice-detail-title {
|
||||||
@@ -222,11 +244,13 @@
|
|||||||
.attachment-filename {
|
.attachment-filename {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incident Info Table
|
// Incident Info Table
|
||||||
.notice-detail-incident-info {
|
.notice-detail-incident-info,
|
||||||
|
.notice-detail-affected-info {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
.detail-table {
|
.detail-table {
|
||||||
@@ -250,11 +274,115 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding-left: 19px;
|
padding: 10px 19px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #000;
|
color: #000;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
colgroup {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody,
|
||||||
|
tr,
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
background: #f8fafc;
|
||||||
|
height: auto;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Affected APIs Styling
|
||||||
|
.affected-apis-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-apis-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affected-api-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background-color: #f3f6fa;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555555;
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #0a4ea3;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-affected-toggle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #0049b4;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: normal;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #003685;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active .arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,37 +452,8 @@
|
|||||||
|
|
||||||
// List Button - Figma Design
|
// List Button - Figma Design
|
||||||
.btn-notice-list {
|
.btn-notice-list {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 247px;
|
width: 247px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background: #2a69de;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: $transition-base;
|
|
||||||
|
|
||||||
@media (max-width: $breakpoint-md) {
|
|
||||||
width: auto;
|
|
||||||
min-width: 120px;
|
|
||||||
padding: 0 20px;
|
|
||||||
height: 48px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: darken(#2a69de, 10%);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notice detail card
|
// Notice detail card
|
||||||
|
|||||||
@@ -14,13 +14,97 @@
|
|||||||
@include respond-to('sm') {
|
@include respond-to('sm') {
|
||||||
margin-bottom: $spacing-3xl;
|
margin-bottom: $spacing-3xl;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.page-header-content {
|
// Recent applications accordion block styling
|
||||||
max-width: 800px;
|
.recent-apps {
|
||||||
margin: 0 auto;
|
margin-bottom: 70px;
|
||||||
|
|
||||||
|
.recent-apps-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.recent-apps-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-apps-item {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #2a69de;
|
||||||
|
box-shadow: 0 4px 12px rgba(42, 105, 222, 0.08);
|
||||||
|
|
||||||
|
.recent-apps-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-apps-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-apps-date {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-apps-subject {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-apps-arrow {
|
||||||
|
color: #64748b;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-apps-body {
|
||||||
|
display: none;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #334155;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
font-size: $font-size-3xl;
|
font-size: $font-size-3xl;
|
||||||
font-weight: $font-weight-bold;
|
font-weight: $font-weight-bold;
|
||||||
color: $text-dark;
|
color: $text-dark;
|
||||||
@@ -44,9 +128,9 @@
|
|||||||
font-size: $font-size-2xl;
|
font-size: $font-size-2xl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-description {
|
.page-description {
|
||||||
font-size: $font-size-lg;
|
font-size: $font-size-lg;
|
||||||
color: $text-gray;
|
color: $text-gray;
|
||||||
line-height: $line-height-normal;
|
line-height: $line-height-normal;
|
||||||
@@ -70,7 +154,6 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partnership Form Container
|
// Partnership Form Container
|
||||||
@@ -194,7 +277,9 @@
|
|||||||
// .form-actions moved to components/_forms.scss
|
// .form-actions moved to components/_forms.scss
|
||||||
|
|
||||||
// File Upload Wrapper Override for Partnership Page
|
// File Upload Wrapper Override for Partnership Page
|
||||||
.partnership-card .file-upload-wrapper {
|
.partnership-card,
|
||||||
|
.service-content {
|
||||||
|
.file-upload-wrapper {
|
||||||
.file-name-display {
|
.file-name-display {
|
||||||
&.has-file {
|
&.has-file {
|
||||||
color: $text-dark;
|
color: $text-dark;
|
||||||
@@ -204,9 +289,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-upload-btn {
|
||||||
|
margin: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 55px;
|
||||||
|
width: 120px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #4685ef;
|
||||||
|
border: 1px solid #2a69de;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f8faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: $breakpoint-sm) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
.file-upload-btn {
|
.file-upload-btn {
|
||||||
order: 1;
|
order: 1;
|
||||||
|
width: 100%; // Make full-width on mobile
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name-display {
|
.file-name-display {
|
||||||
@@ -217,6 +328,7 @@
|
|||||||
order: 3;
|
order: 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -251,6 +363,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-upload-wrapper {
|
.file-upload-wrapper {
|
||||||
|
|
||||||
.file-upload-btn,
|
.file-upload-btn,
|
||||||
.file-remove-btn {
|
.file-remove-btn {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -139,8 +139,14 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
min-width: 0; // Prevent flex item from expanding horizontally beyond parent
|
min-width: 0; // Prevent flex item from expanding horizontally beyond parent
|
||||||
|
|
||||||
|
&.n-gap {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.service-sidebar {
|
.service-sidebar {
|
||||||
width: 213px;
|
width: 213px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -227,13 +233,42 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
|
|||||||
|
|
||||||
.service-sidebar {
|
.service-sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-nav {
|
.service-nav {
|
||||||
flex-direction: row;
|
flex-direction: row !important;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap !important;
|
||||||
justify-content: center;
|
justify-content: flex-start !important;
|
||||||
padding: 20px;
|
padding: 16px 20px !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
gap: 24px !important;
|
||||||
|
border: 1px solid #e3e8f0 !important;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: #ffffff !important;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
|
||||||
|
.service-sidebar__profile,
|
||||||
|
.service-sidebar__divider {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-nav__item {
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사이드바가 세로로 배치되면 가로 여백이 없으므로 100vw로 보정
|
// 사이드바가 세로로 배치되면 가로 여백이 없으므로 100vw로 보정
|
||||||
@@ -1008,7 +1043,7 @@ $o2leg-err-fg: #a23b3b;
|
|||||||
// -- Section 2: 사전 준비 ----------------------------------------------------
|
// -- Section 2: 사전 준비 ----------------------------------------------------
|
||||||
&__prereq-grid {
|
&__prereq-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1079,18 +1114,28 @@ $o2leg-err-fg: #a23b3b;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
|
|
||||||
|
// 연속 배치된 grid 사이 간격을 내부 gap(24px)과 동일하게 유지
|
||||||
|
// (언어별 코드 블럭이 두 grid 에 나뉘어 있어 간격이 달라 보이는 문제 방지)
|
||||||
|
+.oauth2-2legged__step-grid {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__endpoint-box {
|
&__endpoint-box {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: 1fr;
|
||||||
align-items: center;
|
gap: 8px;
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border: 1px solid $o2leg-border;
|
border: 1px solid $o2leg-border;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.oauth2-2legged__method,
|
||||||
|
.oauth2-2legged__endpoint-content-type {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__method {
|
&__method {
|
||||||
@@ -1244,6 +1289,7 @@ $o2leg-err-fg: #a23b3b;
|
|||||||
background: $o2leg-text-dark;
|
background: $o2leg-text-dark;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
|||||||
@@ -9,11 +9,9 @@
|
|||||||
|
|
||||||
// User Management Container (List Page)
|
// User Management Container (List Page)
|
||||||
.user-management-container {
|
.user-management-container {
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: $spacing-3xl 0;
|
padding: $spacing-3xl 0;
|
||||||
margin-bottom: $spacing-2xl;
|
margin-bottom: $spacing-2xl;
|
||||||
min-height: 750px; // Ensure the footer doesn't float when content is short
|
min-height: 650px; // Ensure the footer doesn't float when content is short
|
||||||
|
|
||||||
@media (max-width: $breakpoint-sm) {
|
@media (max-width: $breakpoint-sm) {
|
||||||
padding: $spacing-lg $spacing-md;
|
padding: $spacing-lg $spacing-md;
|
||||||
@@ -109,12 +107,6 @@
|
|||||||
grid-template-columns: 24px 112px 1fr 100px 100px 287px;
|
grid-template-columns: 24px 112px 1fr 100px 100px 287px;
|
||||||
padding: 0 29px; // Match standard board-table padding
|
padding: 0 29px; // Match standard board-table padding
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
||||||
@media (max-width: $breakpoint-sm) {
|
|
||||||
display: flex;
|
|
||||||
padding: 15px;
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-cell,
|
.header-cell,
|
||||||
@@ -505,201 +497,137 @@
|
|||||||
display: contents;
|
display: contents;
|
||||||
|
|
||||||
.total-count {
|
.total-count {
|
||||||
order: 1;
|
order: 1 !important;
|
||||||
text-align: left;
|
display: flex !important;
|
||||||
font-size: 16px;
|
align-items: center !important;
|
||||||
color: $black;
|
width: 100% !important;
|
||||||
padding-bottom: 6px;
|
text-align: left !important;
|
||||||
border-bottom: 1.5px solid #212529;
|
font-size: 16px !important;
|
||||||
|
color: $black !important;
|
||||||
|
padding-bottom: 6px !important;
|
||||||
|
border-bottom: 1.5px solid #212529 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
color: #0049b4;
|
color: #0049b4 !important;
|
||||||
font-weight: $font-weight-bold;
|
font-weight: $font-weight-bold !important;
|
||||||
font-size: 16px;
|
font-size: 16px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이용자 추가 버튼 - 테이블 아래로 이동
|
// 이용자 추가 버튼 (검색창 위치와 동일하게 총건수 바로 아래에 노출하도록 순서 조정)
|
||||||
.search-box {
|
.search-box {
|
||||||
order: 4;
|
order: 2 !important;
|
||||||
width: 100%;
|
width: 100% !important;
|
||||||
|
margin-top: 10px !important;
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List Table - 모바일 디자인 (코너 라운드 적용)
|
|
||||||
.list-table {
|
|
||||||
order: 2;
|
|
||||||
background: $white;
|
|
||||||
border-radius: $border-radius-lg;
|
|
||||||
overflow: visible; // dropdown이 보이도록 변경
|
|
||||||
|
|
||||||
// Header - 모바일에서 표시 (Figma: 40px 높이, #3BA4ED 배경)
|
|
||||||
.list-table-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 8px;
|
|
||||||
background: #3BA4ED;
|
|
||||||
border-radius: $border-radius-md $border-radius-md 0 0;
|
|
||||||
|
|
||||||
.header-cell {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: $font-weight-bold;
|
|
||||||
color: $white;
|
|
||||||
padding: 0 4px;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
// NO 컬럼 - 모바일에서 숨김
|
|
||||||
&:nth-child(1) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 성명 컬럼 - 60px
|
|
||||||
&:nth-child(2) {
|
|
||||||
width: 60px !important;
|
|
||||||
min-width: 60px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이메일 아이디 컬럼 - flex 1
|
|
||||||
&:nth-child(3) {
|
|
||||||
flex: 1 !important;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 권한 컬럼 - 39px
|
|
||||||
&:nth-child(4) {
|
|
||||||
width: 39px !important;
|
|
||||||
min-width: 39px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 계정상태 컬럼 - 61px
|
|
||||||
&:nth-child(5) {
|
|
||||||
width: 61px !important;
|
|
||||||
min-width: 61px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 설정 컬럼 - 모바일에서 표시 (40px)
|
|
||||||
&:nth-child(6) {
|
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
width: 40px !important;
|
align-items: center !important;
|
||||||
min-width: 40px;
|
justify-content: center !important;
|
||||||
flex: none;
|
width: 100% !important;
|
||||||
|
height: 44px !important;
|
||||||
|
margin-top: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body
|
// List Table - 모바일 디자인 (Q&A 세로 카드형 레이아웃 적용)
|
||||||
.list-table-body {
|
.board-table--user-list {
|
||||||
display: flex;
|
order: 3 !important;
|
||||||
flex-direction: column;
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.board-table-header {
|
||||||
|
display: none !important; // 헤더는 모바일에서 숨김
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row - 모바일 디자인 (Figma: 40px 높이)
|
.board-table-row {
|
||||||
.list-table-row {
|
display: flex !important;
|
||||||
display: flex;
|
flex-direction: column !important;
|
||||||
flex-direction: row;
|
align-items: flex-start !important;
|
||||||
align-items: center;
|
height: auto !important;
|
||||||
height: 40px;
|
padding: 20px 15px !important;
|
||||||
padding: 0 8px;
|
gap: 12px !important;
|
||||||
gap: 0;
|
border-bottom: 1px solid #e2e8f0 !important;
|
||||||
position: relative; // dropdown positioning
|
background: $white;
|
||||||
|
margin-bottom: 10px;
|
||||||
// 짝수/홀수 배경색
|
|
||||||
&:nth-child(even) {
|
|
||||||
background-color: #EDF5FD;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:nth-child(odd) {
|
|
||||||
background-color: $white;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba($primary-blue, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-cell {
|
.row-cell {
|
||||||
font-size: 13px;
|
display: flex !important;
|
||||||
font-weight: $font-weight-regular;
|
justify-content: flex-start !important;
|
||||||
color: #212529;
|
width: 100% !important;
|
||||||
padding: 0 4px;
|
font-size: 14px !important;
|
||||||
justify-content: center;
|
color: #212529 !important;
|
||||||
white-space: nowrap;
|
padding: 0 !important;
|
||||||
overflow: hidden;
|
white-space: normal !important;
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
// data-label 숨김
|
|
||||||
&::before {
|
&::before {
|
||||||
display: none;
|
content: attr(data-label) !important;
|
||||||
|
display: inline-block !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
color: #64748b !important;
|
||||||
|
margin-right: 15px !important;
|
||||||
|
min-width: 70px !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NO 컬럼 - 모바일에서 숨김
|
// NO 컬럼 - 모바일 숨김
|
||||||
&:nth-child(1) {
|
&[data-label="NO"] {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성명 컬럼 - 60px
|
// 성명 컬럼 - 제목처럼 크게 노출 (라벨 제거)
|
||||||
&:nth-child(2) {
|
&[data-label="성명"] {
|
||||||
width: 60px !important;
|
font-size: 16px !important;
|
||||||
min-width: 60px;
|
font-weight: 600 !important;
|
||||||
flex: none;
|
color: #0f172a !important;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #212529;
|
color: #0f172a !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 13px;
|
|
||||||
|
&:hover {
|
||||||
|
color: #2a69de !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이메일 아이디 컬럼 - flex 1
|
// 설정 버튼 배치 (액션이 있을 때만 경계점선 표시)
|
||||||
&:nth-child(3) {
|
|
||||||
flex: 1 !important;
|
|
||||||
min-width: 80px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 권한 컬럼 - 39px
|
|
||||||
&:nth-child(4) {
|
|
||||||
width: 39px !important;
|
|
||||||
min-width: 39px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 계정상태 컬럼 - 61px
|
|
||||||
&:nth-child(5) {
|
|
||||||
width: 61px !important;
|
|
||||||
min-width: 61px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 설정 컬럼 (row-actions) - 모바일에서 표시 (40px)
|
|
||||||
&:nth-child(6),
|
|
||||||
&.row-actions {
|
&.row-actions {
|
||||||
display: flex !important;
|
margin-top: 0;
|
||||||
width: 40px !important;
|
padding-top: 0 !important;
|
||||||
min-width: 40px;
|
border-top: none;
|
||||||
flex: none;
|
justify-content: flex-end !important;
|
||||||
justify-content: center;
|
|
||||||
overflow: visible;
|
&.has-actions {
|
||||||
}
|
margin-top: 8px;
|
||||||
|
padding-top: 12px !important;
|
||||||
|
border-top: 1px dashed #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// row-actions 클래스 표시
|
&::before {
|
||||||
.row-actions {
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-desktop {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-mobile {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Empty State - 모바일 조정
|
// Empty State - 모바일 조정
|
||||||
.user-empty-state {
|
.user-empty-state {
|
||||||
@@ -718,9 +646,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pagination - 모바일 디자인 (테이블 다음, 버튼 이전)
|
// Pagination - 모바일 디자인 (테이블 다음)
|
||||||
.pagination {
|
.pagination {
|
||||||
order: 3;
|
order: 4 !important;
|
||||||
gap: 13px;
|
gap: 13px;
|
||||||
padding: $spacing-lg 0;
|
padding: $spacing-lg 0;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// Webhook 신청/관리 (DJPGPT0004)
|
||||||
|
// App API Key 신청 3-step UI 톤을 답습한 자체 완결 스타일.
|
||||||
|
|
||||||
|
$wh-primary: #0b5fff;
|
||||||
|
$wh-primary-dark: #0847c4;
|
||||||
|
$wh-danger: #e03131;
|
||||||
|
$wh-border: #e2e6ee;
|
||||||
|
$wh-muted: #6b7280;
|
||||||
|
$wh-bg-soft: #f5f7fb;
|
||||||
|
|
||||||
|
.webhook-container {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 60px;
|
||||||
|
|
||||||
|
.field-help { color: $wh-muted; font-size: 13px; margin-top: 6px; }
|
||||||
|
.field-error,
|
||||||
|
.webhook-field-error { color: $wh-danger; font-size: 13px; margin-top: 6px; }
|
||||||
|
.req { color: $wh-danger; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 빈 상태 ----------
|
||||||
|
.webhook-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 20px;
|
||||||
|
background: $wh-bg-soft;
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 14px;
|
||||||
|
|
||||||
|
.empty-icon { font-size: 48px; }
|
||||||
|
h3 { margin: 16px 0 8px; font-size: 20px; }
|
||||||
|
p { color: $wh-muted; margin-bottom: 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 진행 표시 ----------
|
||||||
|
.webhook-steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
|
||||||
|
.webhook-step {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $wh-bg-soft;
|
||||||
|
color: $wh-muted;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.step-no {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d5dbe6; color: #fff; font-size: 12px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba($wh-primary, 0.1);
|
||||||
|
color: $wh-primary;
|
||||||
|
.step-no { background: $wh-primary; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 폼 ----------
|
||||||
|
.webhook-field {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
label { display: block; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
&:focus { outline: none; border-color: $wh-primary; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-eventtype-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.eventtype-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input { width: 18px; height: 18px; }
|
||||||
|
.eventtype-name { font-weight: 600; }
|
||||||
|
.eventtype-code { color: $wh-muted; font-size: 12px; margin-left: auto; }
|
||||||
|
&:hover { border-color: $wh-primary; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API 선택 ----------
|
||||||
|
.webhook-api-groups { margin: 16px 0 8px; }
|
||||||
|
.webhook-api-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
.group-name { font-size: 16px; margin-bottom: 12px; }
|
||||||
|
}
|
||||||
|
.webhook-api-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.webhook-api-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input { width: 18px; height: 18px; }
|
||||||
|
.api-name { font-weight: 600; }
|
||||||
|
.api-id { color: $wh-muted; font-size: 12px; margin-left: auto; }
|
||||||
|
&:hover { border-color: $wh-primary; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 액션 버튼 ----------
|
||||||
|
.webhook-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 32px;
|
||||||
|
&.center { justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-webhook-next,
|
||||||
|
.btn-webhook-create {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: $wh-primary; color: #fff;
|
||||||
|
border: none; border-radius: 10px;
|
||||||
|
font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none;
|
||||||
|
&:hover { background: $wh-primary-dark; }
|
||||||
|
}
|
||||||
|
.btn-webhook-cancel {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #fff; color: $wh-muted;
|
||||||
|
border: 1px solid $wh-border; border-radius: 10px;
|
||||||
|
font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none;
|
||||||
|
&:hover { border-color: $wh-muted; }
|
||||||
|
}
|
||||||
|
.btn-webhook-danger {
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #fff; color: $wh-danger;
|
||||||
|
border: 1px solid $wh-danger; border-radius: 10px;
|
||||||
|
font-size: 15px; font-weight: 600; cursor: pointer;
|
||||||
|
&:hover { background: $wh-danger; color: #fff; }
|
||||||
|
}
|
||||||
|
.btn-mini {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: $wh-bg-soft; color: $wh-primary;
|
||||||
|
border: 1px solid $wh-border; border-radius: 8px;
|
||||||
|
font-size: 12px; cursor: pointer;
|
||||||
|
&:hover { background: rgba($wh-primary, 0.1); }
|
||||||
|
}
|
||||||
|
.btn-copy {
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: $wh-primary; color: #fff;
|
||||||
|
border: none; border-radius: 8px; cursor: pointer;
|
||||||
|
&:hover { background: $wh-primary-dark; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 등록 카드 ----------
|
||||||
|
.webhook-card {
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
.webhook-card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
h3 { font-size: 18px; }
|
||||||
|
.webhook-created { color: $wh-muted; font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-card-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid $wh-border;
|
||||||
|
|
||||||
|
.row-label { width: 110px; color: $wh-muted; font-weight: 600; flex-shrink: 0; }
|
||||||
|
.row-value { flex: 1; word-break: break-all; }
|
||||||
|
&.secret-row, .secret-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
code { background: $wh-bg-soft; padding: 4px 8px; border-radius: 6px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventtype-badge,
|
||||||
|
.api-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px; margin: 2px 4px 2px 0;
|
||||||
|
border-radius: 999px; font-size: 12px;
|
||||||
|
background: rgba($wh-primary, 0.1); color: $wh-primary;
|
||||||
|
}
|
||||||
|
.api-badge { background: $wh-bg-soft; color: $wh-muted; }
|
||||||
|
|
||||||
|
.webhook-card-actions {
|
||||||
|
display: flex; gap: 12px; justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 완료 화면 ----------
|
||||||
|
.webhook-complete {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
.complete-icon { font-size: 48px; }
|
||||||
|
h3 { margin: 16px 0; }
|
||||||
|
|
||||||
|
.webhook-secret-box {
|
||||||
|
text-align: left;
|
||||||
|
max-width: 640px; margin: 24px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: $wh-bg-soft; border: 1px solid $wh-border; border-radius: 12px;
|
||||||
|
|
||||||
|
.secret-warning {
|
||||||
|
color: #92400e; background: #fef3c7;
|
||||||
|
border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; font-size: 13px;
|
||||||
|
}
|
||||||
|
label { display: block; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.secret-value-row {
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
input { flex: 1; padding: 12px 14px; border: 1px solid $wh-border; border-radius: 10px; font-family: monospace; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- figma s1-form-card 내부 재사용 ----------
|
||||||
|
// step1-wrap(s1 디자인) 카드 안에서 등록 카드 요소를 쓸 때
|
||||||
|
// 루트 카드 스타일은 .s1-form-card 것을 쓰고 내부 row/배지만 매핑한다.
|
||||||
|
.step1-wrap .s1-form-card {
|
||||||
|
.webhook-card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
h3 { font-size: 18px; }
|
||||||
|
.webhook-created { color: $wh-muted; font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-card-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid $wh-border;
|
||||||
|
|
||||||
|
.row-label { width: 110px; color: $wh-muted; font-weight: 600; flex-shrink: 0; }
|
||||||
|
.row-value { flex: 1; word-break: break-all; }
|
||||||
|
.secret-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
code { background: $wh-bg-soft; padding: 4px 8px; border-radius: 6px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventtype-badge,
|
||||||
|
.api-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px; margin: 2px 4px 2px 0;
|
||||||
|
border-radius: 999px; font-size: 12px;
|
||||||
|
background: rgba($wh-primary, 0.1); color: $wh-primary;
|
||||||
|
}
|
||||||
|
.api-badge { background: $wh-bg-soft; color: $wh-muted; }
|
||||||
|
|
||||||
|
// 빈 상태: s1 카드 안에서는 이중 박스 제거
|
||||||
|
.webhook-empty {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// s1-actions 안 취소/이전/삭제 버튼: 좌측 배치
|
||||||
|
.step1-wrap .s1-actions,
|
||||||
|
.step2-wrap .s1-actions,
|
||||||
|
.step3-wrap .s1-actions {
|
||||||
|
.btn-webhook-cancel,
|
||||||
|
.btn-webhook-danger { margin-right: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 개발가이드 링크 ----------
|
||||||
|
.webhook-guide-link {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: 1px dashed $wh-border;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: $wh-primary;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $wh-primary;
|
||||||
|
background: rgba($wh-primary, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 비밀번호 모달 ----------
|
||||||
|
.webhook-modal {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
.webhook-modal-box {
|
||||||
|
background: #fff; border-radius: 14px;
|
||||||
|
padding: 28px; width: 360px; max-width: 90vw;
|
||||||
|
|
||||||
|
h4 { font-size: 18px; margin-bottom: 8px; }
|
||||||
|
p { color: $wh-muted; font-size: 14px; margin-bottom: 16px; }
|
||||||
|
input {
|
||||||
|
width: 100%; padding: 12px 14px;
|
||||||
|
border: 1px solid $wh-border; border-radius: 10px; margin-bottom: 8px;
|
||||||
|
&:focus { outline: none; border-color: $wh-primary; }
|
||||||
|
}
|
||||||
|
.webhook-modal-actions {
|
||||||
|
display: flex; gap: 12px; justify-content: flex-end; margin-top: 12px;
|
||||||
|
.btn-webhook-next, .btn-webhook-cancel { padding: 10px 20px; font-size: 14px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,31 +13,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<section class="api-market-container">
|
<section class="container api-market-container">
|
||||||
|
|
||||||
<!-- Sidebar Navigation -->
|
<!-- Sidebar Navigation -->
|
||||||
<aside class="api-market-sidebar" id="apiSidebar">
|
<aside class="service-sidebar" id="apiSidebar">
|
||||||
|
<nav class="service-nav">
|
||||||
<div class="api-sidebar-header">
|
|
||||||
<img th:src="@{/img/api_sidebar.png}" alt="API">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="api-sidebar-nav">
|
|
||||||
<!-- All APIs -->
|
<!-- All APIs -->
|
||||||
<div class="menu-section">
|
<a class="service-nav__item"
|
||||||
<a class="menu-title" th:classappend="${selected == '-1'} ? 'active'" th:href="@{/apis}">
|
th:classappend="${selected == '-1' || #request.getParameter('selected') == '-1'} ? 'service-nav__item--active' : ''"
|
||||||
|
th:href="@{/apis}">
|
||||||
전체
|
전체
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Service Categories -->
|
<!-- Service Categories -->
|
||||||
<div class="menu-section" th:each="service : ${services}">
|
<a class="service-nav__item" th:each="service : ${services}"
|
||||||
<a class="menu-title"
|
th:classappend="${(#request.getParameter('selected') != null ? #request.getParameter('selected') : (selected != null ? selected : apiSpecInfo.service)) == service.id} ? 'service-nav__item--active' : ''"
|
||||||
th:classappend="${selected == service.id} ? 'active'"
|
|
||||||
th:href="@{/apis(groupIds=${service.id})}">
|
th:href="@{/apis(groupIds=${service.id})}">
|
||||||
[[${service.groupName}]]
|
[[${service.groupName}]]
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -93,7 +86,9 @@
|
|||||||
<div class="api-simple-description" th:if="${apiSpecInfo.apiSimpleDescription != null}">
|
<div class="api-simple-description" th:if="${apiSpecInfo.apiSimpleDescription != null}">
|
||||||
<p th:text="${apiSpecInfo.apiSimpleDescription}">Simple API description</p>
|
<p th:text="${apiSpecInfo.apiSimpleDescription}">Simple API description</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-content" th:utext="${apiSpecInfo.description}">
|
<!--/* [editor-content] 관리자포탈 Summernote 콘텐츠 스타일 정렬(editor-content.css).
|
||||||
|
과거 스타일(자체 detail-content SCSS)로 회귀하려면 editor-content 클래스만 제거 */-->
|
||||||
|
<div class="detail-content editor-content" th:utext="${apiSpecInfo.description}">
|
||||||
Detailed API description
|
Detailed API description
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +101,8 @@
|
|||||||
<div class="org-section-header org-section-header--agreement">
|
<div class="org-section-header org-section-header--agreement">
|
||||||
<h3>Request Specification</h3>
|
<h3>Request Specification</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-content" th:utext="${apiSpecInfo.apiRequestSpec}">
|
<!--/* [editor-content] 회귀 시 editor-content 클래스 제거 */-->
|
||||||
|
<div class="detail-content editor-content" th:utext="${apiSpecInfo.apiRequestSpec}">
|
||||||
Request spec
|
Request spec
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +122,8 @@
|
|||||||
<div class="org-section-header org-section-header--agreement">
|
<div class="org-section-header org-section-header--agreement">
|
||||||
<h3>Response Specification</h3>
|
<h3>Response Specification</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-content" th:utext="${apiSpecInfo.apiResponseSpec}">
|
<!--/* [editor-content] 회귀 시 editor-content 클래스 제거 */-->
|
||||||
|
<div class="detail-content editor-content" th:utext="${apiSpecInfo.apiResponseSpec}">
|
||||||
Response spec
|
Response spec
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,7 +298,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger.json`;
|
// UI 전용 spec: 서버 주소가 responseType(sample/mock/gw) 설정에 따라 치환됨.
|
||||||
|
// (swagger.json 은 외부 다운로드용으로 항상 GW 주소 고정)
|
||||||
|
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger-ui.json`;
|
||||||
|
|
||||||
// Load Swagger UI scripts dynamically
|
// Load Swagger UI scripts dynamically
|
||||||
const loadScript = (src) => {
|
const loadScript = (src) => {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘"
|
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
<div class="service-hero__badge">
|
<div class="service-hero__badge">
|
||||||
@@ -129,8 +128,11 @@
|
|||||||
apiCards.forEach(function (card) {
|
apiCards.forEach(function (card) {
|
||||||
// Click handler
|
// Click handler
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
const href = this.getAttribute('data-href');
|
let href = this.getAttribute('data-href');
|
||||||
if (href) {
|
if (href) {
|
||||||
|
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
|
||||||
|
const val = selectedGroup ? selectedGroup : '-1';
|
||||||
|
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
|
||||||
window.location.href = href;
|
window.location.href = href;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -139,8 +141,11 @@
|
|||||||
card.addEventListener('keypress', function (e) {
|
card.addEventListener('keypress', function (e) {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const href = this.getAttribute('data-href');
|
let href = this.getAttribute('data-href');
|
||||||
if (href) {
|
if (href) {
|
||||||
|
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
|
||||||
|
const val = selectedGroup ? selectedGroup : '-1';
|
||||||
|
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
|
||||||
window.location.href = href;
|
window.location.href = href;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-8
@@ -1,8 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div th:fragment="commentSection(inquiry, commentWritable)" class="djb-comment-section"
|
<div th:fragment="commentSection(inquiry, commentWritable)" class="djb-comment-section"
|
||||||
th:attr="data-inquiry-id=${inquiry.id},data-closed=${inquiry.inquiryStatus == 'CLOSED' ? 'true' : 'false'}">
|
th:attr="data-inquiry-id=${inquiry.id},data-closed=${inquiry.inquiryStatus == 'CLOSED' ? 'true' : 'false'}">
|
||||||
|
|
||||||
<div class="djb-comment-list-box">
|
<div class="djb-comment-list-box">
|
||||||
@@ -13,26 +14,23 @@
|
|||||||
|
|
||||||
<div class="djb-comment-form-wrapper" th:if="${commentWritable}">
|
<div class="djb-comment-form-wrapper" th:if="${commentWritable}">
|
||||||
<form id="djbCommentForm" class="djb-comment-form" onsubmit="return false;">
|
<form id="djbCommentForm" class="djb-comment-form" onsubmit="return false;">
|
||||||
<textarea id="djbCommentInput"
|
<textarea id="djbCommentInput" class="djb-comment-input" rows="4" maxlength="2000" placeholder="내용"
|
||||||
class="djb-comment-input"
|
|
||||||
rows="4"
|
|
||||||
maxlength="2000"
|
|
||||||
placeholder="내용"
|
|
||||||
required></textarea>
|
required></textarea>
|
||||||
<div class="djb-comment-form-actions">
|
<div class="djb-comment-form-actions">
|
||||||
<label class="djb-comment-private-toggle">
|
<label class="djb-comment-private-toggle">
|
||||||
<input type="checkbox" id="djbCommentPrivate"> 비공개
|
<input type="checkbox" id="djbCommentPrivate"> 비공개
|
||||||
</label>
|
</label>
|
||||||
<span class="djb-comment-char-counter" id="djbCommentCharCounter">0 / 2000</span>
|
<span class="djb-comment-char-counter" id="djbCommentCharCounter">0 / 2000</span>
|
||||||
<button type="submit" class="djb-comment-submit">댓글달기</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button type="submit" class="djb-comment-submit">댓글달기</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="djb-comment-form-wrapper djb-comment-readonly" th:unless="${commentWritable}">
|
<div class="djb-comment-form-wrapper djb-comment-readonly" th:unless="${commentWritable}">
|
||||||
<p class="djb-comment-readonly-msg">종료된 문의에는 댓글을 작성할 수 없습니다.</p>
|
<p class="djb-comment-readonly-msg">종료된 문의에는 댓글을 작성할 수 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -3,34 +3,67 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<section layout:fragment="title">
|
<section layout:fragment="title">
|
||||||
<div class="page-title-banner">
|
<div class="page-title-banner">
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
||||||
<h1>FAQ</h1>
|
<h1>FAQ</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="notice-detail-container">
|
<div class="signup-guide-v2">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="service-hero">
|
||||||
|
<div class="service-hero__inner">
|
||||||
|
<div class="service-hero__icon-wrapper">
|
||||||
|
<img th:src="@{/img/keyimage/faq_img.svg}" alt="FAQ 아이콘"
|
||||||
|
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||||
|
</div>
|
||||||
|
<div class="service-hero__content">
|
||||||
|
<div class="service-hero__badge">
|
||||||
|
<span class="service-hero__badge-dot"></span>
|
||||||
|
<span class="service-hero__badge-text">FAQ 게시판</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="service-hero__title">FAQ</h1>
|
||||||
|
<p class="service-hero__desc">서비스 이용 중 궁금한 사항에 대한 답변을 빠르게 확인해 보세요<br>카테고리별 분류와 검색을 통해 원하는 정보를 쉽게 찾아보실 수
|
||||||
|
있습니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container service-main">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('faq')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<section class="service-content n-gap">
|
||||||
|
|
||||||
<!-- FAQ Header: Question and Date -->
|
<!-- FAQ Header: Question and Date -->
|
||||||
<div class="notice-detail-header">
|
<div class="notice-detail-header">
|
||||||
<h2 class="notice-detail-title">
|
<p class="notice-detail-date" th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.11.01</p>
|
||||||
<span th:text="${faq.faqQuestion}">FAQ 질문</span>
|
<div class="notice-detail-title-wrapper">
|
||||||
</h2>
|
<span class="faq-question-q"
|
||||||
<span class="notice-detail-date" th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
|
style="font-family: 'OneShinhan', sans-serif; font-size: 28px; font-weight: 700; color: #4685ef; margin-right: 15px; line-height: 1;">Q</span>
|
||||||
|
<h2 class="notice-detail-title" th:text="${faq.faqQuestion}">FAQ 질문</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attachment Section -->
|
<!-- Attachment Section -->
|
||||||
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(faq.fileId)}">
|
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(faq.fileId)}">
|
||||||
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(faq.fileId)}">
|
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(faq.fileId)}"
|
||||||
|
th:if="${fileInfo != null}">
|
||||||
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
|
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
|
||||||
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link">
|
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'"
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
class="notice-attachment-link">
|
||||||
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z"
|
||||||
|
fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="attachment-label">첨부파일</span>
|
<span class="attachment-label">첨부파일</span>
|
||||||
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
<span
|
||||||
|
class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,12 +78,14 @@
|
|||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="notice-detail-actions">
|
<div class="notice-detail-actions">
|
||||||
<button type="button" class="btn-notice-list" th:onclick="|location.href='@{/faq_list}'|">목록</button>
|
<button type="button" class="btn-action-primary btn-notice-list"
|
||||||
|
th:onclick="|location.href='@{/faq_list}'|">목록</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
</th:block>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
@@ -65,7 +100,7 @@
|
|||||||
return textArea.value;
|
return textArea.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
var element = document.getElementById('faqDetail');
|
var element = document.getElementById('faqDetail');
|
||||||
if (element) {
|
if (element) {
|
||||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
||||||
|
<h1>FAQ</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="signup-guide-v2">
|
<div class="signup-guide-v2">
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
@@ -26,14 +33,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="service-main">
|
<div class="container service-main">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('faq')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('faq')}"></th:block>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<section class="service-content">
|
<section class="service-content n-gap faq-container">
|
||||||
<div class="notice-list-container"
|
|
||||||
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
|
|
||||||
|
|
||||||
<!-- Search and Filter Controls -->
|
<!-- Search and Filter Controls -->
|
||||||
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
|
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
|
||||||
@@ -60,9 +65,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- FAQ Accordion -->
|
<!-- FAQ Accordion (Click to Redirect) -->
|
||||||
<div class="faq-accordion" th:if="${!#lists.isEmpty(page.content)}">
|
<div class="faq-accordion" th:if="${!#lists.isEmpty(page.content)}">
|
||||||
<div class="faq-item" th:each="faq : ${page.content}">
|
<div class="faq-item faq-item--link" th:each="faq : ${page.content}"
|
||||||
|
th:onclick="|location.href='@{/faq_view(id=${faq.id})}'|" style="cursor: pointer;">
|
||||||
<div class="faq-question">
|
<div class="faq-question">
|
||||||
<span class="faq-question-q"
|
<span class="faq-question-q"
|
||||||
style="font-family: 'OneShinhan', sans-serif; font-size: 20px; font-weight: 700; color: #555; margin-right: 15px;">Q</span>
|
style="font-family: 'OneShinhan', sans-serif; font-size: 20px; font-weight: 700; color: #555; margin-right: 15px;">Q</span>
|
||||||
@@ -70,15 +76,10 @@
|
|||||||
<span class="faq-icon">
|
<span class="faq-icon">
|
||||||
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
stroke-linejoin="round" />
|
stroke-linejoin="round" transform="rotate(-90 7 4)" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="faq-answer">
|
|
||||||
<div class="faq-answer-content editor-content" th:utext="${faq.faqAnswer}">
|
|
||||||
답변 내용이 여기에 표시됩니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,8 +105,6 @@
|
|||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
|
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,37 +123,6 @@
|
|||||||
document.faqForm.page.value = 1;
|
document.faqForm.page.value = 1;
|
||||||
document.faqForm.submit();
|
document.faqForm.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeHTMLEntities(text) {
|
|
||||||
var textArea = document.createElement('textarea');
|
|
||||||
textArea.innerHTML = text;
|
|
||||||
return textArea.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Decode HTML entities in FAQ answers
|
|
||||||
document.querySelectorAll('.faq-answer-content').forEach(function (element) {
|
|
||||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
|
||||||
});
|
|
||||||
|
|
||||||
// FAQ Accordion functionality
|
|
||||||
document.querySelectorAll('.faq-question').forEach(function (question) {
|
|
||||||
question.addEventListener('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const faqItem = this.closest('.faq-item');
|
|
||||||
const isActive = faqItem.classList.contains('active');
|
|
||||||
|
|
||||||
// Close all other items (optional - for single open behavior)
|
|
||||||
// document.querySelectorAll('.faq-item.active').forEach(function(item) {
|
|
||||||
// item.classList.remove('active');
|
|
||||||
// });
|
|
||||||
|
|
||||||
// Toggle active state
|
|
||||||
faqItem.classList.toggle('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/q&a_img.svg}" alt="Q&A 아이콘"
|
<img th:src="@{/img/keyimage/q&a_img.png}" alt="Q&A 아이콘"
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="service-main">
|
<div class="container service-main">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
|
||||||
|
|
||||||
@@ -73,7 +73,8 @@
|
|||||||
문의 내용이 여기에 표시됩니다.
|
문의 내용이 여기에 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
<!-- Attachment: 강제 다운로드 링크만 제공 (이미지 인라인 노출 안 함) -->
|
<!-- Attachment: 강제 다운로드 링크만 제공 (이미지 인라인 노출 안 함) -->
|
||||||
<div class="inquiry-detail-attach" th:if="${inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}">
|
<div class="inquiry-detail-attach"
|
||||||
|
th:if="${inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}">
|
||||||
<a th:href="@{/file/download(fileId=${inquiry.attachFile}, fileSn=1)}" class="inquiry-attach-link"
|
<a th:href="@{/file/download(fileId=${inquiry.attachFile}, fileSn=1)}" class="inquiry-attach-link"
|
||||||
download>
|
download>
|
||||||
첨부 이미지 다운로드
|
첨부 이미지 다운로드
|
||||||
@@ -127,7 +128,7 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
var successMsg = [[${success}]];
|
var successMsg = [[${ success }]];
|
||||||
if (successMsg) {
|
if (successMsg) {
|
||||||
customPopups.showAlert(successMsg);
|
customPopups.showAlert(successMsg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,20 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<section layout:fragment="title">
|
<section layout:fragment="title">
|
||||||
<div class="page-title-banner">
|
<div class="page-title-banner">
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
||||||
<h1>Q&A</h1>
|
<h1>Q&A</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<th:block layout:fragment="contentFragment" th:with="isNew=${inquiry.id == null}">
|
<th:block layout:fragment="contentFragment" th:with="isNew=${inquiry.id == null}">
|
||||||
<div class="signup-guide-v2">
|
<div class="signup-guide-v2">
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/q&a_img.svg}" alt="Q&A 아이콘"
|
<img th:src="@{/img/keyimage/q&a_img.png}" alt="Q&A 아이콘"
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
<span class="service-hero__badge-text">Q&A 게시판</span>
|
<span class="service-hero__badge-text">Q&A 게시판</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="service-hero__title">Q&A</h1>
|
<h1 class="service-hero__title">Q&A</h1>
|
||||||
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해 드리겠습니다.</p>
|
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
||||||
|
드리겠습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -53,24 +54,15 @@
|
|||||||
<!-- Subject Field -->
|
<!-- Subject Field -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="djb-label" for="inquirySubject">제목 <span class="required">*</span></label>
|
<label class="djb-label" for="inquirySubject">제목 <span class="required">*</span></label>
|
||||||
<input type="text"
|
<input type="text" id="inquirySubject" th:field="*{inquirySubject}" class="djb-input"
|
||||||
id="inquirySubject"
|
placeholder="제목을 입력하세요" required maxlength="200">
|
||||||
th:field="*{inquirySubject}"
|
|
||||||
class="djb-input"
|
|
||||||
placeholder="제목을 입력하세요"
|
|
||||||
required
|
|
||||||
maxlength="200">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Field -->
|
<!-- Content Field -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="djb-label" for="inquiryDetail">내용 <span class="required">*</span></label>
|
<label class="djb-label" for="inquiryDetail">내용 <span class="required">*</span></label>
|
||||||
<textarea id="inquiryDetail"
|
<textarea id="inquiryDetail" th:field="*{inquiryDetail}" class="djb-textarea" placeholder="내용을 입력하세요"
|
||||||
th:field="*{inquiryDetail}"
|
required maxlength="2000"></textarea>
|
||||||
class="djb-textarea"
|
|
||||||
placeholder="내용을 입력하세요"
|
|
||||||
required
|
|
||||||
maxlength="2000"></textarea>
|
|
||||||
<div class="djb-char-counter-wrapper">
|
<div class="djb-char-counter-wrapper">
|
||||||
<span class="char-counter">0 / 2000</span>
|
<span class="char-counter">0 / 2000</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,13 +103,13 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
<script th:if="${error}" th:inline="javascript">
|
<script th:if="${error}" th:inline="javascript">
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
const errorMsg = [[${error}]];
|
const errorMsg = [[${ error }]];
|
||||||
customPopups.showAlert(errorMsg);
|
customPopups.showAlert(errorMsg);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -133,7 +125,7 @@
|
|||||||
const maxLength = inquiryDetailTextarea.getAttribute('maxlength');
|
const maxLength = inquiryDetailTextarea.getAttribute('maxlength');
|
||||||
charCounter.textContent = currentLength + ' / ' + maxLength;
|
charCounter.textContent = currentLength + ' / ' + maxLength;
|
||||||
|
|
||||||
inquiryDetailTextarea.addEventListener('input', function() {
|
inquiryDetailTextarea.addEventListener('input', function () {
|
||||||
const currentLength = this.value.length;
|
const currentLength = this.value.length;
|
||||||
const maxLength = this.getAttribute('maxlength');
|
const maxLength = this.getAttribute('maxlength');
|
||||||
charCounter.textContent = currentLength + ' / ' + maxLength;
|
charCounter.textContent = currentLength + ' / ' + maxLength;
|
||||||
@@ -182,4 +174,5 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -3,20 +3,26 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
||||||
|
<h1>Q&A</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="signup-guide-v2">
|
<div class="signup-guide-v2">
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<!-- Used notice icon as a placeholder, can be updated if inquiry icon exists -->
|
<img th:src="@{/img/keyimage/q&a_img.png}" alt="Q&A 아이콘"
|
||||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="Q&A 아이콘"
|
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
<div class="service-hero__badge">
|
<div class="service-hero__badge">
|
||||||
<span class="service-hero__badge-dot"></span>
|
<span class="service-hero__badge-dot"></span>
|
||||||
<span class="service-hero__badge-text">공지사항 목록</span>
|
<span class="service-hero__badge-text">Q&A 게시판</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="service-hero__title">Q&A</h1>
|
<h1 class="service-hero__title">Q&A</h1>
|
||||||
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
||||||
@@ -26,12 +32,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="service-main">
|
<div class="container service-main">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<section class="service-content">
|
<section class="service-content n-gap">
|
||||||
<section class="user-management-container inquiry-list-container"
|
<section class="user-management-container inquiry-list-container"
|
||||||
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
|
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
|
||||||
|
|
||||||
@@ -43,8 +49,10 @@
|
|||||||
<div class="table-controls">
|
<div class="table-controls">
|
||||||
<h2 class="total-count">
|
<h2 class="total-count">
|
||||||
총 <strong th:text="${page.totalElements}">0</strong>건
|
총 <strong th:text="${page.totalElements}">0</strong>건
|
||||||
<span class="inquiry-visibility-notice" th:if="${visibilityCeiling != null}">
|
<span class="inquiry-visibility-notice" th:if="${visibilityCeiling != null}"
|
||||||
공개범위 설정: <span th:text="${visibilityCeiling.label()}">법인공개</span>
|
style="font-size: 13px; color: #666; font-weight: normal; margin-left: 10px;">
|
||||||
|
공개범위 설정: <span th:text="${visibilityCeiling.label()}"
|
||||||
|
style="font-weight: 600; color: #0049b4;">법인공개</span>
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -69,7 +77,7 @@
|
|||||||
<div class="board-table-header">
|
<div class="board-table-header">
|
||||||
<div class="header-cell" style="width: 80px;">NO</div>
|
<div class="header-cell" style="width: 80px;">NO</div>
|
||||||
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
|
<div class="header-cell" style="flex: 1; min-width: 200px;">제목</div>
|
||||||
<div class="header-cell" style="width: 100px;">작성자</div>
|
<div class="header-cell" style="width: 120px;">작성자</div>
|
||||||
<div class="header-cell" style="width: 120px;">처리상태</div>
|
<div class="header-cell" style="width: 120px;">처리상태</div>
|
||||||
<div class="header-cell" style="width: 80px;">조회수</div>
|
<div class="header-cell" style="width: 80px;">조회수</div>
|
||||||
<div class="header-cell" style="width: 120px;">등록일</div>
|
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||||
@@ -81,41 +89,50 @@
|
|||||||
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
|
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
|
||||||
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
|
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
|
||||||
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
|
th:style="${inquiry.privatePlaceholder} ? 'cursor: default;' : 'cursor: pointer;'">
|
||||||
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
|
<div class="row-cell row-cell--number" data-label="NO"
|
||||||
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
|
||||||
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="제목">
|
<div class="row-cell row-cell--title" data-label="제목">
|
||||||
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
|
<a th:href="${inquiry.privatePlaceholder} ? null : @{/inquiry/detail(id=${inquiry.id})}"
|
||||||
class="notice-title-link"
|
class="notice-title-link"
|
||||||
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
|
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
|
||||||
<span class="notice-number"
|
<span class="notice-number"
|
||||||
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||||
<svg th:if="${inquiry.visibility == 'PRIVATE'}" class="inquiry-lock-icon" width="14" height="14"
|
|
||||||
viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="비공개">
|
<!-- Private/Lock Icon -->
|
||||||
|
<span class="file-icon" th:if="${inquiry.visibility == 'PRIVATE'}">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-label="비공개">
|
||||||
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
|
<rect x="4" y="10" width="16" height="11" rx="2" fill="currentColor" />
|
||||||
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none" />
|
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none" />
|
||||||
</svg>
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
<span th:if="${inquiry.privatePlaceholder}" class="inquiry-private-label">비공개 게시물</span>
|
<span th:if="${inquiry.privatePlaceholder}" class="inquiry-private-label">비공개 게시물</span>
|
||||||
<span th:unless="${inquiry.privatePlaceholder}" th:text="${inquiry.inquirySubject}">질문 제목</span>
|
<span th:unless="${inquiry.privatePlaceholder}" th:text="${inquiry.inquirySubject}"
|
||||||
|
class="inquiry-subject-text">질문 제목</span>
|
||||||
|
|
||||||
|
<!-- Comment Count -->
|
||||||
<span class="inquiry-comment-count"
|
<span class="inquiry-comment-count"
|
||||||
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
|
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
|
||||||
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cell row-cell--writer" style="width: 100px;" data-label="작성자">
|
<div class="row-cell row-cell--writer" data-label="작성자">
|
||||||
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
|
<span class="inquiry-writer-name" th:text="${inquiry.maskedInquirerName}">홍**</span>
|
||||||
<span class="inquiry-writer-org"
|
<span class="inquiry-writer-org"
|
||||||
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
|
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
|
||||||
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
|
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cell" style="width: 120px;" data-label="처리상태">
|
<div class="row-cell row-cell--status" data-label="처리상태">
|
||||||
<span class="inquiry-status-badge"
|
<!-- completed for RESPONDED, closed for CLOSED, pending for PENDING, maintenance for REVIEWING -->
|
||||||
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
<span class="notice-type-badge lg"
|
||||||
|
th:classappend="${inquiry.inquiryStatus == 'RESPONDED' ? 'notice-type-badge--completed' : (inquiry.inquiryStatus == 'CLOSED' ? 'notice-type-badge--closed' : (inquiry.inquiryStatus == 'REVIEWING' ? 'notice-type-badge--maintenance' : 'notice-type-badge--pending'))}"
|
||||||
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
th:text="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).displayName(inquiry.inquiryStatus)}">
|
||||||
답변대기
|
답변대기
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cell" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
<div class="row-cell row-cell--views" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
||||||
<div class="row-cell" style="width: 120px;" data-label="등록일"
|
<div class="row-cell row-cell--date" data-label="등록일"
|
||||||
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,8 +150,8 @@
|
|||||||
|
|
||||||
<!-- New Inquiry Button -->
|
<!-- New Inquiry Button -->
|
||||||
<div style="display: flex; justify-content: center; margin-top: 40px;">
|
<div style="display: flex; justify-content: center; margin-top: 40px;">
|
||||||
<a th:href="@{/inquiry/new}" class="btn btn-primary"
|
<a th:href="@{/inquiry/new}" class="btn-action-primary btn-notice-list"
|
||||||
style="width: 247px; height: 60px; font-size: 20px; font-weight: 700; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: #2a69de; color: #fff; text-decoration: none;">
|
style="display: inline-flex; align-items: center; justify-content: center; text-decoration: none;">
|
||||||
문의하기
|
문의하기
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +167,7 @@
|
|||||||
<th:block layout:fragment="contentScript">
|
<th:block layout:fragment="contentScript">
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
var successMsg = [[${success}]];
|
var successMsg = [[${ success }]];
|
||||||
if (successMsg) {
|
if (successMsg) {
|
||||||
customPopups.showAlert(successMsg);
|
customPopups.showAlert(successMsg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<section layout:fragment="title">
|
<section layout:fragment="title">
|
||||||
<div class="page-title-banner">
|
<div class="page-title-banner">
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
||||||
<h1>공지사항</h1>
|
<h1>공지사항</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="signup-guide-v2">
|
<div class="signup-guide-v2">
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
@@ -25,26 +25,31 @@
|
|||||||
<span class="service-hero__badge-text">고객지원</span>
|
<span class="service-hero__badge-text">고객지원</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="service-hero__title">공지사항</h1>
|
<h1 class="service-hero__title">공지사항</h1>
|
||||||
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해 주시기 바랍니다.</p>
|
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
|
||||||
|
주시기 바랍니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="service-main">
|
<div class="container service-main">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<section class="service-content">
|
<section class="service-content">
|
||||||
<div class="notice-detail-container" style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
|
<div class="notice-detail-container"
|
||||||
|
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
|
||||||
|
|
||||||
<!-- Notice Header: Title and Date -->
|
<!-- Notice Header: Title and Date -->
|
||||||
<div class="notice-detail-header">
|
<div class="notice-detail-header">
|
||||||
<p class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">2026.06.24</p>
|
<p class="notice-detail-date" th:text="${#temporals.format(portalNotice.createdDate, 'yyyy.MM.dd')}">
|
||||||
|
2026.06.24</p>
|
||||||
<div class="notice-detail-title-wrapper">
|
<div class="notice-detail-title-wrapper">
|
||||||
<span class="notice-type-badge notice-type-badge--incident" th:if="${portalNotice.noticeType == '3'}">장애</span>
|
<span class="notice-type-badge lg notice-type-badge--incident"
|
||||||
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${portalNotice.noticeType == '2'}">점검</span>
|
th:if="${portalNotice.noticeType == '3'}">장애</span>
|
||||||
|
<span class="notice-type-badge lg notice-type-badge--maintenance"
|
||||||
|
th:if="${portalNotice.noticeType == '2'}">점검</span>
|
||||||
<h2 class="notice-detail-title" th:text="${portalNotice.noticeSubject}">공지사항 디테일 입니다</h2>
|
<h2 class="notice-detail-title" th:text="${portalNotice.noticeSubject}">공지사항 디테일 입니다</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,26 +58,57 @@
|
|||||||
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}">
|
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}">
|
||||||
<table class="detail-table">
|
<table class="detail-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style="width: 160px;"><col><col style="width: 160px;"><col>
|
<col style="width: 160px;">
|
||||||
|
<col>
|
||||||
|
<col style="width: 160px;">
|
||||||
|
<col>
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>시작</th>
|
<th>시작</th>
|
||||||
<td th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">-</td>
|
<td
|
||||||
|
th:text="${portalNotice.startedAt != null ? #temporals.format(portalNotice.startedAt, 'yyyy-MM-dd HH:mm') : '-'}">
|
||||||
|
-</td>
|
||||||
<th>끝</th>
|
<th>끝</th>
|
||||||
<td th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">-</td>
|
<td
|
||||||
|
th:text="${portalNotice.endAt != null ? #temporals.format(portalNotice.endAt, 'yyyy-MM-dd HH:mm') : '진행중'}">
|
||||||
|
-</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>상태</th>
|
<th>상태</th>
|
||||||
<td th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
|
<td colspan="3" th:text="${portalNotice.state != null ? portalNotice.state : '-'}">-</td>
|
||||||
|
</tr>
|
||||||
|
<tr th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
|
||||||
<th>영향 API</th>
|
<th>영향 API</th>
|
||||||
<td>
|
<td colspan="3">
|
||||||
<th:block th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
|
<div class="affected-apis-wrapper">
|
||||||
<span th:each="api, iterStat : ${portalNotice.affectedApis}">
|
<!-- First 3 APIs (Always visible) -->
|
||||||
<strong th:text="${api.apiId}">API_ID</strong><span th:if="${api.apiName != null and !api.apiName.isEmpty()}" th:text="| - ${api.apiName}|"></span><th:block th:if="${!iterStat.last}">, </th:block>
|
<div class="affected-apis-list">
|
||||||
|
<span class="affected-api-badge" th:each="api, iterStat : ${portalNotice.affectedApis}"
|
||||||
|
th:if="${iterStat.index < 3}">
|
||||||
|
<strong th:text="${api.apiId}">API_ID</strong><span
|
||||||
|
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
||||||
|
th:text="| - ${api.apiName}|"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Rest of APIs (Hidden by default) -->
|
||||||
|
<th:block th:if="${portalNotice.affectedApis.size() > 3}">
|
||||||
|
<span class="affected-api-badge extra-api"
|
||||||
|
th:each="api, iterStat : ${portalNotice.affectedApis}" th:if="${iterStat.index >= 3}"
|
||||||
|
style="display: none;">
|
||||||
|
<strong th:text="${api.apiId}">API_ID</strong><span
|
||||||
|
th:if="${api.apiName != null and !api.apiName.isEmpty()}"
|
||||||
|
th:text="| - ${api.apiName}|"></span>
|
||||||
</span>
|
</span>
|
||||||
</th:block>
|
</th:block>
|
||||||
<th:block th:unless="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">-</th:block>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toggle Button -->
|
||||||
|
<button type="button" class="btn-affected-toggle"
|
||||||
|
th:if="${portalNotice.affectedApis.size() > 3}" onclick="toggleAffectedApis(this)">
|
||||||
|
리스트 전체보기 <span class="arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -83,12 +119,16 @@
|
|||||||
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(portalNotice.fileId)}">
|
<div class="notice-detail-attachment" th:if="${!#strings.isEmpty(portalNotice.fileId)}">
|
||||||
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
|
<div class="attachment-list" th:with="fileInfo=${@fileService.findById(portalNotice.fileId)}">
|
||||||
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
|
<div class="attachment-item" th:each="fileDetail, status : ${fileInfo.getFileDetails()}">
|
||||||
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link">
|
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'"
|
||||||
|
class="notice-attachment-link">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
|
<path
|
||||||
|
d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z"
|
||||||
|
fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="attachment-label">첨부파일</span>
|
<span class="attachment-label">첨부파일</span>
|
||||||
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
<span
|
||||||
|
class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,14 +143,15 @@
|
|||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="notice-detail-actions">
|
<div class="notice-detail-actions">
|
||||||
<button type="button" class="btn-notice-list" th:onclick="|location.href='@{/portalnotice}'|">목록</button>
|
<button type="button" class="btn-action-primary btn-notice-list"
|
||||||
|
th:onclick="|location.href='@{/portalnotice}'|">목록</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
@@ -120,13 +161,31 @@
|
|||||||
window.open('[[@{/file/download}]]' + "?fileId=" + fileId + "&fileSn=" + fileSn);
|
window.open('[[@{/file/download}]]' + "?fileId=" + fileId + "&fileSn=" + fileSn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAffectedApis(btn) {
|
||||||
|
const wrapper = btn.closest('.affected-apis-wrapper');
|
||||||
|
const extraApis = wrapper.querySelectorAll('.extra-api');
|
||||||
|
const isExpanded = btn.classList.contains('active');
|
||||||
|
|
||||||
|
extraApis.forEach(el => {
|
||||||
|
el.style.display = isExpanded ? 'none' : 'inline-flex';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
btn.innerHTML = '리스트 전체보기 <span class="arrow">▼</span>';
|
||||||
|
} else {
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.innerHTML = '리스트 접기 <span class="arrow">▲</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function decodeHTMLEntities(text) {
|
function decodeHTMLEntities(text) {
|
||||||
var textArea = document.createElement('textarea');
|
var textArea = document.createElement('textarea');
|
||||||
textArea.innerHTML = text;
|
textArea.innerHTML = text;
|
||||||
return textArea.value;
|
return textArea.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
var element = document.getElementById('noticeDetail');
|
var element = document.getElementById('noticeDetail');
|
||||||
if (element) {
|
if (element) {
|
||||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
<section class="service-hero">
|
<section class="service-hero">
|
||||||
<div class="service-hero__inner">
|
<div class="service-hero__inner">
|
||||||
<div class="service-hero__icon-wrapper">
|
<div class="service-hero__icon-wrapper">
|
||||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
|
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="service-hero__content">
|
<div class="service-hero__content">
|
||||||
<div class="service-hero__badge">
|
<div class="service-hero__badge">
|
||||||
@@ -32,7 +31,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="service-main">
|
<div class="container service-main">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
||||||
|
|
||||||
@@ -86,9 +85,15 @@
|
|||||||
<span class="notice-number"
|
<span class="notice-number"
|
||||||
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
|
||||||
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
|
<span class="notice-type-badge notice-type-badge--fix" th:if="${notice.fixYn == 'Y'}">고정</span>
|
||||||
<span class="notice-type-badge notice-type-badge--incident" th:if="${notice.noticeType == '3'}">장애</span>
|
<span class="notice-type-badge notice-type-badge--incident"
|
||||||
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${notice.noticeType == '2'}">점검</span>
|
th:if="${notice.noticeType == '3'}">장애</span>
|
||||||
<span th:text="${notice.noticeSubject}">공지사항 제목</span>
|
<span class="notice-type-badge notice-type-badge--maintenance"
|
||||||
|
th:if="${notice.noticeType == '2'}">점검</span>
|
||||||
|
<span class="notice-type-badge notice-type-badge--test"
|
||||||
|
th:if="${notice.noticeType == '4'}">테스트</span>
|
||||||
|
<span class="notice-type-badge notice-type-badge--normal"
|
||||||
|
th:if="${notice.noticeType == '1'}">공지</span>
|
||||||
|
<span th:text="${#strings.replace(#strings.replace(#strings.replace(#strings.replace(notice.noticeSubject, '[장애]', ''), '[점검]', ''), '[테스트]', ''), '[공지]', '')}">공지사항 제목</span>
|
||||||
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
|
<span class="file-icon" th:if="${notice.fileId != null and !notice.fileId.isEmpty()}">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -31,29 +31,27 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="service-main">
|
<div class="container service-main">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('feedback')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('feedback')}"></th:block>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<section class="service-content">
|
<section class="service-content">
|
||||||
<div class="djb-board-write-container">
|
<!-- 상단: 최근 작성한 글 목록 -->
|
||||||
|
<div class="recent-apps">
|
||||||
<!-- Title Bar -->
|
<div class="recent-apps-header-group">
|
||||||
<div class="djb-board-write-header">
|
<p class="recent-apps-title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">
|
||||||
<h2 class="title">피드백 / 개선 요청</h2>
|
피드백 / 개선 요청</p>
|
||||||
<p class="subtitle">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을 환영합니다.</p>
|
<p class="recent-apps-subtitle" style="font-size: 14px; color: #7f8a95; margin-bottom: 24px;">최근 최대 3개의 나의
|
||||||
|
피드백/ 개선 요청을 보여줍니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 내가 작성한 최근 글(최대 3건) 아코디언 -->
|
<ul class="recent-apps-list" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
|
||||||
<div class="recent-apps" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
|
|
||||||
<p class="recent-apps-title">내가 작성한 최근 글</p>
|
|
||||||
<ul class="recent-apps-list">
|
|
||||||
<li class="recent-apps-item" th:each="item : ${recentApplications}">
|
<li class="recent-apps-item" th:each="item : ${recentApplications}">
|
||||||
<button type="button" class="recent-apps-head">
|
<button type="button" class="recent-apps-head">
|
||||||
<span class="recent-apps-subject" th:text="${item.bizSubject}">제목</span>
|
|
||||||
<span class="recent-apps-date"
|
<span class="recent-apps-date"
|
||||||
th:text="${item.createdDate != null ? #temporals.format(item.createdDate, 'yyyy.MM.dd') : ''}">2026.07.13</span>
|
th:text="${item.createdDate != null ? #temporals.format(item.createdDate, 'yyyy.MM.dd') : ''}">2026.07.13</span>
|
||||||
|
<span class="recent-apps-subject" th:text="${item.bizSubject}">제목</span>
|
||||||
<svg class="recent-apps-arrow" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
<svg class="recent-apps-arrow" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
@@ -64,6 +62,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<!-- Empty State for Recent Applications -->
|
||||||
|
<div class="recent-apps-empty" th:unless="${recentApplications != null and !recentApplications.isEmpty()}"
|
||||||
|
style="padding: 35px; text-align: center; background: #f8fafc; border: 1px dashed #e2e8f0; border-radius: 8px; font-size: 14px; color: #94a3b8;">
|
||||||
|
작성하신 피드백 / 개선 요청 내역이 없습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 하단: 신규 작성 폼 카드 -->
|
||||||
|
<div class="djb-board-write-container">
|
||||||
|
<!-- Title Bar -->
|
||||||
|
<div class="djb-board-write-header" style="margin-bottom: 30px;">
|
||||||
|
<h2 class="title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">피드백 / 개선 요청
|
||||||
|
</h2>
|
||||||
|
<p class="subtitle" style="font-size: 14px; color: #7f8a95; margin: 0;">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
|
||||||
|
환영합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form Content -->
|
<!-- Form Content -->
|
||||||
@@ -87,6 +101,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 파일 첨부 Field -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="djb-label" for="file">첨부파일</label>
|
||||||
|
<div class="file-upload-wrapper" id="fileInputDisplay">
|
||||||
|
<span class="file-name-display" id="fileDisplayText">선택된 파일이 없습니다</span>
|
||||||
|
<button type="button" class="file-remove-btn" id="btnRemoveFile" style="display: none;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<label for="file" class="file-upload-btn">
|
||||||
|
파일 선택
|
||||||
|
</label>
|
||||||
|
<input type="file" id="file" name="file" style="display: none;" />
|
||||||
|
</div>
|
||||||
|
<small class="form-help-text" style="margin-top: 8px; display: block; color: #8c959f;">
|
||||||
|
문서파일(pdf, doc, docx, xls, xlsx, ppt, pptx, hwp)과 이미지 파일(gif, jpg, png)만 등록 가능합니다.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Form Actions -->
|
<!-- Form Actions -->
|
||||||
@@ -208,6 +243,19 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recent applications accordion toggle handler
|
||||||
|
$('.recent-apps-head').on('click', function () {
|
||||||
|
const item = $(this).closest('.recent-apps-item');
|
||||||
|
const body = item.find('.recent-apps-body');
|
||||||
|
|
||||||
|
// Toggle active class and slide
|
||||||
|
item.toggleClass('active');
|
||||||
|
body.slideToggle(200);
|
||||||
|
|
||||||
|
// Optional: Close other open accordion items
|
||||||
|
item.siblings('.recent-apps-item').removeClass('active').find('.recent-apps-body').slideUp(200);
|
||||||
|
});
|
||||||
|
|
||||||
// Focus on subject field
|
// Focus on subject field
|
||||||
document.getElementById('bizSubject').focus();
|
document.getElementById('bizSubject').focus();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,6 +115,24 @@
|
|||||||
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
|
form.checkId.checked = ((form.id.value = getCookie('saveid')) !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CSRF 토큰 재발급 안전망: keepalive ping이 끊긴 경우(절전·네트워크 단절 등)
|
||||||
|
// 세션이 만료됐어도 제출 직전 새 토큰을 받아 403(만료) 대신 정상 로그인되게 한다.
|
||||||
|
// 재발급 실패 시에는 기존 토큰으로 그대로 제출한다.
|
||||||
|
function refreshCsrfAndThen(form, next) {
|
||||||
|
$.ajax({
|
||||||
|
url: /*[[@{/api/session/csrf}]]*/ '/api/session/csrf',
|
||||||
|
type: 'GET',
|
||||||
|
success: function (data) {
|
||||||
|
if (data && data.token) {
|
||||||
|
if (form['_csrf']) { form['_csrf'].value = data.token; }
|
||||||
|
var meta = document.querySelector('meta[name="_csrf"]');
|
||||||
|
if (meta) { meta.content = data.token; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: function () { next(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
|
// 로그인 전 중복 접속 확인 → 중복 시 기존 세션 강제 로그아웃 여부 질의
|
||||||
function checkDuplicateAndLogin(form) {
|
function checkDuplicateAndLogin(form) {
|
||||||
var loginId = form.id.value;
|
var loginId = form.id.value;
|
||||||
@@ -157,26 +175,8 @@
|
|||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
|
|
||||||
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
|
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
|
||||||
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
|
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
|
||||||
// 단, 입력 중에는 이탈 금지 → idle 타이머(활동 시 리셋, 입력값/포커스 있으면 보류).
|
|
||||||
// 만료 시간은 하드코딩하지 않고 서버 세션 타임아웃(초)을 렌더 시점에 읽어 파생 → yml 변경 시 자동 반영.
|
|
||||||
var SESSION_TIMEOUT_SEC = /*[[${#request.session.maxInactiveInterval}]]*/ 600;
|
|
||||||
var LOGIN_IDLE_LIMIT_MS = Math.max(60, SESSION_TIMEOUT_SEC - 120) * 1000; // 세션 만료 2분 전
|
|
||||||
var loginIdleTimer;
|
|
||||||
function scheduleIdleRedirect() {
|
|
||||||
clearTimeout(loginIdleTimer);
|
|
||||||
loginIdleTimer = setTimeout(function () {
|
|
||||||
var idEl = document.getElementById('id');
|
|
||||||
var pwEl = document.getElementById('password');
|
|
||||||
var busy = document.activeElement === idEl || document.activeElement === pwEl
|
|
||||||
|| (idEl && idEl.value) || (pwEl && pwEl.value);
|
|
||||||
if (busy) { scheduleIdleRedirect(); return; } // 입력 중/입력값 있음 → 이탈 보류
|
|
||||||
window.location.href = /*[[@{/}]]*/ '/';
|
|
||||||
}, LOGIN_IDLE_LIMIT_MS);
|
|
||||||
}
|
|
||||||
$('#id, #password, #checkId').on('input keydown focus click', scheduleIdleRedirect);
|
|
||||||
scheduleIdleRedirect();
|
|
||||||
|
|
||||||
var successMsg = [[${success}]];
|
var successMsg = [[${success}]];
|
||||||
console.log("Success message:", successMsg);
|
console.log("Success message:", successMsg);
|
||||||
@@ -225,7 +225,9 @@
|
|||||||
$('#loginLoading').removeClass('active');
|
$('#loginLoading').removeClass('active');
|
||||||
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
||||||
} else {
|
} else {
|
||||||
|
refreshCsrfAndThen(form, function () {
|
||||||
checkDuplicateAndLogin(form);
|
checkDuplicateAndLogin(form);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
form.classList.add('was-validated');
|
form.classList.add('was-validated');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -11,208 +11,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<style>
|
|
||||||
/* App Card Figma Styles */
|
|
||||||
.app-management-layout {
|
|
||||||
display: flex;
|
|
||||||
gap: 36px;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 40px auto 100px;
|
|
||||||
font-family: 'Spoqa Han Sans Neo', 'Noto Sans CJK KR', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-management-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-management-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-management-header h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #000;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-app-create-figma {
|
|
||||||
background-color: #2a69de;
|
|
||||||
color: #ffffff;
|
|
||||||
border-radius: 7px;
|
|
||||||
padding: 0 20px;
|
|
||||||
width: 122px;
|
|
||||||
height: 35px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-app-create-figma:hover {
|
|
||||||
background-color: #1b4ab7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-list-container-figma {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-figma {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
height: 107px;
|
|
||||||
padding: 20px 31px 20px 17px;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #dfdfdf;
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0px 4px 5.45px rgba(192, 192, 192, 0.25);
|
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-figma:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0px 6px 15px rgba(192, 192, 192, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-icon-box {
|
|
||||||
width: 68px;
|
|
||||||
height: 67px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
border: 1px solid #dee3e7;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-icon-box img,
|
|
||||||
.app-card-icon-box svg {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #000;
|
|
||||||
margin: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-badge {
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-badge.status-pending {
|
|
||||||
background: #fdf4d4;
|
|
||||||
color: #ef9546;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-badge.status-approved {
|
|
||||||
background: #1b4ab7;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-badge.status-inactive {
|
|
||||||
background: #f3f4f6;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-desc {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #64748b;
|
|
||||||
margin: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-footer-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-card-expected-date {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #64748b;
|
|
||||||
margin-left: 16px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-list-empty-figma {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 60px 0;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #dfdfdf;
|
|
||||||
border-radius: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-list-empty-figma h3 {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #000;
|
|
||||||
margin: 16px 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-list-empty-figma p {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #64748b;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="signup-guide-v2">
|
<div class="signup-guide-v2">
|
||||||
<div class="service-main app-management-layout">
|
<div class="service-main container" style="padding-top: 70px;">
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
||||||
@@ -223,7 +23,7 @@
|
|||||||
<div class="app-management-header">
|
<div class="app-management-header">
|
||||||
<h2>인증 키 관리</h2>
|
<h2>인증 키 관리</h2>
|
||||||
<div sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
|
<div sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
|
||||||
<button type="button" class="btn-app-create-figma" id="requestApiKey">
|
<button type="button" class="btn-action-primary md" id="requestApiKey">
|
||||||
앱 생성
|
앱 생성
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,7 +55,8 @@
|
|||||||
<!-- App Name -->
|
<!-- App Name -->
|
||||||
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
|
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
<span class="app-card-badge status-pending"
|
<span class="app-card-badge"
|
||||||
|
th:classappend="${request.approval != null and request.approval.approvalStatus != null and request.approval.approvalStatus.toString() == 'REQUESTED' ? 'badge-requested' : 'badge-pending'}"
|
||||||
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인대기'}">
|
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인대기'}">
|
||||||
승인대기
|
승인대기
|
||||||
</span>
|
</span>
|
||||||
@@ -266,11 +67,16 @@
|
|||||||
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
|
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
|
||||||
앱 설명이 여기에 표시됩니다.
|
앱 설명이 여기에 표시됩니다.
|
||||||
</p>
|
</p>
|
||||||
|
<!-- Expected date or placeholder to keep structure aligned -->
|
||||||
<span class="app-card-expected-date"
|
<span class="app-card-expected-date"
|
||||||
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
|
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
|
||||||
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
|
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
|
||||||
예상 완료일 : 05월 30일 (토)
|
예상 완료일 : 05월 30일 (토)
|
||||||
</span>
|
</span>
|
||||||
|
<span class="app-card-expected-date"
|
||||||
|
th:unless="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}">
|
||||||
|
예상 완료일 : -
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -300,7 +106,7 @@
|
|||||||
<h3 class="app-card-title" th:text="${apikey.clientname}">앱 이름</h3>
|
<h3 class="app-card-title" th:text="${apikey.clientname}">앱 이름</h3>
|
||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
<span class="app-card-badge"
|
<span class="app-card-badge"
|
||||||
th:classappend="${apikey.appstatus == '1' ? 'status-approved' : apikey.appstatus == '0' ? 'status-inactive' : 'status-pending'}"
|
th:classappend="${apikey.appstatus == '1' ? 'badge-approved' : apikey.appstatus == '0' ? 'badge-inactive' : 'badge-pending'}"
|
||||||
th:text="${apikey.appstatus == '1' ? '승인' : apikey.appstatus == '0' ? '비활성화' : '승인대기'}">
|
th:text="${apikey.appstatus == '1' ? '승인' : apikey.appstatus == '0' ? '비활성화' : '승인대기'}">
|
||||||
승인
|
승인
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
<div class="app-management-content">
|
<div class="app-management-content">
|
||||||
|
<!-- 모든 스타일은 step2-wrap 안에서만 적용 -->
|
||||||
<div class="step2-wrap">
|
<div class="step2-wrap">
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
<!-- Progress Steps Card -->
|
<!-- Progress Steps Card -->
|
||||||
<div class="s1-progress-card">
|
<div class="s1-progress-card">
|
||||||
<div class="s1-steps">
|
<div class="s1-steps">
|
||||||
<!-- Step 1: 앱 정보입력 (completed) -->
|
<!-- Step 1: 앱 정보수정 (completed) -->
|
||||||
<div class="s1-step">
|
<div class="s1-step">
|
||||||
<div class="s1-step-circle">
|
<div class="s1-step-circle">
|
||||||
<!-- 입력폼 아이콘 -->
|
<!-- 입력폼 아이콘 -->
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
|
|
||||||
<div class="s1-step-line"></div>
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
<!-- Step 3: 앱 생성 완료 (rocket icon) -->
|
<!-- Step 3: 앱 수정 완료 (rocket icon) -->
|
||||||
<div class="s1-step">
|
<div class="s1-step">
|
||||||
<div class="s1-step-circle">
|
<div class="s1-step-circle">
|
||||||
<!-- 로켓 아이콘 -->
|
<!-- 로켓 아이콘 -->
|
||||||
@@ -90,85 +91,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Carousel Tab Container -->
|
<!-- API 선택 공용 모듈 -->
|
||||||
<div class="s2-category-carousel-container">
|
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyModification.selectedApis}, '/myapikey/modify/step2', '/myapikey/modify/step2/save')}"/>
|
||||||
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="15 18 9 12 15 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="s2-category-list-wrapper" id="categoryListWrapper">
|
|
||||||
<div class="s2-category-list" id="categoryList">
|
|
||||||
<button type="button" class="s2-category-tab active" data-group="">
|
|
||||||
전체
|
|
||||||
</button>
|
|
||||||
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
|
|
||||||
서비스명
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- clientId 는 모듈 폼에 form 속성으로 주입 -->
|
||||||
<div class="s2-selection-container">
|
<input type="hidden" name="clientId" th:value="${apiKeyModification.clientId}" form="apiSelectorForm"/>
|
||||||
<main class="s2-content-area">
|
|
||||||
<form id="modifyStep2Form" method="post" th:action="@{/myapikey/modify/step2}" th:object="${apiKeyModification}" class="s2-form">
|
|
||||||
|
|
||||||
<!-- Hidden field for clientId -->
|
|
||||||
<input type="hidden" th:field="*{clientId}"/>
|
|
||||||
|
|
||||||
<!-- Header filter: Search and Select All -->
|
|
||||||
<div class="s2-filter-header">
|
|
||||||
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
|
|
||||||
<label class="s2-select-all-label">
|
|
||||||
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
|
|
||||||
<span class="s2-checkbox-custom"></span>
|
|
||||||
<span id="selectAllText">전체 선택</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="s2-result-count">
|
|
||||||
총 <strong id="apiResultCount">0</strong>건
|
|
||||||
</div>
|
|
||||||
<div class="s2-search-box">
|
|
||||||
<input type="text" id="apiSearch" class="s2-search-input" placeholder="API 검색...">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Cards Grid -->
|
|
||||||
<div class="s2-cards-grid" id="apiCardGrid">
|
|
||||||
<!-- Loading state -->
|
|
||||||
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
|
|
||||||
<div class="s2-spinner"></div>
|
|
||||||
<p>API 목록을 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
|
|
||||||
<div class="s2-empty-icon">📦</div>
|
|
||||||
<h3>API를 선택해주세요</h3>
|
|
||||||
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom Navigation Actions -->
|
<!-- Bottom Navigation Actions -->
|
||||||
<div class="s2-actions">
|
<div class="s2-actions">
|
||||||
<button type="button" id="btnPrevStep" class="s2-btn-prev">
|
<button type="button" id="btnPrevStep" class="s2-btn-prev">
|
||||||
이전
|
이전
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" form="modifyStep2Form" class="s2-btn-save">
|
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
|
||||||
다음
|
저장
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,549 +111,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Cart Button -->
|
|
||||||
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
|
|
||||||
<span class="s2-cart-label">선택된 API</span>
|
|
||||||
<span class="s2-cart-badge" id="cartBadge">
|
|
||||||
<span class="s2-cart-count">0</span>
|
|
||||||
<span class="s2-cart-unit">개</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Selected APIs Modal -->
|
|
||||||
<div class="s2-modal" id="selectedApisModal" style="display: none;">
|
|
||||||
<div class="s2-modal-backdrop" id="modalOverlay"></div>
|
|
||||||
<div class="s2-modal-dialog">
|
|
||||||
<div class="s2-modal-header">
|
|
||||||
<h3 class="s2-modal-title">선택된 API 목록</h3>
|
|
||||||
<button type="button" class="s2-modal-close" id="modalCloseBtn">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="s2-modal-body">
|
|
||||||
<div class="s2-selected-list" id="modalSelectedList">
|
|
||||||
<!-- Dynamically populated -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="s2-modal-footer">
|
|
||||||
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<th:block layout:fragment="contentScript">
|
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
|
||||||
<script th:inline="javascript">
|
<section layout:fragment="pagePopups">
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
|
||||||
// DOM Elements
|
</section>
|
||||||
const form = document.getElementById('modifyStep2Form');
|
|
||||||
const searchInput = document.getElementById('apiSearch');
|
|
||||||
const menuTitles = document.querySelectorAll('.s2-category-tab');
|
|
||||||
const apiCardGrid = document.getElementById('apiCardGrid');
|
|
||||||
const loadingState = document.getElementById('loadingState');
|
|
||||||
const emptyState = document.getElementById('emptyState');
|
|
||||||
|
|
||||||
let currentFilter = ''; // Empty string means "all"
|
|
||||||
let currentServiceName = '전체'; // Store current service name
|
|
||||||
let allApis = []; // Store loaded APIs
|
|
||||||
let allLoadedApis = []; // Store ALL APIs from server for modal display
|
|
||||||
let selectedApis = new Set(); // Track selected API IDs
|
|
||||||
|
|
||||||
// Restore selected APIs from session
|
|
||||||
const sessionSelectedApis = /*[[${apiKeyModification.selectedApis}]]*/ [];
|
|
||||||
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
|
|
||||||
sessionSelectedApis.forEach(function(apiId) {
|
|
||||||
selectedApis.add(apiId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all APIs for modal display (used when APIs from different categories are selected)
|
|
||||||
function loadAllApisForModal() {
|
|
||||||
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
|
|
||||||
fetch(baseUrl)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(apis => {
|
|
||||||
allLoadedApis = apis;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Failed to load all APIs for modal:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load APIs via AJAX
|
|
||||||
function loadApis(groupId) {
|
|
||||||
// Show loading state
|
|
||||||
loadingState.style.display = 'block';
|
|
||||||
emptyState.style.display = 'none';
|
|
||||||
|
|
||||||
// Remove existing API cards
|
|
||||||
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
|
|
||||||
|
|
||||||
// Build URL with optional groupId filter (Thymeleaf contextPath safe)
|
|
||||||
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
|
|
||||||
let url = baseUrl;
|
|
||||||
if (groupId) {
|
|
||||||
url += '?groupIds=' + encodeURIComponent(groupId);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(url).then(response => response.json()).then(apis => {
|
|
||||||
allApis = apis;
|
|
||||||
loadingState.style.display = 'none';
|
|
||||||
|
|
||||||
if (apis.length === 0) {
|
|
||||||
emptyState.style.display = 'block';
|
|
||||||
document.getElementById('apiResultCount').textContent = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('apiResultCount').textContent = apis.length;
|
|
||||||
renderApiCards(apis);
|
|
||||||
updateSelectAllUI();
|
|
||||||
}).catch(error => {
|
|
||||||
console.error('Failed to load APIs:', error);
|
|
||||||
loadingState.style.display = 'none';
|
|
||||||
emptyState.querySelector('h3').textContent = 'API 로드 실패';
|
|
||||||
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
|
||||||
emptyState.style.display = 'block';
|
|
||||||
document.getElementById('apiResultCount').textContent = '0';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render API cards
|
|
||||||
function renderApiCards(apis) {
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
|
|
||||||
apis.forEach(api => {
|
|
||||||
const card = createApiCard(api);
|
|
||||||
fragment.appendChild(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
apiCardGrid.appendChild(fragment);
|
|
||||||
|
|
||||||
// Re-attach event listeners
|
|
||||||
attachCardEventListeners();
|
|
||||||
|
|
||||||
// Update modal list after cards are rendered
|
|
||||||
updateModalList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create API card element
|
|
||||||
function createApiCard(api) {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 's2-api-card';
|
|
||||||
card.setAttribute('data-group', api.apiGroupId || '');
|
|
||||||
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
|
|
||||||
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
|
|
||||||
card.setAttribute('data-api-id', api.apiId);
|
|
||||||
|
|
||||||
const isSelected = selectedApis.has(api.apiId);
|
|
||||||
if (isSelected) {
|
|
||||||
card.classList.add('selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainIconHtml = api.mainIcon
|
|
||||||
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
|
|
||||||
: `<i class="fas fa-cube"></i>`;
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="s2-api-card-badge">
|
|
||||||
<span>${api.apiGroupName || '카테고리'}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Checkbox container with visible custom design -->
|
|
||||||
<label class="s2-checkbox-wrapper">
|
|
||||||
<input type="checkbox"
|
|
||||||
name="selectedApis"
|
|
||||||
value="${api.apiId}"
|
|
||||||
id="api-${api.apiId}"
|
|
||||||
class="s2-api-checkbox visually-hidden"
|
|
||||||
${isSelected ? 'checked' : ''}>
|
|
||||||
<span class="s2-checkbox-custom"></span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
|
|
||||||
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
|
|
||||||
|
|
||||||
<div class="s2-api-card-image">
|
|
||||||
${mainIconHtml}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach event listeners to cards
|
|
||||||
function attachCardEventListeners() {
|
|
||||||
const apiCards = document.querySelectorAll('.s2-api-card');
|
|
||||||
|
|
||||||
apiCards.forEach(card => {
|
|
||||||
card.addEventListener('click', function(e) {
|
|
||||||
const checkbox = card.querySelector('.s2-api-checkbox');
|
|
||||||
if (checkbox) {
|
|
||||||
checkbox.checked = !checkbox.checked;
|
|
||||||
updateCardSelection(checkbox);
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent double toggle when clicking the checkbox wrapper
|
|
||||||
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
|
|
||||||
checkboxWrappers.forEach(wrapper => {
|
|
||||||
wrapper.addEventListener('click', function(e) {
|
|
||||||
e.stopPropagation(); // Stop click from bubbling to card!
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen to checkbox change event inside it
|
|
||||||
const checkbox = wrapper.querySelector('.s2-api-checkbox');
|
|
||||||
if (checkbox) {
|
|
||||||
checkbox.addEventListener('change', function() {
|
|
||||||
updateCardSelection(this);
|
|
||||||
updateSelectedCount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update card visual state
|
|
||||||
function updateCardSelection(checkbox) {
|
|
||||||
const card = checkbox.closest('.s2-api-card');
|
|
||||||
if (checkbox.checked) {
|
|
||||||
card.classList.add('selected');
|
|
||||||
selectedApis.add(checkbox.value);
|
|
||||||
} else {
|
|
||||||
card.classList.remove('selected');
|
|
||||||
selectedApis.delete(checkbox.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update selected count
|
|
||||||
function updateSelectedCount() {
|
|
||||||
// Update floating cart button
|
|
||||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
|
||||||
const cartCount = document.querySelector('.s2-cart-count');
|
|
||||||
|
|
||||||
if (selectedApis.size > 0) {
|
|
||||||
floatingCartBtn.style.display = 'flex';
|
|
||||||
cartCount.textContent = selectedApis.size;
|
|
||||||
} else {
|
|
||||||
floatingCartBtn.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update modal list
|
|
||||||
updateModalList();
|
|
||||||
|
|
||||||
// Update select all checkbox state
|
|
||||||
updateSelectAllCheckboxState();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update select all UI visibility and text
|
|
||||||
function updateSelectAllUI() {
|
|
||||||
const selectAllWrapper = document.getElementById('selectAllWrapper');
|
|
||||||
const selectAllText = document.getElementById('selectAllText');
|
|
||||||
|
|
||||||
if (currentFilter === '') {
|
|
||||||
// Hide select all when "전체" is selected
|
|
||||||
selectAllWrapper.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
// Show select all with service name
|
|
||||||
selectAllWrapper.style.display = 'flex';
|
|
||||||
selectAllText.textContent = currentServiceName + ' API 전체 선택';
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSelectAllCheckboxState();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update select all checkbox state based on visible cards
|
|
||||||
function updateSelectAllCheckboxState() {
|
|
||||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
|
||||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
|
||||||
|
|
||||||
if (visibleCards.length === 0) {
|
|
||||||
selectAllCheckbox.checked = false;
|
|
||||||
selectAllCheckbox.indeterminate = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
|
|
||||||
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
|
|
||||||
|
|
||||||
if (checkedCount === 0) {
|
|
||||||
selectAllCheckbox.checked = false;
|
|
||||||
selectAllCheckbox.indeterminate = false;
|
|
||||||
} else if (checkedCount === visibleCheckboxes.length) {
|
|
||||||
selectAllCheckbox.checked = true;
|
|
||||||
selectAllCheckbox.indeterminate = false;
|
|
||||||
} else {
|
|
||||||
selectAllCheckbox.checked = false;
|
|
||||||
selectAllCheckbox.indeterminate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update modal selected APIs list
|
|
||||||
function updateModalList() {
|
|
||||||
const modalSelectedList = document.getElementById('modalSelectedList');
|
|
||||||
modalSelectedList.innerHTML = '';
|
|
||||||
|
|
||||||
if (selectedApis.size === 0) {
|
|
||||||
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build list using selectedApis Set for consistency
|
|
||||||
selectedApis.forEach(function(apiId) {
|
|
||||||
// Try to find the checkbox in the DOM first
|
|
||||||
const checkbox = document.querySelector('.s2-api-checkbox[value="' + apiId + '"]');
|
|
||||||
let apiName = apiId; // Default to ID if we can't find the name
|
|
||||||
|
|
||||||
if (checkbox) {
|
|
||||||
// If checkbox exists in DOM, get the name from the card
|
|
||||||
const card = checkbox.closest('.s2-api-card');
|
|
||||||
if (card) {
|
|
||||||
const nameElement = card.querySelector('.s2-api-card-title');
|
|
||||||
if (nameElement) {
|
|
||||||
apiName = nameElement.textContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If checkbox not in DOM (different category is showing),
|
|
||||||
// try to find the API in our loaded data
|
|
||||||
let api = allApis.find(a => a.apiId === apiId);
|
|
||||||
if (!api && allLoadedApis && allLoadedApis.length > 0) {
|
|
||||||
api = allLoadedApis.find(a => a.apiId === apiId);
|
|
||||||
}
|
|
||||||
if (api && api.apiName) {
|
|
||||||
apiName = api.apiName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiPill = document.createElement('div');
|
|
||||||
apiPill.className = 's2-api-pill';
|
|
||||||
apiPill.innerHTML = `
|
|
||||||
<span class="s2-api-pill-name">${apiName}</span>
|
|
||||||
<button type="button" class="s2-api-pill-remove" data-value="${apiId}" aria-label="Remove ${apiName}">✕</button>
|
|
||||||
`;
|
|
||||||
modalSelectedList.appendChild(apiPill);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add remove handlers
|
|
||||||
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const value = this.getAttribute('data-value');
|
|
||||||
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
|
|
||||||
if (checkbox) {
|
|
||||||
checkbox.checked = false;
|
|
||||||
updateCardSelection(checkbox);
|
|
||||||
updateSelectedCount();
|
|
||||||
} else {
|
|
||||||
// If checkbox not found (maybe different category is showing),
|
|
||||||
// still remove from selection
|
|
||||||
selectedApis.delete(value);
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search functionality
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.addEventListener('input', function() {
|
|
||||||
const searchTerm = this.value.toLowerCase();
|
|
||||||
|
|
||||||
const apiCards = document.querySelectorAll('.s2-api-card');
|
|
||||||
let visibleCount = 0;
|
|
||||||
apiCards.forEach(function(card) {
|
|
||||||
const apiName = card.getAttribute('data-name');
|
|
||||||
const apiDesc = card.getAttribute('data-desc');
|
|
||||||
|
|
||||||
// Check if matches search
|
|
||||||
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
|
|
||||||
|
|
||||||
if (matchesSearch) {
|
|
||||||
card.style.display = '';
|
|
||||||
visibleCount++;
|
|
||||||
} else {
|
|
||||||
card.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('apiResultCount').textContent = visibleCount;
|
|
||||||
|
|
||||||
// Update select all checkbox state after search
|
|
||||||
updateSelectAllCheckboxState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category tab selection
|
|
||||||
menuTitles.forEach(function(title) {
|
|
||||||
title.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Remove active from all
|
|
||||||
menuTitles.forEach(t => t.classList.remove('active'));
|
|
||||||
// Add active to clicked
|
|
||||||
this.classList.add('active');
|
|
||||||
|
|
||||||
const groupId = this.getAttribute('data-group');
|
|
||||||
currentFilter = groupId;
|
|
||||||
|
|
||||||
// Store service name for select all label
|
|
||||||
currentServiceName = this.textContent.trim();
|
|
||||||
|
|
||||||
// Load APIs for selected service
|
|
||||||
loadApis(groupId);
|
|
||||||
|
|
||||||
// Clear search when changing category
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.value = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Category Carousel Scroll
|
|
||||||
const categoryListWrapper = document.getElementById('categoryListWrapper');
|
|
||||||
const btnPrevCategory = document.getElementById('btnPrevCategory');
|
|
||||||
const btnNextCategory = document.getElementById('btnNextCategory');
|
|
||||||
|
|
||||||
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
|
|
||||||
const scrollAmount = 200;
|
|
||||||
|
|
||||||
btnPrevCategory.addEventListener('click', function() {
|
|
||||||
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
btnNextCategory.addEventListener('click', function() {
|
|
||||||
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle buttons visibility/disabled state based on scroll position
|
|
||||||
function updateCarouselButtons() {
|
|
||||||
const scrollLeft = categoryListWrapper.scrollLeft;
|
|
||||||
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
|
|
||||||
|
|
||||||
btnPrevCategory.disabled = scrollLeft <= 0;
|
|
||||||
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
|
|
||||||
window.addEventListener('resize', updateCarouselButtons);
|
|
||||||
|
|
||||||
// Initial check after loading categories
|
|
||||||
setTimeout(updateCarouselButtons, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal control
|
|
||||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
|
||||||
const selectedApisModal = document.getElementById('selectedApisModal');
|
|
||||||
const modalOverlay = document.getElementById('modalOverlay');
|
|
||||||
const modalCloseBtn = document.getElementById('modalCloseBtn');
|
|
||||||
const modalCancelBtn = document.getElementById('modalCancelBtn');
|
|
||||||
|
|
||||||
// Open modal
|
|
||||||
function openModal() {
|
|
||||||
selectedApisModal.style.display = 'block';
|
|
||||||
// Add show class to backdrop and modal for visibility
|
|
||||||
setTimeout(function() {
|
|
||||||
if (modalOverlay) {
|
|
||||||
modalOverlay.classList.add('show');
|
|
||||||
}
|
|
||||||
selectedApisModal.classList.add('show');
|
|
||||||
}, 10);
|
|
||||||
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
function closeModal() {
|
|
||||||
// Remove show class first for transition
|
|
||||||
if (modalOverlay) {
|
|
||||||
modalOverlay.classList.remove('show');
|
|
||||||
}
|
|
||||||
selectedApisModal.classList.remove('show');
|
|
||||||
|
|
||||||
// Hide modal after transition
|
|
||||||
setTimeout(function() {
|
|
||||||
selectedApisModal.style.display = 'none';
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
document.body.style.overflow = ''; // Restore scroll
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floating cart button click
|
|
||||||
if (floatingCartBtn) {
|
|
||||||
floatingCartBtn.addEventListener('click', openModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal overlay click
|
|
||||||
if (modalOverlay) {
|
|
||||||
modalOverlay.addEventListener('click', closeModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal close button click
|
|
||||||
if (modalCloseBtn) {
|
|
||||||
modalCloseBtn.addEventListener('click', closeModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal cancel button click
|
|
||||||
if (modalCancelBtn) {
|
|
||||||
modalCancelBtn.addEventListener('click', closeModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal on ESC key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Select All checkbox event
|
|
||||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
|
||||||
if (selectAllCheckbox) {
|
|
||||||
selectAllCheckbox.addEventListener('change', function() {
|
|
||||||
const isChecked = this.checked;
|
|
||||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
|
||||||
|
|
||||||
visibleCards.forEach(function(card) {
|
|
||||||
const checkbox = card.querySelector('.s2-api-checkbox');
|
|
||||||
if (checkbox) {
|
|
||||||
checkbox.checked = isChecked;
|
|
||||||
updateCardSelection(checkbox);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
updateSelectedCount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Previous step button - save current selections before going back
|
|
||||||
const btnPrevStep = document.getElementById('btnPrevStep');
|
|
||||||
if (btnPrevStep) {
|
|
||||||
btnPrevStep.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Change form action to save endpoint (Thymeleaf contextPath safe)
|
|
||||||
form.action = /*[[@{/myapikey/modify/step2/save}]]*/ '/myapikey/modify/step2/save';
|
|
||||||
|
|
||||||
// Submit the form to save selections
|
|
||||||
form.submit();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form validation
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
if (selectedApis.size === 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
loadAllApisForModal();
|
|
||||||
updateSelectedCount(); // This will show count from restored session data
|
|
||||||
|
|
||||||
// Load all APIs automatically on page load (empty string = all APIs)
|
|
||||||
loadApis('');
|
|
||||||
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</th:block>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="signup-guide-v2 figma-register-wrapper">
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
<div class="service-main app-management-layout">
|
<div class="service-main container" style="padding-top: 70px;">
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
||||||
@@ -29,10 +29,19 @@
|
|||||||
<!-- Progress Steps Card -->
|
<!-- Progress Steps Card -->
|
||||||
<div class="s1-progress-card">
|
<div class="s1-progress-card">
|
||||||
<div class="s1-steps">
|
<div class="s1-steps">
|
||||||
<!-- Step 1 Active: 앱 정보 입력 -->
|
<!-- Step 1 Active: 앱 정보 입력 (document icon) -->
|
||||||
<div class="s1-step s1-step--active">
|
<div class="s1-step s1-step--active">
|
||||||
<div class="s1-step-circle">
|
<div class="s1-step-circle">
|
||||||
<img th:src="@{/img/apikey_step1.png}" alt="앱 정보 입력" width="36" height="36">
|
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.8" />
|
||||||
|
<line x1="7" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="1.8"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
<line x1="7" y1="12" x2="14" y2="12" stroke="currentColor" stroke-width="1.8"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
<line x1="7" y1="15" x2="11" y2="15" stroke="currentColor" stroke-width="1.8"
|
||||||
|
stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="s1-step-num">1단계</span>
|
<span class="s1-step-num">1단계</span>
|
||||||
<span class="s1-step-name">앱 정보 입력</span>
|
<span class="s1-step-name">앱 정보 입력</span>
|
||||||
@@ -40,10 +49,16 @@
|
|||||||
|
|
||||||
<div class="s1-step-line"></div>
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
<!-- Step 2: API 선택 -->
|
<!-- Step 2: API 선택 (chain link icon) -->
|
||||||
<div class="s1-step">
|
<div class="s1-step">
|
||||||
<div class="s1-step-circle">
|
<div class="s1-step-circle">
|
||||||
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor"
|
||||||
|
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor"
|
||||||
|
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="s1-step-num">2단계</span>
|
<span class="s1-step-num">2단계</span>
|
||||||
<span class="s1-step-name">API 선택</span>
|
<span class="s1-step-name">API 선택</span>
|
||||||
@@ -51,10 +66,22 @@
|
|||||||
|
|
||||||
<div class="s1-step-line"></div>
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
<!-- Step 3: 앱 생성 완료 -->
|
<!-- Step 3: 앱 생성 완료 (rocket icon) -->
|
||||||
<div class="s1-step">
|
<div class="s1-step">
|
||||||
<div class="s1-step-circle">
|
<div class="s1-step-circle">
|
||||||
<img th:src="@{/img/apikey_step3.png}" alt="앱 생성 완료" width="36" height="36">
|
<svg class="s1-step-svg" width="26" height="26" viewBox="0 0 24 24" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
|
||||||
|
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path
|
||||||
|
d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
|
||||||
|
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" stroke="currentColor" stroke-width="1.6"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" stroke="currentColor" stroke-width="1.6"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="s1-step-num">3단계</span>
|
<span class="s1-step-num">3단계</span>
|
||||||
<span class="s1-step-name">앱 생성 완료</span>
|
<span class="s1-step-name">앱 생성 완료</span>
|
||||||
@@ -111,13 +138,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
|
<!--/* Call Back URL : OAuth2 3-legged 전용. 현재 미사용으로 주석 처리 (추후 OAuth2 도입 시 복원)
|
||||||
<div class="s1-field">
|
<div class="s1-field">
|
||||||
<label class="s1-label">Call Back URL</label>
|
<label class="s1-label">Call Back URL</label>
|
||||||
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
|
<input type="text" id="callbackUrl" name="callbackUrl" th:field="*{callbackUrl}" class="s1-input"
|
||||||
placeholder="URL을 입력해 주세요.">
|
placeholder="URL을 입력해 주세요.">
|
||||||
</div>
|
</div>
|
||||||
-->
|
*/-->
|
||||||
|
|
||||||
<!-- 화이트 리스트 -->
|
<!-- 화이트 리스트 -->
|
||||||
<div class="s1-field">
|
<div class="s1-field">
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<section layout:fragment="title">
|
<section layout:fragment="title">
|
||||||
<div class="page-title-banner">
|
<div class="page-title-banner">
|
||||||
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
<h1>앱관리</h1>
|
<h1>앱관리</h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="signup-guide-v2 figma-register-wrapper">
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
<div class="service-main app-management-layout">
|
<div class="service-main container">
|
||||||
|
|
||||||
<!-- Left Sidebar (Service Sidebar) -->
|
<!-- Left Sidebar (Service Sidebar) -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('apiKey')}"></th:block>
|
||||||
@@ -91,81 +91,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Category Carousel Tab Container -->
|
<!-- API 선택 공용 모듈 -->
|
||||||
<div class="s2-category-carousel-container">
|
<th:block
|
||||||
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
|
th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${apiKeyRegistration.selectedApis}, '/myapikey/register/step2', '/myapikey/register/step2/save')}" />
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="15 18 9 12 15 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="s2-category-list-wrapper" id="categoryListWrapper">
|
|
||||||
<div class="s2-category-list" id="categoryList">
|
|
||||||
<button type="button" class="s2-category-tab active" data-group="">
|
|
||||||
전체
|
|
||||||
</button>
|
|
||||||
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
|
|
||||||
서비스명
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<div class="s2-selection-container">
|
|
||||||
<main class="s2-content-area">
|
|
||||||
<form id="registerStep2Form" method="post" th:action="@{/myapikey/register/step2}" class="s2-form">
|
|
||||||
|
|
||||||
<!-- Header filter: Search and Select All -->
|
|
||||||
<div class="s2-filter-header">
|
|
||||||
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
|
|
||||||
<label class="s2-select-all-label">
|
|
||||||
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
|
|
||||||
<span class="s2-checkbox-custom"></span>
|
|
||||||
<span id="selectAllText">전체 선택</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="s2-result-count">
|
|
||||||
총 <strong id="apiResultCount">0</strong>건
|
|
||||||
</div>
|
|
||||||
<div class="s2-search-box">
|
|
||||||
<input type="text" id="apiSearch" class="s2-search-input" placeholder="API 검색...">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Cards Grid -->
|
|
||||||
<div class="s2-cards-grid" id="apiCardGrid">
|
|
||||||
<!-- Loading state -->
|
|
||||||
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
|
|
||||||
<div class="s2-spinner"></div>
|
|
||||||
<p>API 목록을 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
|
|
||||||
<div class="s2-empty-icon">📦</div>
|
|
||||||
<h3>API를 선택해주세요</h3>
|
|
||||||
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom Navigation Actions -->
|
<!-- Bottom Navigation Actions -->
|
||||||
<div class="s2-actions">
|
<div class="s2-actions">
|
||||||
<button type="button" id="btnPrevStep" class="s2-btn-prev">
|
<button type="button" id="btnPrevStep" class="s2-btn-prev">
|
||||||
이전
|
이전
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" form="registerStep2Form" class="s2-btn-save">
|
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
|
||||||
다음
|
다음
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,518 +109,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
<!-- Floating Cart Button -->
|
<!-- 화면 전체 오버레이/플로팅은 body 직속(pagePopups)으로 렌더 → wrapper transform·overflow 영향 없이 뷰포트 기준 중앙 정렬 -->
|
||||||
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
|
<section layout:fragment="pagePopups">
|
||||||
<span class="s2-cart-label">선택된 API</span>
|
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}" />
|
||||||
<span class="s2-cart-badge" id="cartBadge">
|
</section>
|
||||||
<span class="s2-cart-count">0</span>
|
|
||||||
<span class="s2-cart-unit">개</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Selected APIs Modal -->
|
|
||||||
<div class="s2-modal" id="selectedApisModal" style="display: none;">
|
|
||||||
<div class="s2-modal-backdrop" id="modalOverlay"></div>
|
|
||||||
<div class="s2-modal-dialog">
|
|
||||||
<div class="s2-modal-header">
|
|
||||||
<h3 class="s2-modal-title">선택된 API 목록</h3>
|
|
||||||
<button type="button" class="s2-modal-close" id="modalCloseBtn">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="s2-modal-body">
|
|
||||||
<div class="s2-selected-list" id="modalSelectedList">
|
|
||||||
<!-- Dynamically populated -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="s2-modal-footer">
|
|
||||||
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</th:block>
|
|
||||||
|
|
||||||
<th:block layout:fragment="contentScript">
|
|
||||||
<script th:inline="javascript">
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// DOM Elements
|
|
||||||
const form = document.getElementById('registerStep2Form');
|
|
||||||
const searchInput = document.getElementById('apiSearch');
|
|
||||||
const menuTitles = document.querySelectorAll('.s2-category-tab');
|
|
||||||
const apiCardGrid = document.getElementById('apiCardGrid');
|
|
||||||
const loadingState = document.getElementById('loadingState');
|
|
||||||
const emptyState = document.getElementById('emptyState');
|
|
||||||
|
|
||||||
let currentFilter = ''; // Empty string means "all"
|
|
||||||
let currentServiceName = '전체'; // Store current service name
|
|
||||||
let allApis = []; // Store loaded APIs
|
|
||||||
let selectedApis = new Set(); // Track selected API IDs
|
|
||||||
|
|
||||||
// Restore selected APIs from session
|
|
||||||
const sessionSelectedApis = /*[[${apiKeyRegistration.selectedApis}]]*/ [];
|
|
||||||
if (sessionSelectedApis && Array.isArray(sessionSelectedApis)) {
|
|
||||||
sessionSelectedApis.forEach(function(apiId) {
|
|
||||||
selectedApis.add(apiId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load APIs via AJAX
|
|
||||||
function loadApis(groupId) {
|
|
||||||
// Show loading state
|
|
||||||
loadingState.style.display = 'block';
|
|
||||||
emptyState.style.display = 'none';
|
|
||||||
|
|
||||||
// Remove existing API cards
|
|
||||||
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
|
|
||||||
|
|
||||||
// Build URL with optional groupId filter (Thymeleaf contextPath safe)
|
|
||||||
const baseUrl = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
|
|
||||||
let url = baseUrl;
|
|
||||||
if (groupId) {
|
|
||||||
url += '?groupIds=' + encodeURIComponent(groupId);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(url).then(response => response.json()).then(apis => {
|
|
||||||
allApis = apis;
|
|
||||||
loadingState.style.display = 'none';
|
|
||||||
|
|
||||||
if (apis.length === 0) {
|
|
||||||
emptyState.style.display = 'block';
|
|
||||||
document.getElementById('apiResultCount').textContent = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('apiResultCount').textContent = apis.length;
|
|
||||||
renderApiCards(apis);
|
|
||||||
updateSelectAllUI();
|
|
||||||
}).catch(error => {
|
|
||||||
console.error('Failed to load APIs:', error);
|
|
||||||
loadingState.style.display = 'none';
|
|
||||||
emptyState.querySelector('h3').textContent = 'API 로드 실패';
|
|
||||||
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
|
|
||||||
emptyState.style.display = 'block';
|
|
||||||
document.getElementById('apiResultCount').textContent = '0';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render API cards
|
|
||||||
function renderApiCards(apis) {
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
|
|
||||||
apis.forEach(api => {
|
|
||||||
const card = createApiCard(api);
|
|
||||||
fragment.appendChild(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
apiCardGrid.appendChild(fragment);
|
|
||||||
|
|
||||||
// Re-attach event listeners
|
|
||||||
attachCardEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create API card element (Matches mainApiList.html exactly, card clicks toggle selection)
|
|
||||||
function createApiCard(api) {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 's2-api-card';
|
|
||||||
card.setAttribute('data-group', api.apiGroupId || '');
|
|
||||||
card.setAttribute('data-name', (api.apiName || '').toLowerCase());
|
|
||||||
card.setAttribute('data-desc', (api.apiSimpleDescription || '').toLowerCase());
|
|
||||||
card.setAttribute('data-api-id', api.apiId);
|
|
||||||
|
|
||||||
const isSelected = selectedApis.has(api.apiId);
|
|
||||||
if (isSelected) {
|
|
||||||
card.classList.add('selected');
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainIconHtml = api.mainIcon
|
|
||||||
? `<img src="${api.mainIcon}" alt="${api.apiName}" onerror="this.style.display='none'; this.nextElementSibling.style.display='block'"><i class="fas fa-cube" style="display:none"></i>`
|
|
||||||
: `<i class="fas fa-cube"></i>`;
|
|
||||||
|
|
||||||
card.innerHTML = `
|
|
||||||
<div class="s2-api-card-badge">
|
|
||||||
<span>${api.apiGroupName || '카테고리'}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Checkbox container with visible custom design -->
|
|
||||||
<label class="s2-checkbox-wrapper">
|
|
||||||
<input type="checkbox"
|
|
||||||
name="selectedApis"
|
|
||||||
value="${api.apiId}"
|
|
||||||
id="api-${api.apiId}"
|
|
||||||
class="s2-api-checkbox visually-hidden"
|
|
||||||
${isSelected ? 'checked' : ''}>
|
|
||||||
<span class="s2-checkbox-custom"></span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<h3 class="s2-api-card-title">${api.apiName || 'API 이름'}</h3>
|
|
||||||
<p class="s2-api-card-desc">${api.apiSimpleDescription || 'API 설명이 없습니다.'}</p>
|
|
||||||
|
|
||||||
<div class="s2-api-card-image">
|
|
||||||
${mainIconHtml}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach event listeners to cards
|
|
||||||
function attachCardEventListeners() {
|
|
||||||
const apiCards = document.querySelectorAll('.s2-api-card');
|
|
||||||
|
|
||||||
apiCards.forEach(card => {
|
|
||||||
card.addEventListener('click', function(e) {
|
|
||||||
const checkbox = card.querySelector('.s2-api-checkbox');
|
|
||||||
if (checkbox) {
|
|
||||||
checkbox.checked = !checkbox.checked;
|
|
||||||
updateCardSelection(checkbox);
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent double toggle when clicking the checkbox wrapper
|
|
||||||
const checkboxWrappers = document.querySelectorAll('.s2-checkbox-wrapper');
|
|
||||||
checkboxWrappers.forEach(wrapper => {
|
|
||||||
wrapper.addEventListener('click', function(e) {
|
|
||||||
e.stopPropagation(); // Stop click from bubbling to card!
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen to checkbox change event inside it
|
|
||||||
const checkbox = wrapper.querySelector('.s2-api-checkbox');
|
|
||||||
if (checkbox) {
|
|
||||||
checkbox.addEventListener('change', function() {
|
|
||||||
updateCardSelection(this);
|
|
||||||
updateSelectedCount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update card visual state
|
|
||||||
function updateCardSelection(checkbox) {
|
|
||||||
const card = checkbox.closest('.s2-api-card');
|
|
||||||
if (checkbox.checked) {
|
|
||||||
card.classList.add('selected');
|
|
||||||
selectedApis.add(checkbox.value);
|
|
||||||
} else {
|
|
||||||
card.classList.remove('selected');
|
|
||||||
selectedApis.delete(checkbox.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update selected count
|
|
||||||
function updateSelectedCount() {
|
|
||||||
// Update floating cart button
|
|
||||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
|
||||||
const cartCount = document.querySelector('.s2-cart-count');
|
|
||||||
|
|
||||||
if (selectedApis.size > 0) {
|
|
||||||
floatingCartBtn.style.display = 'flex';
|
|
||||||
cartCount.textContent = selectedApis.size;
|
|
||||||
} else {
|
|
||||||
floatingCartBtn.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update modal list
|
|
||||||
updateModalList();
|
|
||||||
|
|
||||||
// Update select all checkbox state
|
|
||||||
updateSelectAllCheckboxState();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update select all UI visibility and text
|
|
||||||
function updateSelectAllUI() {
|
|
||||||
const selectAllWrapper = document.getElementById('selectAllWrapper');
|
|
||||||
const selectAllText = document.getElementById('selectAllText');
|
|
||||||
|
|
||||||
if (currentFilter === '') {
|
|
||||||
// Hide select all when "전체" is selected
|
|
||||||
selectAllWrapper.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
// Show select all with service name
|
|
||||||
selectAllWrapper.style.display = 'flex';
|
|
||||||
selectAllText.textContent = currentServiceName + ' API 전체 선택';
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSelectAllCheckboxState();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update select all checkbox state based on visible cards
|
|
||||||
function updateSelectAllCheckboxState() {
|
|
||||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
|
||||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
|
||||||
|
|
||||||
if (visibleCards.length === 0) {
|
|
||||||
selectAllCheckbox.checked = false;
|
|
||||||
selectAllCheckbox.indeterminate = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
|
|
||||||
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
|
|
||||||
|
|
||||||
if (checkedCount === 0) {
|
|
||||||
selectAllCheckbox.checked = false;
|
|
||||||
selectAllCheckbox.indeterminate = false;
|
|
||||||
} else if (checkedCount === visibleCheckboxes.length) {
|
|
||||||
selectAllCheckbox.checked = true;
|
|
||||||
selectAllCheckbox.indeterminate = false;
|
|
||||||
} else {
|
|
||||||
selectAllCheckbox.checked = false;
|
|
||||||
selectAllCheckbox.indeterminate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update modal selected APIs list
|
|
||||||
function updateModalList() {
|
|
||||||
const modalSelectedList = document.getElementById('modalSelectedList');
|
|
||||||
modalSelectedList.innerHTML = '';
|
|
||||||
|
|
||||||
if (selectedApis.size === 0) {
|
|
||||||
modalSelectedList.innerHTML = '<p class="s2-empty-message">선택된 API가 없습니다.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all checked checkboxes
|
|
||||||
const checkedBoxes = document.querySelectorAll('.s2-api-checkbox:checked');
|
|
||||||
checkedBoxes.forEach(function(checkbox) {
|
|
||||||
const card = checkbox.closest('.s2-api-card');
|
|
||||||
const apiName = card.querySelector('.s2-api-card-title').textContent;
|
|
||||||
|
|
||||||
const apiPill = document.createElement('div');
|
|
||||||
apiPill.className = 's2-api-pill';
|
|
||||||
apiPill.innerHTML = `
|
|
||||||
<span class="s2-api-pill-name">${apiName}</span>
|
|
||||||
<button type="button" class="s2-api-pill-remove" data-value="${checkbox.value}" aria-label="Remove ${apiName}">✕</button>
|
|
||||||
`;
|
|
||||||
modalSelectedList.appendChild(apiPill);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add remove handlers
|
|
||||||
document.querySelectorAll('.s2-api-pill-remove').forEach(function(btn) {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const value = this.getAttribute('data-value');
|
|
||||||
const checkbox = document.querySelector('.s2-api-checkbox[value="' + value + '"]');
|
|
||||||
if (checkbox) {
|
|
||||||
checkbox.checked = false;
|
|
||||||
updateCardSelection(checkbox);
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search functionality
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.addEventListener('input', function() {
|
|
||||||
const searchTerm = this.value.toLowerCase();
|
|
||||||
|
|
||||||
const apiCards = document.querySelectorAll('.s2-api-card');
|
|
||||||
let visibleCount = 0;
|
|
||||||
apiCards.forEach(function(card) {
|
|
||||||
const apiName = card.getAttribute('data-name');
|
|
||||||
const apiDesc = card.getAttribute('data-desc');
|
|
||||||
|
|
||||||
// Check if matches search
|
|
||||||
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
|
|
||||||
|
|
||||||
if (matchesSearch) {
|
|
||||||
card.style.display = '';
|
|
||||||
visibleCount++;
|
|
||||||
} else {
|
|
||||||
card.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('apiResultCount').textContent = visibleCount;
|
|
||||||
|
|
||||||
// Update select all checkbox state after search
|
|
||||||
updateSelectAllCheckboxState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category tab selection
|
|
||||||
menuTitles.forEach(function(title) {
|
|
||||||
title.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Remove active from all
|
|
||||||
menuTitles.forEach(t => t.classList.remove('active'));
|
|
||||||
// Add active to clicked
|
|
||||||
this.classList.add('active');
|
|
||||||
|
|
||||||
const groupId = this.getAttribute('data-group');
|
|
||||||
currentFilter = groupId;
|
|
||||||
|
|
||||||
// Store service name for select all label
|
|
||||||
currentServiceName = this.textContent.trim();
|
|
||||||
|
|
||||||
// Load APIs for selected service
|
|
||||||
loadApis(groupId);
|
|
||||||
|
|
||||||
// Clear search when changing category
|
|
||||||
if (searchInput) {
|
|
||||||
searchInput.value = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Category Carousel Scroll
|
|
||||||
const categoryListWrapper = document.getElementById('categoryListWrapper');
|
|
||||||
const btnPrevCategory = document.getElementById('btnPrevCategory');
|
|
||||||
const btnNextCategory = document.getElementById('btnNextCategory');
|
|
||||||
|
|
||||||
if (categoryListWrapper && btnPrevCategory && btnNextCategory) {
|
|
||||||
const scrollAmount = 200;
|
|
||||||
|
|
||||||
btnPrevCategory.addEventListener('click', function() {
|
|
||||||
categoryListWrapper.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
btnNextCategory.addEventListener('click', function() {
|
|
||||||
categoryListWrapper.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle buttons visibility/disabled state based on scroll position
|
|
||||||
function updateCarouselButtons() {
|
|
||||||
const scrollLeft = categoryListWrapper.scrollLeft;
|
|
||||||
const maxScrollLeft = categoryListWrapper.scrollWidth - categoryListWrapper.clientWidth;
|
|
||||||
|
|
||||||
btnPrevCategory.disabled = scrollLeft <= 0;
|
|
||||||
btnNextCategory.disabled = scrollLeft >= maxScrollLeft - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryListWrapper.addEventListener('scroll', updateCarouselButtons);
|
|
||||||
window.addEventListener('resize', updateCarouselButtons);
|
|
||||||
|
|
||||||
// Initial check after loading categories
|
|
||||||
setTimeout(updateCarouselButtons, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal control
|
|
||||||
const floatingCartBtn = document.getElementById('floatingCartBtn');
|
|
||||||
const selectedApisModal = document.getElementById('selectedApisModal');
|
|
||||||
const modalOverlay = document.getElementById('modalOverlay');
|
|
||||||
const modalCloseBtn = document.getElementById('modalCloseBtn');
|
|
||||||
const modalCancelBtn = document.getElementById('modalCancelBtn');
|
|
||||||
|
|
||||||
// Open modal
|
|
||||||
function openModal() {
|
|
||||||
selectedApisModal.style.display = 'block';
|
|
||||||
// Add show class to backdrop and modal for visibility
|
|
||||||
setTimeout(function() {
|
|
||||||
if (modalOverlay) {
|
|
||||||
modalOverlay.classList.add('show');
|
|
||||||
}
|
|
||||||
selectedApisModal.classList.add('show');
|
|
||||||
}, 10);
|
|
||||||
document.body.style.overflow = 'hidden'; // Prevent background scroll
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
function closeModal() {
|
|
||||||
// Remove show class first for transition
|
|
||||||
if (modalOverlay) {
|
|
||||||
modalOverlay.classList.remove('show');
|
|
||||||
}
|
|
||||||
selectedApisModal.classList.remove('show');
|
|
||||||
|
|
||||||
// Hide modal after transition
|
|
||||||
setTimeout(function() {
|
|
||||||
selectedApisModal.style.display = 'none';
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
document.body.style.overflow = ''; // Restore scroll
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floating cart button click
|
|
||||||
if (floatingCartBtn) {
|
|
||||||
floatingCartBtn.addEventListener('click', openModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal overlay click
|
|
||||||
if (modalOverlay) {
|
|
||||||
modalOverlay.addEventListener('click', closeModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal close button click
|
|
||||||
if (modalCloseBtn) {
|
|
||||||
modalCloseBtn.addEventListener('click', closeModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal cancel button click
|
|
||||||
if (modalCancelBtn) {
|
|
||||||
modalCancelBtn.addEventListener('click', closeModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal on ESC key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape' && selectedApisModal.style.display === 'block') {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Select All checkbox event
|
|
||||||
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
|
|
||||||
if (selectAllCheckbox) {
|
|
||||||
selectAllCheckbox.addEventListener('change', function() {
|
|
||||||
const isChecked = this.checked;
|
|
||||||
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
|
|
||||||
|
|
||||||
visibleCards.forEach(function(card) {
|
|
||||||
const checkbox = card.querySelector('.s2-api-checkbox');
|
|
||||||
if (checkbox) {
|
|
||||||
checkbox.checked = isChecked;
|
|
||||||
updateCardSelection(checkbox);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
updateSelectedCount();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Previous step button - save current selections before going back
|
|
||||||
const btnPrevStep = document.getElementById('btnPrevStep');
|
|
||||||
if (btnPrevStep) {
|
|
||||||
btnPrevStep.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Change form action to save endpoint (Thymeleaf contextPath safe)
|
|
||||||
form.action = /*[[@{/myapikey/register/step2/save}]]*/ '/myapikey/register/step2/save';
|
|
||||||
|
|
||||||
// Submit the form to save selections
|
|
||||||
form.submit();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form validation
|
|
||||||
form.addEventListener('submit', function(e) {
|
|
||||||
if (selectedApis.size === 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
customPopups.showAlert('최소 1개 이상의 API를 선택해주세요.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store selected APIs in sessionStorage
|
|
||||||
sessionStorage.setItem('registration_selectedApis', JSON.stringify(Array.from(selectedApis)));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
updateSelectedCount(); // This will show count from restored session data
|
|
||||||
|
|
||||||
// Load all APIs automatically on page load (empty string = all APIs)
|
|
||||||
loadApis('');
|
|
||||||
|
|
||||||
// Load data from step 1 (if needed for display)
|
|
||||||
const appName = sessionStorage.getItem('registration_appName');
|
|
||||||
if (appName) {
|
|
||||||
console.log('App Name from Step 1:', appName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log restored selections for debugging
|
|
||||||
if (selectedApis.size > 0) {
|
|
||||||
console.log('Restored ' + selectedApis.size + ' selected APIs from session:', Array.from(selectedApis));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</th:block>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<!-- Step 1 -->
|
<!-- Step 1 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box one">
|
<div class="signup-step__icon-box one">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z"
|
d="M19 14C19 12.8954 19.8954 12 21 12C22.1046 12 23 12.8954 23 14C23 15.1046 22.1046 16 21 16C19.8954 16 19 15.1046 19 14Z"
|
||||||
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||||
stroke-linejoin="round" />
|
stroke-linejoin="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-person-add" viewBox="0 0 16 16">
|
class="bi bi-person-add" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
<!-- Step 2 -->
|
<!-- Step 2 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box two">
|
<div class="signup-step__icon-box two">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path
|
||||||
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
d="M14 20C14 19.4477 14.4477 19 15 19H17C17.5523 19 18 19.4477 18 20V24C18 24.5523 17.5523 25 17 25H15C14.4477 25 14 24.5523 14 24V20Z"
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
stroke-linejoin="round" />
|
stroke-linejoin="round" />
|
||||||
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
|
<path d="M19 16L21 18L25 14" stroke="#0049B4" stroke-width="1.6"
|
||||||
stroke-linecap="round" stroke-linejoin="round" />
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-journal-check" viewBox="0 0 16 16">
|
class="bi bi-journal-check" viewBox="0 0 16 16">
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
@@ -115,14 +115,14 @@
|
|||||||
<!-- Step 3 -->
|
<!-- Step 3 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box three">
|
<div class="signup-step__icon-box three">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
|
<rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"
|
||||||
stroke-linecap="round" stroke-linejoin="round" />
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 12H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 16H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 20H16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-code" viewBox="0 0 16 16">
|
class="bi bi-code" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
@@ -138,13 +138,13 @@
|
|||||||
<!-- Step 4 -->
|
<!-- Step 4 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box four">
|
<div class="signup-step__icon-box four">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
<path d="M21 8H24V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||||
stroke-linejoin="round" />
|
stroke-linejoin="round" />
|
||||||
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
<path d="M8 8H11V16" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round"
|
||||||
stroke-linejoin="round" />
|
stroke-linejoin="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-display" viewBox="0 0 16 16">
|
class="bi bi-display" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
@@ -160,14 +160,14 @@
|
|||||||
<!-- Step 5 -->
|
<!-- Step 5 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box five">
|
<div class="signup-step__icon-box five">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"-->
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"*/-->
|
||||||
<!-- xmlns="http://www.w3.org/2000/svg">-->
|
<!-- xmlns="http://www.w3.org/2000/svg">-->
|
||||||
<!-- <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"-->
|
<!--/* <rect x="7" y="7" width="18" height="18" rx="2" stroke="#0049B4" stroke-width="1.6"*/-->
|
||||||
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
||||||
<!-- <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />-->
|
<!--/* <path d="M12 11H20" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />*/-->
|
||||||
<!-- <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"-->
|
<!--/* <path d="M12 15L14.5 17.5L20 12" stroke="#0049B4" stroke-width="1.6"*/-->
|
||||||
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
<!-- stroke-linecap="round" stroke-linejoin="round" />-->
|
||||||
<!-- </svg>-->
|
<!--/* </svg>*/-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
|
class="bi bi-ui-checks-grid" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
<!-- Step 6 -->
|
<!-- Step 6 -->
|
||||||
<div class="signup-step">
|
<div class="signup-step">
|
||||||
<div class="signup-step__icon-box six">
|
<div class="signup-step__icon-box six">
|
||||||
<!-- <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
<!--/* <svg width="40" height="40" viewBox="0 0 32 32" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
|
<rect x="7" y="9" width="18" height="16" rx="2" stroke="#0049B4" stroke-width="1.6"
|
||||||
stroke-linecap="round" stroke-linejoin="round" />
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M20 7V11" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 17H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
<path d="M12 21H14" stroke="#0049B4" stroke-width="1.6" stroke-linecap="round" />
|
||||||
</svg> -->
|
</svg> */-->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-airplane" viewBox="0 0 16 16">
|
class="bi bi-airplane" viewBox="0 0 16 16">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<span class="oauth2-2legged__prereq-num">3</span>
|
<span class="oauth2-2legged__prereq-num">3</span>
|
||||||
<div class="oauth2-2legged__prereq-body">
|
<div class="oauth2-2legged__prereq-body">
|
||||||
<h3 class="oauth2-2legged__prereq-title">Scope 확인</h3>
|
<h3 class="oauth2-2legged__prereq-title">Scope 확인</h3>
|
||||||
<p>호출하려는 API에 필요한 scope</p>
|
<p>scope 는 고정값 "api" 사용</p>
|
||||||
<p>권한이 부여됐는지 확인합니다.</p>
|
<p>권한이 부여됐는지 확인합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
<text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700"
|
<text x="560" y="102" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
fill="#0049b4">① POST /dj/oauth/token</text>
|
fill="#0049b4">① POST /dj/oauth/token</text>
|
||||||
<text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500"
|
<text x="560" y="118" text-anchor="middle" font-size="11" font-weight="500"
|
||||||
fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope</text>
|
fill="#64748B">grant_type=client_credentials, client_id, client_secret, scope=api</text>
|
||||||
<line x1="240" y1="128" x2="880" y2="128" stroke="#0049b4" stroke-width="2"
|
<line x1="240" y1="128" x2="880" y2="128" stroke="#0049b4" stroke-width="2"
|
||||||
marker-end="url(#o2leg-arrow-primary)" />
|
marker-end="url(#o2leg-arrow-primary)" />
|
||||||
|
|
||||||
@@ -145,11 +145,11 @@
|
|||||||
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2"
|
<line x1="880" y1="170" x2="240" y2="170" stroke="#64748B" stroke-width="2"
|
||||||
marker-end="url(#o2leg-arrow-gray)" />
|
marker-end="url(#o2leg-arrow-gray)" />
|
||||||
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500"
|
<text x="560" y="188" text-anchor="middle" font-size="11" font-weight="500"
|
||||||
fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope, jti
|
fill="#64748B">{ access_token, token_type:"bearer", expires_in:86400, scope:"api", jti
|
||||||
}</text>
|
}</text>
|
||||||
|
|
||||||
<text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700"
|
<text x="560" y="216" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
fill="#0049b4">③ GET /api/v1/... · Authorization: Bearer <access_token></text>
|
fill="#0049b4">③ GET /api/v1/... · X-AUTH-TOKEN: Bearer <access_token></text>
|
||||||
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2"
|
<line x1="240" y1="226" x2="880" y2="226" stroke="#0049b4" stroke-width="2"
|
||||||
marker-end="url(#o2leg-arrow-primary)" />
|
marker-end="url(#o2leg-arrow-primary)" />
|
||||||
|
|
||||||
@@ -210,9 +210,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><code>scope</code></td>
|
<td><code>scope</code></td>
|
||||||
<td><span
|
<td><span
|
||||||
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--optional">선택</span>
|
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">필수</span>
|
||||||
</td>
|
</td>
|
||||||
<td>호출 권한 범위 (공백 구분)</td>
|
<td>고정값 "api"</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -228,7 +228,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
-d <span class="o2leg-c">'grant_type=client_credentials'</span> \
|
-d <span class="o2leg-c">'grant_type=client_credentials'</span> \
|
||||||
-d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \
|
-d <span class="o2leg-c">'client_id=YOUR_CLIENT_ID'</span> \
|
||||||
-d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \
|
-d <span class="o2leg-c">'client_secret=YOUR_CLIENT_SECRET'</span> \
|
||||||
-d <span class="o2leg-c">'scope=read.accounts'</span>
|
-d <span class="o2leg-c">'scope=api'</span>
|
||||||
|
|
||||||
<span class="o2leg-g"># 응답: 200 OK + JSON</span></pre>
|
<span class="o2leg-g"># 응답: 200 OK + JSON</span></pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -248,7 +248,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>,
|
<span class="o2leg-c">"access_token"</span>: <span class="o2leg-y">"eyJhbGciOiJSUzI1NiJ9..."</span>,
|
||||||
<span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>,
|
<span class="o2leg-c">"token_type"</span>: <span class="o2leg-y">"bearer"</span>,
|
||||||
<span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86400</span>,
|
<span class="o2leg-c">"expires_in"</span>: <span class="o2leg-p">86400</span>,
|
||||||
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"read.accounts"</span>,
|
<span class="o2leg-c">"scope"</span>: <span class="o2leg-y">"api"</span>,
|
||||||
<span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span>
|
<span class="o2leg-c">"jti"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-..."</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<tr>
|
<tr>
|
||||||
<td><code class="oauth2-2legged__field-name">scope</code></td>
|
<td><code class="oauth2-2legged__field-name">scope</code></td>
|
||||||
<td>string</td>
|
<td>string</td>
|
||||||
<td>실제 부여된 권한 범위</td>
|
<td>부여된 권한 범위 (api)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code class="oauth2-2legged__field-name">jti</code></td>
|
<td><code class="oauth2-2legged__field-name">jti</code></td>
|
||||||
@@ -301,13 +301,13 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title">
|
<section class="oauth2-2legged__step" aria-labelledby="o2leg-step3-title">
|
||||||
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
||||||
<h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2>
|
<h2 class="oauth2-2legged__h2" id="o2leg-step3-title">발급 토큰으로 API 호출</h2>
|
||||||
<p class="oauth2-2legged__desc">Authorization 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p>
|
<p class="oauth2-2legged__desc">X-AUTH-TOKEN 헤더에 Bearer 토큰을 실어 보호 자원 API 를 호출합니다.</p>
|
||||||
|
|
||||||
<div class="oauth2-2legged__step-grid">
|
<div class="oauth2-2legged__step-grid">
|
||||||
<div class="oauth2-2legged__panel">
|
<div class="oauth2-2legged__panel">
|
||||||
<h3 class="oauth2-2legged__panel-title">필수 헤더</h3>
|
<h3 class="oauth2-2legged__panel-title">필수 헤더</h3>
|
||||||
<div class="oauth2-2legged__header-box">
|
<div class="oauth2-2legged__header-box">
|
||||||
<code class="oauth2-2legged__header-key">Authorization:</code>
|
<code class="oauth2-2legged__header-key">X-AUTH-TOKEN:</code>
|
||||||
<code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code>
|
<code class="oauth2-2legged__header-value">Bearer eyJhbGciOiJSUzI1NiJ9...</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -316,7 +316,6 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li>
|
<li>동일 토큰은 expires_in(기본 86400초) 동안 재사용</li>
|
||||||
<li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li>
|
<li>만료 임박 시 재발급 후 교체 (예: TTL의 80% 시점)</li>
|
||||||
<li>매 호출마다 토큰을 새로 발급하지 마세요</li>
|
<li>매 호출마다 토큰을 새로 발급하지 마세요</li>
|
||||||
<li>권한이 다른 API 는 scope 별로 토큰을 분리 발급</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -325,7 +324,7 @@ curl -X <span class="o2leg-y">POST</span> <span class="o2leg-c">'https://openapi
|
|||||||
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span>
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 보호 자원 API 호출</span>
|
||||||
curl -X <span class="o2leg-y">GET</span> \
|
curl -X <span class="o2leg-y">GET</span> \
|
||||||
<span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \
|
<span class="o2leg-c">'https://openapi.djbank.co.kr/api/v1/accounts'</span> \
|
||||||
-H <span class="o2leg-c">'Authorization: Bearer eyJhbGciOiJSUzI..'</span> \
|
-H <span class="o2leg-c">'X-AUTH-TOKEN: Bearer eyJhbGciOiJSUzI..'</span> \
|
||||||
-H <span class="o2leg-c">'Accept: application/json'</span>
|
-H <span class="o2leg-c">'Accept: application/json'</span>
|
||||||
|
|
||||||
<span class="o2leg-g"># 응답</span>
|
<span class="o2leg-g"># 응답</span>
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<section class="oauth2-2legged">
|
||||||
|
|
||||||
|
<!-- Section 1: Hero -->
|
||||||
|
<header class="oauth2-2legged__hero">
|
||||||
|
<div class="oauth2-2legged__hero-inner">
|
||||||
|
<div class="oauth2-2legged__hero-body">
|
||||||
|
<span class="oauth2-2legged__hero-eyebrow">
|
||||||
|
<span class="oauth2-2legged__hero-eyebrow-dot"></span>
|
||||||
|
개발 가이드 · Webhook · HMAC-SHA256
|
||||||
|
</span>
|
||||||
|
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
|
||||||
|
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
|
||||||
|
단계별로 설명합니다.</p>
|
||||||
|
|
||||||
|
<!-- <div class="oauth2-2legged__hero-chips">
|
||||||
|
<span class="oauth2-2legged__chip oauth2-2legged__chip--primary">HMAC-SHA256</span>
|
||||||
|
<span class="oauth2-2legged__chip">X-Webhook-Signature</span>
|
||||||
|
<span class="oauth2-2legged__chip">Raw Body</span>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__hero-illust" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 280 200" xmlns="http://www.w3.org/2000/svg" role="img"
|
||||||
|
aria-label="Signed Webhook Delivery">
|
||||||
|
<defs>
|
||||||
|
<marker id="whsig-hero-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
|
||||||
|
markerHeight="8" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="280" height="200" rx="20" fill="#FFFFFF" />
|
||||||
|
|
||||||
|
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
|
||||||
|
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700"
|
||||||
|
fill="#0049b4">DJBank</text>
|
||||||
|
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700"
|
||||||
|
fill="#0049b4">Webhook</text>
|
||||||
|
|
||||||
|
<rect x="170" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
|
||||||
|
<text x="213" y="114" text-anchor="middle" font-size="10" font-weight="700"
|
||||||
|
fill="#0049b4">Your</text>
|
||||||
|
<text x="213" y="128" text-anchor="middle" font-size="10" font-weight="700"
|
||||||
|
fill="#0049b4">Endpoint</text>
|
||||||
|
|
||||||
|
<line x1="110" y1="118" x2="170" y2="118" stroke="#0049b4" stroke-width="2"
|
||||||
|
marker-end="url(#whsig-hero-arrow)" />
|
||||||
|
|
||||||
|
<g transform="translate(58,28)">
|
||||||
|
<rect width="164" height="34" rx="6" fill="#1A1A2E" />
|
||||||
|
<text x="12" y="16" font-family="'Fira Code', monospace" font-size="9"
|
||||||
|
fill="#00D4FF">X-Webhook-Signature:</text>
|
||||||
|
<text x="12" y="28" font-family="'Fira Code', monospace" font-size="9"
|
||||||
|
fill="#FFD93D">sha256=9f86d0..</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g transform="translate(133,104)">
|
||||||
|
<circle r="13" fill="#0049b4" />
|
||||||
|
<path d="M-5,-1 h10 v7 h-10 z M-3,-1 v-3 a3,3 0 0 1 6,0 v3" fill="none" stroke="#FFFFFF"
|
||||||
|
stroke-width="1.5" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="140" y="172" text-anchor="middle" font-size="11" font-weight="600"
|
||||||
|
fill="#64748B">Signed Webhook Delivery</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container service-main">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhookGuide')}"></th:block>
|
||||||
|
|
||||||
|
<div class="service-content">
|
||||||
|
|
||||||
|
<!-- Section 2: 개요 -->
|
||||||
|
<section class="oauth2-2legged__prereq" aria-labelledby="whsig-prereq-title">
|
||||||
|
<span class="oauth2-2legged__eyebrow">OVERVIEW</span>
|
||||||
|
<h2 class="oauth2-2legged__h2" id="whsig-prereq-title">서명 검증이 필요한 이유</h2>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__prereq-grid">
|
||||||
|
<article class="oauth2-2legged__prereq-card">
|
||||||
|
<span class="oauth2-2legged__prereq-num">1</span>
|
||||||
|
<div class="oauth2-2legged__prereq-body">
|
||||||
|
<h3 class="oauth2-2legged__prereq-title">Secret 확보</h3>
|
||||||
|
<p>[마이페이지 > Webhook 관리]에서 발급된</p>
|
||||||
|
<p>Secret Key를 서버에 안전하게 보관.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="oauth2-2legged__prereq-card">
|
||||||
|
<span class="oauth2-2legged__prereq-num">2</span>
|
||||||
|
<div class="oauth2-2legged__prereq-body">
|
||||||
|
<h3 class="oauth2-2legged__prereq-title">원문(raw body) 보존</h3>
|
||||||
|
<p>수신 즉시 본문을 파싱/재직렬화하지 말고</p>
|
||||||
|
<p>바이트 원문 그대로 서명 계산에 사용.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="oauth2-2legged__prereq-card">
|
||||||
|
<span class="oauth2-2legged__prereq-num">3</span>
|
||||||
|
<div class="oauth2-2legged__prereq-body">
|
||||||
|
<h3 class="oauth2-2legged__prereq-title">HMAC 재계산·비교</h3>
|
||||||
|
<p>동일 Secret으로 HMAC-SHA256을 재계산해</p>
|
||||||
|
<p>헤더 서명과 상수 시간으로 비교.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 3: 전체 서명·검증 시퀀스 -->
|
||||||
|
<section class="oauth2-2legged__sequence" aria-labelledby="whsig-seq-title">
|
||||||
|
<span class="oauth2-2legged__eyebrow">SEQUENCE</span>
|
||||||
|
<h2 class="oauth2-2legged__h2" id="whsig-seq-title">전체 서명·검증 시퀀스</h2>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__sequence-diagram">
|
||||||
|
<svg viewBox="0 0 1120 300" xmlns="http://www.w3.org/2000/svg" role="img"
|
||||||
|
aria-label="Webhook Signature Verification Sequence">
|
||||||
|
<defs>
|
||||||
|
<marker id="whsig-arrow-primary" viewBox="0 0 10 10" refX="9" refY="5"
|
||||||
|
markerWidth="8" markerHeight="8" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#0049b4" />
|
||||||
|
</marker>
|
||||||
|
<marker id="whsig-arrow-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8"
|
||||||
|
markerHeight="8" orient="auto">
|
||||||
|
<path d="M0,0 L10,5 L0,10 Z" fill="#64748B" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g>
|
||||||
|
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE"
|
||||||
|
stroke="#0049b4" />
|
||||||
|
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700"
|
||||||
|
fill="#0049b4">DJBank Webhook Sender</text>
|
||||||
|
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF"
|
||||||
|
stroke="#0049b4" />
|
||||||
|
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700"
|
||||||
|
fill="#0049b4">Your Endpoint</text>
|
||||||
|
<line x1="880" y1="72" x2="880" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="240" y="104" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
|
fill="#64748B">① 이벤트 발생 (점검·지연·장애)</text>
|
||||||
|
<text x="240" y="124" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
|
fill="#64748B">② signature = HMAC-SHA256(secret, body)</text>
|
||||||
|
|
||||||
|
<text x="560" y="156" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
|
fill="#0049b4">③ POST body · X-Webhook-Signature: sha256=<hex></text>
|
||||||
|
<line x1="240" y1="166" x2="880" y2="166" stroke="#0049b4" stroke-width="2"
|
||||||
|
marker-end="url(#whsig-arrow-primary)" />
|
||||||
|
|
||||||
|
<text x="880" y="198" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
|
fill="#64748B">④ 동일 secret으로 재계산</text>
|
||||||
|
<text x="880" y="218" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
|
fill="#64748B">⑤ 상수 시간 비교 (일치 여부)</text>
|
||||||
|
|
||||||
|
<text x="560" y="250" text-anchor="middle" font-size="12" font-weight="700"
|
||||||
|
fill="#64748B">⑥ 200 OK (검증 성공 시)</text>
|
||||||
|
<line x1="880" y1="260" x2="240" y2="260" stroke="#64748B" stroke-width="2"
|
||||||
|
marker-end="url(#whsig-arrow-gray)" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 4: STEP 1 — 수신 요청 형식 -->
|
||||||
|
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
|
||||||
|
<span class="oauth2-2legged__eyebrow">STEP 1</span>
|
||||||
|
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
|
||||||
|
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__endpoint-box">
|
||||||
|
<span class="oauth2-2legged__method">POST</span>
|
||||||
|
<code class="oauth2-2legged__endpoint-path">https://your-service.example.com/webhook</code>
|
||||||
|
<span class="oauth2-2legged__endpoint-content-type">application/json</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__step-grid">
|
||||||
|
<div class="oauth2-2legged__panel">
|
||||||
|
<h3 class="oauth2-2legged__panel-title">요청 헤더</h3>
|
||||||
|
<table class="oauth2-2legged__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>HEADER</th>
|
||||||
|
<th>설명</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>X-Webhook-Signature</code></td>
|
||||||
|
<td><code>sha256=<hex></code> — 본문 HMAC-SHA256 서명(소문자 hex)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>X-Webhook-Event</code></td>
|
||||||
|
<td>이벤트 코드 (예: CONTROL_START)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>X-Webhook-Timestamp</code></td>
|
||||||
|
<td>발송 시각 (epoch millis) — 재전송 방어용</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Content-Type</code></td>
|
||||||
|
<td>application/json</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__code-panel">
|
||||||
|
<span class="oauth2-2legged__code-tag">Request Body · JSON</span>
|
||||||
|
<pre class="oauth2-2legged__code-block">{
|
||||||
|
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>,
|
||||||
|
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
|
||||||
|
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
|
||||||
|
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 5: STEP 2 — 서명 검증 알고리즘 -->
|
||||||
|
<section class="oauth2-2legged__step" aria-labelledby="whsig-step2-title">
|
||||||
|
<span class="oauth2-2legged__eyebrow">STEP 2</span>
|
||||||
|
<h2 class="oauth2-2legged__h2" id="whsig-step2-title">서명 검증 알고리즘</h2>
|
||||||
|
<p class="oauth2-2legged__desc">수신한 본문 원문과 발급된 Secret으로 서명을 재계산해 헤더 값과 비교합니다.</p>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__step-grid">
|
||||||
|
<div class="oauth2-2legged__code-panel">
|
||||||
|
<span class="oauth2-2legged__code-tag">Verification Steps</span>
|
||||||
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 1) 헤더에서 서명 추출</span>
|
||||||
|
received = header[<span class="o2leg-c">"X-Webhook-Signature"</span>] <span class="o2leg-g"># "sha256=...."</span>
|
||||||
|
|
||||||
|
<span class="o2leg-cm"># 2) 본문 원문으로 HMAC-SHA256 재계산</span>
|
||||||
|
digest = HMAC_SHA256(secret, rawBody) <span class="o2leg-g"># bytes</span>
|
||||||
|
expected = <span class="o2leg-y">"sha256="</span> + toHexLower(digest)
|
||||||
|
|
||||||
|
<span class="o2leg-cm"># 3) 상수 시간 비교</span>
|
||||||
|
valid = constantTimeEquals(received, expected)</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__panel">
|
||||||
|
<h3 class="oauth2-2legged__panel-title">검증 규칙</h3>
|
||||||
|
<ul class="oauth2-2legged__tips">
|
||||||
|
<li>알고리즘: <code>HmacSHA256</code>, 키: Secret(UTF-8 bytes)</li>
|
||||||
|
<li>메시지: 수신 <strong>본문 원문</strong>(UTF-8 bytes)</li>
|
||||||
|
<li>출력: <strong>소문자 hex</strong>, 헤더는 <code>sha256=</code> 접두 포함</li>
|
||||||
|
<li>비교: 타이밍 공격 방지를 위해 <strong>상수 시간</strong> 비교</li>
|
||||||
|
<li>불일치 시 요청을 폐기하고 2xx 이외로 응답</li>
|
||||||
|
</ul>
|
||||||
|
<h4 class="oauth2-2legged__panel-subtitle">재전송(replay) 방어</h4>
|
||||||
|
<ul class="oauth2-2legged__tips">
|
||||||
|
<li><code>X-Webhook-Timestamp</code>가 허용 오차(예: 5분) 밖이면 거부</li>
|
||||||
|
<li>동일 <code>eventId</code> 중복 수신은 멱등 처리</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 6: STEP 3 — 언어별 예제 -->
|
||||||
|
<section class="oauth2-2legged__step" aria-labelledby="whsig-step3-title">
|
||||||
|
<span class="oauth2-2legged__eyebrow">STEP 3</span>
|
||||||
|
<h2 class="oauth2-2legged__h2" id="whsig-step3-title">언어별 서명 검증 예제</h2>
|
||||||
|
<p class="oauth2-2legged__desc">프레임워크에서 반드시 <strong>원문 바디</strong>에 접근할 수 있어야 합니다.</p>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__step-grid">
|
||||||
|
<div class="oauth2-2legged__code-panel">
|
||||||
|
<span class="oauth2-2legged__code-tag">Node.js (Express)</span>
|
||||||
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">const</span> crypto = <span class="o2leg-y">require</span>(<span class="o2leg-c">'crypto'</span>);
|
||||||
|
|
||||||
|
<span class="o2leg-cm">// rawBody: express.raw() 등으로 확보한 원문 Buffer</span>
|
||||||
|
<span class="o2leg-p">function</span> <span class="o2leg-y">verify</span>(rawBody, header, secret) {
|
||||||
|
<span class="o2leg-p">const</span> expected = <span class="o2leg-c">'sha256='</span> +
|
||||||
|
crypto.<span class="o2leg-y">createHmac</span>(<span class="o2leg-c">'sha256'</span>, secret)
|
||||||
|
.<span class="o2leg-y">update</span>(rawBody)
|
||||||
|
.<span class="o2leg-y">digest</span>(<span class="o2leg-c">'hex'</span>);
|
||||||
|
<span class="o2leg-p">const</span> a = Buffer.<span class="o2leg-y">from</span>(header);
|
||||||
|
<span class="o2leg-p">const</span> b = Buffer.<span class="o2leg-y">from</span>(expected);
|
||||||
|
<span class="o2leg-p">return</span> a.length === b.length &&
|
||||||
|
crypto.<span class="o2leg-y">timingSafeEqual</span>(a, b);
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__code-panel">
|
||||||
|
<span class="oauth2-2legged__code-tag">Python (Flask)</span>
|
||||||
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> hmac, hashlib
|
||||||
|
|
||||||
|
<span class="o2leg-cm"># raw_body: request.get_data() 로 확보한 bytes</span>
|
||||||
|
<span class="o2leg-p">def</span> <span class="o2leg-y">verify</span>(raw_body, header, secret):
|
||||||
|
digest = hmac.<span class="o2leg-y">new</span>(
|
||||||
|
secret.<span class="o2leg-y">encode</span>(<span class="o2leg-c">'utf-8'</span>),
|
||||||
|
raw_body,
|
||||||
|
hashlib.sha256
|
||||||
|
).<span class="o2leg-y">hexdigest</span>()
|
||||||
|
expected = <span class="o2leg-c">'sha256='</span> + digest
|
||||||
|
<span class="o2leg-p">return</span> hmac.<span class="o2leg-y">compare_digest</span>(expected, header)</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__step-grid">
|
||||||
|
<div class="oauth2-2legged__code-panel">
|
||||||
|
<span class="oauth2-2legged__code-tag">Java</span>
|
||||||
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-p">import</span> javax.crypto.Mac;
|
||||||
|
<span class="o2leg-p">import</span> javax.crypto.spec.SecretKeySpec;
|
||||||
|
<span class="o2leg-p">import</span> java.nio.charset.StandardCharsets;
|
||||||
|
<span class="o2leg-p">import</span> java.security.MessageDigest;
|
||||||
|
|
||||||
|
<span class="o2leg-cm">// rawBody: 파싱 전 원문 문자열</span>
|
||||||
|
<span class="o2leg-p">boolean</span> <span class="o2leg-y">verify</span>(String rawBody, String header, String secret) <span class="o2leg-p">throws</span> Exception {
|
||||||
|
Mac mac = Mac.<span class="o2leg-y">getInstance</span>(<span class="o2leg-c">"HmacSHA256"</span>);
|
||||||
|
mac.<span class="o2leg-y">init</span>(<span class="o2leg-p">new</span> SecretKeySpec(secret.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8), <span class="o2leg-c">"HmacSHA256"</span>));
|
||||||
|
<span class="o2leg-p">byte</span>[] hash = mac.<span class="o2leg-y">doFinal</span>(rawBody.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder hex = <span class="o2leg-p">new</span> StringBuilder();
|
||||||
|
<span class="o2leg-p">for</span> (<span class="o2leg-p">byte</span> b : hash) hex.<span class="o2leg-y">append</span>(String.<span class="o2leg-y">format</span>(<span class="o2leg-c">"%02x"</span>, b));
|
||||||
|
String expected = <span class="o2leg-c">"sha256="</span> + hex;
|
||||||
|
<span class="o2leg-p">return</span> MessageDigest.<span class="o2leg-y">isEqual</span>(
|
||||||
|
expected.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8),
|
||||||
|
header.<span class="o2leg-y">getBytes</span>(StandardCharsets.UTF_8));
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__panel">
|
||||||
|
<h3 class="oauth2-2legged__panel-title">이벤트 코드 (X-Webhook-Event)</h3>
|
||||||
|
<table class="oauth2-2legged__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>CODE</th>
|
||||||
|
<th>의미</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>CONTROL_START</code></td>
|
||||||
|
<td>점검 시작</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>CONTROL_END</code></td>
|
||||||
|
<td>점검 종료</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DELAY_START</code></td>
|
||||||
|
<td>지연 시작</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DELAY_END</code></td>
|
||||||
|
<td>지연 종료</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ERROR_START</code></td>
|
||||||
|
<td>장애 시작</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ERROR_END</code></td>
|
||||||
|
<td>장애 종료</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 6.5: STEP 4 — 응답 반환 규칙 -->
|
||||||
|
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
|
||||||
|
<span class="oauth2-2legged__eyebrow">STEP 4</span>
|
||||||
|
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
|
||||||
|
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__step-grid">
|
||||||
|
<div class="oauth2-2legged__panel">
|
||||||
|
<h3 class="oauth2-2legged__panel-title">상태 코드별 처리</h3>
|
||||||
|
<table class="oauth2-2legged__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>반환</th>
|
||||||
|
<th>상황</th>
|
||||||
|
<th>DJBank 처리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><span
|
||||||
|
class="oauth2-2legged__req-badge oauth2-2legged__req-badge--required">2xx</span>
|
||||||
|
</td>
|
||||||
|
<td>검증 성공 + 정상 접수</td>
|
||||||
|
<td><strong>발송 성공</strong> 기록. 재시도 없음</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>400</code></td>
|
||||||
|
<td>필수 헤더 누락</td>
|
||||||
|
<td>실패 기록</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>401</code></td>
|
||||||
|
<td>서명 불일치 / timestamp 만료</td>
|
||||||
|
<td>실패 기록</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>5xx</code> · 타임아웃</td>
|
||||||
|
<td>수신 서버 일시 장애</td>
|
||||||
|
<td>실패 기록 + 재시도</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="oauth2-2legged__warning">⚠ 5xx 응답·타임아웃과 네트워크 오류는 <strong>일정 간격을 두고
|
||||||
|
재시도</strong>됩니다(기본 3회). 재시도로 인한 중복 수신은 <code>eventId</code> 멱등 처리로 방어하세요.</p>
|
||||||
|
>>>>>>> 9981459691c836bfd33b6b81a8e6aa22ca12446a
|
||||||
|
|
||||||
|
<h4 class="oauth2-2legged__panel-subtitle">응답 가이드</h4>
|
||||||
|
<ul class="oauth2-2legged__tips">
|
||||||
|
<li>검증 통과 시 <strong>즉시 200 OK</strong> 반환 — 무거운 후속 처리는 비동기로 분리</li>
|
||||||
|
<li>응답 본문 규격은 자유(발송 로그에 기록만 됨) — 간단한 JSON 권장</li>
|
||||||
|
<li>서명 검증 실패는 <code>401</code>, 필수 헤더 누락은 <code>400</code> 반환 권장</li>
|
||||||
|
<li>동일 <code>eventId</code> 재수신 시 재처리 없이 200 반환(멱등)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__code-panel">
|
||||||
|
<span class="oauth2-2legged__code-tag oauth2-2legged__code-tag--ok">200 OK · JSON
|
||||||
|
(권장)</span>
|
||||||
|
<pre class="oauth2-2legged__code-block"><span class="o2leg-cm"># 정상 접수</span>
|
||||||
|
HTTP/1.1 <span class="o2leg-g">200 OK</span>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"OK"</span>,
|
||||||
|
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CONTROL_START"</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="o2leg-cm"># 서명 검증 실패</span>
|
||||||
|
HTTP/1.1 <span class="o2leg-g">401 Unauthorized</span>
|
||||||
|
{
|
||||||
|
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
|
||||||
|
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"서명 검증에 실패하였습니다."</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="o2leg-cm"># 필수 헤더 누락</span>
|
||||||
|
HTTP/1.1 <span class="o2leg-g">400 Bad Request</span>
|
||||||
|
{
|
||||||
|
<span class="o2leg-c">"result"</span>: <span class="o2leg-y">"ERROR"</span>,
|
||||||
|
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"필수 헤더가 누락되었습니다."</span>
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 7: 검증 실패 대표 원인 -->
|
||||||
|
<section class="oauth2-2legged__errors" aria-labelledby="whsig-errors-title">
|
||||||
|
<span class="oauth2-2legged__eyebrow">TROUBLESHOOTING</span>
|
||||||
|
<h2 class="oauth2-2legged__h2" id="whsig-errors-title">서명 불일치 대표 원인</h2>
|
||||||
|
|
||||||
|
<div class="oauth2-2legged__panel">
|
||||||
|
<table class="oauth2-2legged__table oauth2-2legged__table--errors">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>원인</th>
|
||||||
|
<th>증상</th>
|
||||||
|
<th>해결 가이드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>본문 재직렬화</td>
|
||||||
|
<td>JSON 파싱 후 다시 문자열화한 값으로 서명 계산</td>
|
||||||
|
<td>파싱 전 raw body(bytes)로 계산</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>hex 대소문자</td>
|
||||||
|
<td>대문자 hex로 비교해 불일치</td>
|
||||||
|
<td>소문자 hex 사용</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>접두어 처리</td>
|
||||||
|
<td><code>sha256=</code> 접두 포함/제외 불일치</td>
|
||||||
|
<td>양쪽 모두 접두 포함 후 비교</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>인코딩</td>
|
||||||
|
<td>Secret/본문을 UTF-8 외로 인코딩</td>
|
||||||
|
<td>키·메시지 모두 UTF-8 bytes</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Secret 불일치</td>
|
||||||
|
<td>재발급 후 이전 Secret 사용</td>
|
||||||
|
<td>최신 Secret으로 교체</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>재전송</td>
|
||||||
|
<td>동일 이벤트 중복 수신</td>
|
||||||
|
<td>timestamp 검사 + eventId 멱등 처리</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 8: CTA -->
|
||||||
|
<a class="oauth2-2legged__cta" th:href="@{/webhook}">
|
||||||
|
<div class="oauth2-2legged__cta-body">
|
||||||
|
<span class="oauth2-2legged__cta-eyebrow">MANAGE</span>
|
||||||
|
<h2 class="oauth2-2legged__cta-title">Webhook 관리로 가기</h2>
|
||||||
|
<p class="oauth2-2legged__cta-desc">수신 URL·Secret·구독 이벤트를 등록하고 관리하세요.</p>
|
||||||
|
<span class="oauth2-2legged__cta-button">Webhook 관리 →</span>
|
||||||
|
</div>
|
||||||
|
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
|
||||||
|
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--sm" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentScript">
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<th:block layout:fragment="contentFragment">
|
<th:block layout:fragment="contentFragment">
|
||||||
<div class="service-main" style="padding-top: 40px;">
|
<div class="container service-main" style="padding-top: 70px;">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('users')}"></th:block>
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('users')}"></th:block>
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<section class="service-content">
|
<section class="service-content">
|
||||||
|
|
||||||
|
|
||||||
<section class="user-management-container" style="padding-top: 0;">
|
<section class="user-management-container">
|
||||||
|
|
||||||
<!-- App List Title Section -->
|
<!-- App List Title Section -->
|
||||||
<div class="table-controls">
|
<div class="table-controls">
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<div class="row-cell" data-label="계정상태" th:text="${user.userStatus.getDescription()}">
|
<div class="row-cell" data-label="계정상태" th:text="${user.userStatus.getDescription()}">
|
||||||
활성
|
활성
|
||||||
</div>
|
</div>
|
||||||
<div class="row-cell row-actions">
|
<div class="row-cell row-actions" th:classappend="${user.roleCode != null and user.id != currentUserId} ? 'has-actions'">
|
||||||
<!-- Current User -->
|
<!-- Current User -->
|
||||||
<span class="user-current-badge" th:if="${user.id == currentUserId}">
|
<span class="user-current-badge" th:if="${user.id == currentUserId}">
|
||||||
<i class="fas fa-user-check"></i> 현재 사용자
|
<i class="fas fa-user-check"></i> 현재 사용자
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
|
<div class="service-main app-management-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="app-management-content">
|
||||||
|
<div class="step1-wrap">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="s1-title">Webhook 관리</h2>
|
||||||
|
|
||||||
|
<!-- Empty Card -->
|
||||||
|
<div class="s1-form-card">
|
||||||
|
<div class="webhook-empty">
|
||||||
|
<div class="empty-icon">🔔</div>
|
||||||
|
<h3>등록된 Webhook이 없습니다</h3>
|
||||||
|
<p>API 서비스의 점검·지연·장애 알림을 받을 Webhook을 신청해보세요.</p>
|
||||||
|
|
||||||
|
<p class="field-help" sec:authorize="!hasRole('ROLE_API_KEY_REQUEST')">
|
||||||
|
Webhook 신청은 법인 관리자만 가능합니다. 기관 관리자에게 문의하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 액션 -->
|
||||||
|
<div class="s1-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
|
||||||
|
<button type="button" class="s1-btn-next" id="requestWebhook">Webhook 신청</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="webhook-guide-link">
|
||||||
|
<a th:href="@{/service/webhook-dev-guide}">
|
||||||
|
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div><!-- /step1-wrap -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentScript">
|
||||||
|
<script th:inline="javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var btn = document.getElementById('requestWebhook');
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
window.location.href = '/webhook/register/step1?clear=true';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
|
<div class="service-main app-management-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="app-management-content">
|
||||||
|
<div class="step1-wrap">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="s1-title">Webhook 관리</h2>
|
||||||
|
|
||||||
|
<!-- Registered Webhook Card -->
|
||||||
|
<div class="s1-form-card" th:object="${webhook}">
|
||||||
|
|
||||||
|
<div class="webhook-card-head">
|
||||||
|
<h3>등록된 Webhook</h3>
|
||||||
|
<span class="webhook-created" th:if="*{createdDate != null and #strings.length(createdDate) >= 8}"
|
||||||
|
th:text="|등록일 ${#strings.substring(webhook.createdDate,0,4)}-${#strings.substring(webhook.createdDate,4,6)}-${#strings.substring(webhook.createdDate,6,8)}|">등록일</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-card-row">
|
||||||
|
<span class="row-label">수신 URL</span>
|
||||||
|
<span class="row-value" th:text="*{targetUrl}">https://...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-card-row">
|
||||||
|
<span class="row-label">알림 이벤트</span>
|
||||||
|
<span class="row-value">
|
||||||
|
<span class="eventtype-badge" th:each="et : *{eventTypes}" th:text="${et.name}">이벤트</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-card-row">
|
||||||
|
<span class="row-label">대상 API</span>
|
||||||
|
<span class="row-value">
|
||||||
|
<span class="api-badge" th:each="apiId : *{apiIds}" th:text="${apiId}">API</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-card-row">
|
||||||
|
<span class="row-label">Secret Key</span>
|
||||||
|
<span class="row-value secret-row">
|
||||||
|
<code id="secretMasked" th:text="*{secretMasked}">••••••••</code>
|
||||||
|
<th:block sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
|
||||||
|
<button type="button" class="btn-mini" id="btnRevealSecret">조회</button>
|
||||||
|
<button type="button" class="btn-mini" id="btnRegenSecret">재발급</button>
|
||||||
|
</th:block>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 액션 -->
|
||||||
|
<div class="s1-actions" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">
|
||||||
|
<button type="button" class="btn-webhook-danger" id="btnDeleteWebhook">삭제</button>
|
||||||
|
<a class="s1-btn-next" th:href="@{/webhook/modify/step1}">수정</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="webhook-guide-link">
|
||||||
|
<a th:href="@{/service/webhook-dev-guide}">
|
||||||
|
📘 웹훅 개발가이드 — 수신·서명검증·응답 규칙 보러가기
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div><!-- /step1-wrap -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 비밀번호 재인증 모달 -->
|
||||||
|
<div class="webhook-modal" id="pwModal" style="display:none;">
|
||||||
|
<div class="webhook-modal-box">
|
||||||
|
<h4 id="pwModalTitle">비밀번호 확인</h4>
|
||||||
|
<p id="pwModalDesc">계속하려면 비밀번호를 입력하세요.</p>
|
||||||
|
<input type="password" id="pwModalInput" placeholder="비밀번호" autocomplete="current-password">
|
||||||
|
<p class="field-error" id="pwModalError" style="display:none;"></p>
|
||||||
|
<div class="webhook-modal-actions">
|
||||||
|
<button type="button" class="btn-webhook-cancel" id="pwModalCancel">취소</button>
|
||||||
|
<button type="button" class="btn-webhook-next" id="pwModalConfirm">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentScript">
|
||||||
|
<script th:inline="javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var csrfToken = document.querySelector('meta[name="_csrf"]');
|
||||||
|
var csrfHeaderMeta = document.querySelector('meta[name="_csrf_header"]');
|
||||||
|
var CSRF_TOKEN = csrfToken ? csrfToken.getAttribute('content') : '';
|
||||||
|
var CSRF_HEADER = csrfHeaderMeta ? csrfHeaderMeta.getAttribute('content') : 'X-XSRF-TOKEN';
|
||||||
|
|
||||||
|
var modal = document.getElementById('pwModal');
|
||||||
|
var input = document.getElementById('pwModalInput');
|
||||||
|
var errorEl = document.getElementById('pwModalError');
|
||||||
|
var titleEl = document.getElementById('pwModalTitle');
|
||||||
|
var descEl = document.getElementById('pwModalDesc');
|
||||||
|
var pendingAction = null;
|
||||||
|
|
||||||
|
function openModal(title, desc, action) {
|
||||||
|
titleEl.textContent = title;
|
||||||
|
descEl.textContent = desc;
|
||||||
|
input.value = '';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
pendingAction = action;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
function closeModal() { modal.style.display = 'none'; pendingAction = null; }
|
||||||
|
|
||||||
|
document.getElementById('pwModalCancel').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('pwModalConfirm').addEventListener('click', function () {
|
||||||
|
if (pendingAction) { pendingAction(input.value); }
|
||||||
|
});
|
||||||
|
input.addEventListener('keyup', function (e) {
|
||||||
|
if (e.key === 'Enter' && pendingAction) { pendingAction(input.value); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function post(url, password) {
|
||||||
|
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||||
|
headers[CSRF_HEADER] = CSRF_TOKEN;
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: 'password=' + encodeURIComponent(password)
|
||||||
|
}).then(function (r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||||
|
|
||||||
|
// Secret 조회
|
||||||
|
var btnReveal = document.getElementById('btnRevealSecret');
|
||||||
|
if (btnReveal) btnReveal.addEventListener('click', function () {
|
||||||
|
openModal('Secret 조회', '비밀번호 확인 후 Secret Key를 표시합니다.', function (pw) {
|
||||||
|
post('/webhook/verify-secret', pw).then(function (res) {
|
||||||
|
if (res.success) {
|
||||||
|
document.getElementById('secretMasked').textContent = res.secret;
|
||||||
|
closeModal();
|
||||||
|
} else { showError(res.message || '실패했습니다.'); }
|
||||||
|
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Secret 재발급
|
||||||
|
var btnRegen = document.getElementById('btnRegenSecret');
|
||||||
|
if (btnRegen) btnRegen.addEventListener('click', function () {
|
||||||
|
openModal('Secret 재발급',
|
||||||
|
'재발급 시 기존 Secret은 즉시 무효화됩니다. 새 Secret을 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
|
||||||
|
function (pw) {
|
||||||
|
post('/webhook/regenerate-secret', pw).then(function (res) {
|
||||||
|
if (res.success) {
|
||||||
|
document.getElementById('secretMasked').textContent = res.secret;
|
||||||
|
closeModal();
|
||||||
|
alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
|
||||||
|
} else { showError(res.message || '실패했습니다.'); }
|
||||||
|
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
var btnDelete = document.getElementById('btnDeleteWebhook');
|
||||||
|
if (btnDelete) btnDelete.addEventListener('click', function () {
|
||||||
|
openModal('Webhook 삭제', '삭제하면 복구할 수 없습니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
|
||||||
|
post('/webhook/delete', pw).then(function (res) {
|
||||||
|
if (res.success) { window.location.href = '/webhook'; }
|
||||||
|
else { showError(res.message || '실패했습니다.'); }
|
||||||
|
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
|
<div class="service-main app-management-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="app-management-content">
|
||||||
|
<div class="step1-wrap">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="s1-title">Webhook 수정</h2>
|
||||||
|
|
||||||
|
<!-- Progress Steps Card -->
|
||||||
|
<div class="s1-progress-card">
|
||||||
|
<div class="s1-steps">
|
||||||
|
<!-- Step 1 Active: 기본 정보 수정 -->
|
||||||
|
<div class="s1-step s1-step--active">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">1단계</span>
|
||||||
|
<span class="s1-step-name">기본 정보 수정</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 2: API 선택 -->
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">2단계</span>
|
||||||
|
<span class="s1-step-name">API 선택</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 3: 수정 완료 -->
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">3단계</span>
|
||||||
|
<span class="s1-step-name">수정 완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div th:if="${error}" class="s1-alert">
|
||||||
|
<span th:text="${error}"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Card -->
|
||||||
|
<div class="s1-form-card">
|
||||||
|
<form id="webhookStep1Form" method="post" th:action="@{/webhook/modify/step1}"
|
||||||
|
th:object="${webhookModification}">
|
||||||
|
|
||||||
|
<!-- Webhook 수신 URL -->
|
||||||
|
<div class="s1-field">
|
||||||
|
<label class="s1-label">Webhook 수신 URL <span class="s1-required">*</span></label>
|
||||||
|
<input type="text" id="targetUrl" th:field="*{targetUrl}" class="s1-input"
|
||||||
|
placeholder="https://example.com/webhook" autocomplete="off" maxlength="255">
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
|
||||||
|
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 알림 이벤트 -->
|
||||||
|
<div class="s1-field">
|
||||||
|
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
|
||||||
|
<div class="webhook-eventtype-list">
|
||||||
|
<label class="eventtype-item" th:each="et : ${eventTypes}">
|
||||||
|
<input type="checkbox" name="eventTypes" th:value="${et.code}"
|
||||||
|
th:checked="${#lists.contains(webhookModification.eventTypes, et.code)}">
|
||||||
|
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
|
||||||
|
<span class="eventtype-code" th:text="${et.code}">CODE</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 액션 -->
|
||||||
|
<div class="s1-actions">
|
||||||
|
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/cancel}">취소</a>
|
||||||
|
<button type="submit" form="webhookStep1Form" class="s1-btn-next">다음</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /step1-wrap -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
|
<div class="service-main app-management-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="app-management-content">
|
||||||
|
<div class="step2-wrap">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="s1-title">Webhook 수정</h2>
|
||||||
|
|
||||||
|
<!-- Progress Steps Card -->
|
||||||
|
<div class="s1-progress-card">
|
||||||
|
<div class="s1-steps">
|
||||||
|
<!-- Step 1: 기본 정보 수정 -->
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">1단계</span>
|
||||||
|
<span class="s1-step-name">기본 정보 수정</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 2 Active: API 선택 -->
|
||||||
|
<div class="s1-step s1-step--active">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">2단계</span>
|
||||||
|
<span class="s1-step-name">API 선택</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 3: 수정 완료 -->
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">3단계</span>
|
||||||
|
<span class="s1-step-name">수정 완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div th:if="${error}" class="s1-alert">
|
||||||
|
<span th:text="${error}"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API 선택 공용 모듈 -->
|
||||||
|
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookModification.selectedApis}, '/webhook/modify/step2', '/webhook/modify/step2/save')}"/>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation Actions -->
|
||||||
|
<div class="s2-actions">
|
||||||
|
<button type="button" id="btnPrevStep" class="s2-btn-prev">
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
|
||||||
|
수정 완료
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /step2-wrap -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 플로팅 카트/모달: body 직속 렌더 (wrapper transform 영향 회피) -->
|
||||||
|
<section layout:fragment="pagePopups">
|
||||||
|
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
|
<div class="service-main app-management-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="app-management-content">
|
||||||
|
<div class="step3-wrap">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="s1-title">Webhook 수정</h2>
|
||||||
|
|
||||||
|
<!-- Progress Steps Card -->
|
||||||
|
<div class="s1-progress-card">
|
||||||
|
<div class="s1-steps">
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 수정" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">1단계</span>
|
||||||
|
<span class="s1-step-name">기본 정보 수정</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">2단계</span>
|
||||||
|
<span class="s1-step-name">API 선택</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 3 Active: 수정 완료 -->
|
||||||
|
<div class="s1-step s1-step--active">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step3.png}" alt="수정 완료" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">3단계</span>
|
||||||
|
<span class="s1-step-name">수정 완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complete Card -->
|
||||||
|
<div class="s1-form-card">
|
||||||
|
<div class="webhook-complete">
|
||||||
|
<div class="complete-icon">✅</div>
|
||||||
|
<h3>Webhook 설정이 수정되었습니다</h3>
|
||||||
|
<p class="field-help">Secret Key는 변경되지 않았습니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 액션 -->
|
||||||
|
<div class="s1-actions">
|
||||||
|
<a class="s1-btn-next" th:href="@{/webhook}">완료</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /step3-wrap -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
|
<div class="service-main app-management-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="app-management-content">
|
||||||
|
<div class="step1-wrap">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="s1-title">Webhook 신청</h2>
|
||||||
|
|
||||||
|
<!-- Progress Steps Card -->
|
||||||
|
<div class="s1-progress-card">
|
||||||
|
<div class="s1-steps">
|
||||||
|
<!-- Step 1 Active: 기본 정보 입력 -->
|
||||||
|
<div class="s1-step s1-step--active">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">1단계</span>
|
||||||
|
<span class="s1-step-name">기본 정보 입력</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 2: API 선택 -->
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">2단계</span>
|
||||||
|
<span class="s1-step-name">API 선택</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 3: 신청 완료 -->
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">3단계</span>
|
||||||
|
<span class="s1-step-name">신청 완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div th:if="${error}" class="s1-alert">
|
||||||
|
<span th:text="${error}"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Card -->
|
||||||
|
<div class="s1-form-card">
|
||||||
|
<form id="webhookStep1Form" method="post" th:action="@{/webhook/register/step1}"
|
||||||
|
th:object="${webhookRegistration}">
|
||||||
|
|
||||||
|
<!-- Webhook 수신 URL -->
|
||||||
|
<div class="s1-field">
|
||||||
|
<label class="s1-label">Webhook 수신 URL <span class="s1-required">*</span></label>
|
||||||
|
<input type="text" id="targetUrl" th:field="*{targetUrl}" class="s1-input"
|
||||||
|
placeholder="https://example.com/webhook" autocomplete="off" maxlength="255">
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
|
||||||
|
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 알림 이벤트 -->
|
||||||
|
<div class="s1-field">
|
||||||
|
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
|
||||||
|
<div class="webhook-eventtype-list">
|
||||||
|
<label class="eventtype-item" th:each="et : ${eventTypes}">
|
||||||
|
<input type="checkbox" name="eventTypes" th:value="${et.code}"
|
||||||
|
th:checked="${#lists.contains(webhookRegistration.eventTypes, et.code)}">
|
||||||
|
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
|
||||||
|
<span class="eventtype-code" th:text="${et.code}">CODE</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 액션 -->
|
||||||
|
<div class="s1-actions">
|
||||||
|
<a class="btn-webhook-cancel" th:href="@{/webhook/register/cancel}">취소</a>
|
||||||
|
<button type="submit" form="webhookStep1Form" class="s1-btn-next">다음</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /step1-wrap -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
|
<div class="service-main app-management-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="app-management-content">
|
||||||
|
<div class="step2-wrap">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="s1-title">Webhook 신청</h2>
|
||||||
|
|
||||||
|
<!-- Progress Steps Card -->
|
||||||
|
<div class="s1-progress-card">
|
||||||
|
<div class="s1-steps">
|
||||||
|
<!-- Step 1: 기본 정보 입력 -->
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">1단계</span>
|
||||||
|
<span class="s1-step-name">기본 정보 입력</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 2 Active: API 선택 -->
|
||||||
|
<div class="s1-step s1-step--active">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">2단계</span>
|
||||||
|
<span class="s1-step-name">API 선택</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 3: 신청 완료 -->
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">3단계</span>
|
||||||
|
<span class="s1-step-name">신청 완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div th:if="${error}" class="s1-alert">
|
||||||
|
<span th:text="${error}"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API 선택 공용 모듈 -->
|
||||||
|
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookRegistration.selectedApis}, '/webhook/register/step2', '/webhook/register/step2/save')}"/>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation Actions -->
|
||||||
|
<div class="s2-actions">
|
||||||
|
<button type="button" id="btnPrevStep" class="s2-btn-prev">
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<button type="submit" form="apiSelectorForm" class="s2-btn-save">
|
||||||
|
신청 완료
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /step2-wrap -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 플로팅 카트/모달: body 직속 렌더 (wrapper transform 영향 회피) -->
|
||||||
|
<section layout:fragment="pagePopups">
|
||||||
|
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<div class="signup-guide-v2 figma-register-wrapper">
|
||||||
|
<div class="service-main app-management-layout">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('webhook')}"></th:block>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="app-management-content">
|
||||||
|
<div class="step3-wrap">
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="s1-title">Webhook 신청</h2>
|
||||||
|
|
||||||
|
<!-- Progress Steps Card -->
|
||||||
|
<div class="s1-progress-card">
|
||||||
|
<div class="s1-steps">
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step1.png}" alt="기본 정보 입력" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">1단계</span>
|
||||||
|
<span class="s1-step-name">기본 정보 입력</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<div class="s1-step">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step2.png}" alt="API 선택" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">2단계</span>
|
||||||
|
<span class="s1-step-name">API 선택</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s1-step-line"></div>
|
||||||
|
|
||||||
|
<!-- Step 3 Active: 신청 완료 -->
|
||||||
|
<div class="s1-step s1-step--active">
|
||||||
|
<div class="s1-step-circle">
|
||||||
|
<img th:src="@{/img/apikey_step3.png}" alt="신청 완료" width="36" height="36">
|
||||||
|
</div>
|
||||||
|
<span class="s1-step-num">3단계</span>
|
||||||
|
<span class="s1-step-name">신청 완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complete Card -->
|
||||||
|
<div class="s1-form-card">
|
||||||
|
<div class="webhook-complete">
|
||||||
|
<div class="complete-icon">✅</div>
|
||||||
|
<h3>Webhook이 등록되었습니다</h3>
|
||||||
|
<p class="field-help">Secret Key는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 액션 -->
|
||||||
|
<div class="s1-actions">
|
||||||
|
<a class="s1-btn-next" th:href="@{/webhook}">완료</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /step3-wrap -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
color: #212529;
|
color: #212529;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-buttons {
|
.error-buttons {
|
||||||
@@ -246,7 +247,7 @@
|
|||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<!-- Error Icon -->
|
<!-- Error Icon -->
|
||||||
<div class="error-icon">
|
<div class="error-icon">
|
||||||
<!-- 3D Speech Bubble Error Icon - Base64 encoded or use th:src for server image -->
|
<!--/* 3D Speech Bubble Error Icon - Base64 encoded or use th:src for server image */-->
|
||||||
<img th:src="@{/img/error_3d.png}" alt="에러">
|
<img th:src="@{/img/error_3d.png}" alt="에러">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<body>
|
||||||
|
<!--/*
|
||||||
|
API 선택 공용 모듈 (figma s2 디자인: 카테고리 캐러셀 탭 + 카드그리드 + 플로팅 카트 + 선택목록 모달)
|
||||||
|
|
||||||
|
사용처: 앱(API Key) 신청/수정 step2, Webhook 신청/수정 step2
|
||||||
|
파라미터:
|
||||||
|
apiServices : List<ApiServiceDTO> — 캐러셀 카테고리 (컨트롤러 model "apiServices")
|
||||||
|
selectedApis: List<String> — 세션에 보존된 기선택 API ID 목록
|
||||||
|
formAction : String — 제출(POST) 경로 (예: '/webhook/register/step2')
|
||||||
|
saveAction : String — "이전" 버튼 저장(POST) 경로 (예: '/webhook/register/step2/save')
|
||||||
|
|
||||||
|
호출 (contentFragment 안):
|
||||||
|
<th:block th:replace="~{fragment/api_selector :: apiSelector(${apiServices}, ${webhookRegistration.selectedApis}, '/webhook/register/step2', '/webhook/register/step2/save')}"/>
|
||||||
|
|
||||||
|
호출 (pagePopups fragment 안 — 필수! 플로팅 카트/모달은 wrapper transform 영향을 피해 body 직속 렌더):
|
||||||
|
<section layout:fragment="pagePopups">
|
||||||
|
<th:block th:replace="~{fragment/api_selector :: apiSelectorPopups}"/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
계약:
|
||||||
|
- 폼 id 고정 "apiSelectorForm" — 제출 버튼은 form="apiSelectorForm" 으로 연결.
|
||||||
|
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
|
||||||
|
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
|
||||||
|
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
|
||||||
|
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
|
||||||
|
*/-->
|
||||||
|
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
|
||||||
|
|
||||||
|
<!-- Category Carousel Tab Container -->
|
||||||
|
<div class="s2-category-carousel-container">
|
||||||
|
<button type="button" class="s2-carousel-btn s2-carousel-btn--prev" id="btnPrevCategory" aria-label="이전 카테고리">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="s2-category-list-wrapper" id="categoryListWrapper">
|
||||||
|
<div class="s2-category-list" id="categoryList">
|
||||||
|
<button type="button" class="s2-category-tab active" data-group="">
|
||||||
|
전체
|
||||||
|
</button>
|
||||||
|
<button type="button" class="s2-category-tab" th:each="service : ${apiServices}" th:data-group="${service.id}" th:text="${service.groupName}">
|
||||||
|
서비스명
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="s2-carousel-btn s2-carousel-btn--next" id="btnNextCategory" aria-label="다음 카테고리">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="s2-selection-container">
|
||||||
|
<main class="s2-content-area">
|
||||||
|
<form id="apiSelectorForm" method="post" th:action="@{${formAction}}" class="s2-form"
|
||||||
|
th:attr="data-save-action=@{${saveAction}}">
|
||||||
|
|
||||||
|
<!-- Header filter: Search and Select All -->
|
||||||
|
<div class="s2-filter-header">
|
||||||
|
<div class="s2-select-all" id="selectAllWrapper" style="display: none;">
|
||||||
|
<label class="s2-select-all-label">
|
||||||
|
<input type="checkbox" id="selectAllCheckbox" class="visually-hidden">
|
||||||
|
<span class="s2-checkbox-custom"></span>
|
||||||
|
<span id="selectAllText">전체 선택</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="s2-result-count">
|
||||||
|
총 <strong id="apiResultCount">0</strong>건
|
||||||
|
</div>
|
||||||
|
<div class="s2-search-box">
|
||||||
|
<input type="text" id="apiSearch" class="s2-search-input" placeholder="API 검색...">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.23047 0C14.3285 0 18.4618 4.0539 18.4619 9.05469C18.4619 11.1083 17.7634 13.0014 16.5889 14.5205C16.5913 14.5229 16.5942 14.5249 16.5967 14.5273L19.5498 17.4238C20.1502 18.0131 20.1501 18.9683 19.5498 19.5576C18.949 20.147 17.9748 20.147 17.374 19.5576L14.4209 16.6621C14.3966 16.6383 14.3749 16.6119 14.3525 16.5869C12.8868 17.5478 11.1257 18.1094 9.23047 18.1094C4.13258 18.1092 0 14.0554 0 9.05469C0.000110268 4.05404 4.13265 0.000222701 9.23047 0ZM9.23047 3.01855C5.83201 3.01878 3.07726 5.721 3.07715 9.05469C3.07715 12.3885 5.83194 15.0916 9.23047 15.0918C12.6292 15.0918 15.3848 12.3886 15.3848 9.05469C15.3847 5.72086 12.6291 3.01855 9.23047 3.01855Z" fill="#515961"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Cards Grid -->
|
||||||
|
<div class="s2-cards-grid" id="apiCardGrid">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div class="s2-loading" id="loadingState" style="grid-column: 1 / -1;">
|
||||||
|
<div class="s2-spinner"></div>
|
||||||
|
<p>API 목록을 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="s2-empty" id="emptyState" style="display: none; grid-column: 1 / -1;">
|
||||||
|
<div class="s2-empty-icon">📦</div>
|
||||||
|
<h3>API를 선택해주세요</h3>
|
||||||
|
<p>상단 카테고리에서 서비스를 선택하면 해당 API 목록이 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 모듈 초기 데이터 + 스크립트 -->
|
||||||
|
<script th:inline="javascript">
|
||||||
|
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
|
||||||
|
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
|
||||||
|
</script>
|
||||||
|
<script th:src="@{/js/api-selector.js}"></script>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<!-- 플로팅 카트 + 선택목록 모달 (pagePopups 슬롯에서 호출 — body 직속 렌더) -->
|
||||||
|
<th:block th:fragment="apiSelectorPopups">
|
||||||
|
<!-- Floating Cart Button -->
|
||||||
|
<button type="button" class="s2-floating-cart" id="floatingCartBtn" style="display: none;">
|
||||||
|
<span class="s2-cart-label">선택된 API</span>
|
||||||
|
<span class="s2-cart-badge" id="cartBadge">
|
||||||
|
<span class="s2-cart-count">0</span>
|
||||||
|
<span class="s2-cart-unit">개</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Selected APIs Modal -->
|
||||||
|
<div class="s2-modal" id="selectedApisModal" style="display: none;">
|
||||||
|
<div class="s2-modal-backdrop" id="modalOverlay"></div>
|
||||||
|
<div class="s2-modal-dialog">
|
||||||
|
<div class="s2-modal-header">
|
||||||
|
<h3 class="s2-modal-title">선택된 API 목록</h3>
|
||||||
|
<button type="button" class="s2-modal-close" id="modalCloseBtn">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="s2-modal-body">
|
||||||
|
<div class="s2-selected-list" id="modalSelectedList">
|
||||||
|
<!-- Dynamically populated -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="s2-modal-footer">
|
||||||
|
<button type="button" class="s2-btn-close-modal" id="modalCancelBtn">닫기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
<p class="pop_text l_text2">신청사유</p>
|
<p class="pop_text l_text2">신청사유</p>
|
||||||
<div class="pop_textbox">
|
<div class="pop_textbox">
|
||||||
<textarea name="reason" rows="5" cols="30" class="common_textareaType_1" placeholder="신청사유를 입력해 주세요."></textarea>
|
<textarea name="reason" rows="5" cols="30" class="common_textareaType_1" placeholder="신청사유를 입력해 주세요."></textarea>
|
||||||
<!-- <span>158/1,000 byte</span> -->
|
<!--/* <span>158/1,000 byte</span> */-->
|
||||||
</div>
|
</div>
|
||||||
<div class="pop_btnbox">
|
<div class="pop_btnbox">
|
||||||
<button class="popup_button_gray btn_cancel">취소</button>
|
<button class="popup_button_gray btn_cancel">취소</button>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
|
||||||
|
|
||||||
<aside layout:fragment="apiAside" class="lnb">
|
|
||||||
<nav th:with="services=${@apiServiceService.searchApiGroupsForLnb()}">
|
|
||||||
<ul class="lnb_list_type">
|
|
||||||
<li>
|
|
||||||
<a href="#none">공통안내</a>
|
|
||||||
<ul class="lnb_list_nav">
|
|
||||||
<li> <a th:href="@{/apis/common}">API 개발 공통</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U3}">가상계좌 응답 코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U4}">가상계좌 배치 설계</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U5}">가상계좌 VAN사 코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U6}">펌뱅킹 응답코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U7}">펌뱅킹 배치 설계</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U8}">대출금리 응답코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U9}">케이뱅크 페이 응답코드</a></li>
|
|
||||||
<li> <a th:href="@{/apis/KAPAP004U10}">케이뱅크 페이 복합과세 예제</a></li>
|
|
||||||
<li> <a th:href="@{/apis/token-spec}">케이뱅크 OAuth 2.0 토큰 발급</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li th:each="apiService : ${services}">
|
|
||||||
<a href="#none" th:text="${apiService.groupName}">API Group Name</a>
|
|
||||||
<ul class="lnb_list_nav">
|
|
||||||
<li th:each="api : ${apiService.apiGroupApiList}">
|
|
||||||
<a th:href="@{/apis/detail(id=${api.apiId})}" th:text="${api.apiDesc}">API Description</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||||
|
|
||||||
<head th:fragment="headFragment">
|
<head th:fragment="headFragment">
|
||||||
<title th:text="#{title.html}">개발자 포털</title>
|
<title th:text="#{title.html}">개발자 포털</title>
|
||||||
@@ -27,6 +28,23 @@
|
|||||||
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
<!-- CSRF 토큰 (세션 기반). 정적 JS/AJAX에서 토큰·헤더명을 읽어 사용한다. -->
|
||||||
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
<meta name="_csrf" th:content="${_csrf != null ? _csrf.token : ''}"/>
|
||||||
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
<meta name="_csrf_header" th:content="${_csrf != null ? _csrf.headerName : 'X-XSRF-TOKEN'}"/>
|
||||||
|
|
||||||
|
<!-- 익명(비로그인) 세션 keepalive: 로그인/회원가입 등에서 페이지에 머무는 동안 주기적 ping으로
|
||||||
|
세션 비활성 타이머를 리셋해 세션 저장 CSRF 토큰·회원가입 본인인증 상태의 만료(10분)를 방지한다.
|
||||||
|
탭을 닫으면 ping이 멈춰 정상 만료. 인증 사용자는 헤더의 세션 타이머/heartbeat가 대신 처리한다. -->
|
||||||
|
<script sec:authorize="isAnonymous()" th:inline="javascript">
|
||||||
|
(function () {
|
||||||
|
var PING_URL = /*[[@{/api/session/ping}]]*/ '/api/session/ping';
|
||||||
|
var PING_INTERVAL_MS = 4 * 60 * 1000; // 세션 타임아웃(10분)의 절반 이하
|
||||||
|
setInterval(function () {
|
||||||
|
try {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', PING_URL, true);
|
||||||
|
xhr.send();
|
||||||
|
} catch (ignore) { /* keepalive 실패는 화면 동작에 영향 없음 */ }
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
|
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
|
||||||
<meta content="index, follow" name="robots"/>
|
<meta content="index, follow" name="robots"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||||
@@ -100,9 +118,9 @@
|
|||||||
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
|
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
|
||||||
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
|
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
|
||||||
|
|
||||||
<!-- LiveReload (개발 전용): DevTools 가 정적 리소스/템플릿 변경을 감지하면 브라우저를 자동 새로고침한다.
|
<!--/* LiveReload (개발 전용): DevTools 가 정적 리소스/템플릿 변경을 감지하면 브라우저를 자동 새로고침한다.
|
||||||
localhost 외 IP(예: 172.30.1.14)로 접속해도 동작하도록 접속 호스트 기준으로 livereload.js 를 로드한다.
|
localhost 외 IP(예: 172.30.1.14)로 접속해도 동작하도록 접속 호스트 기준으로 livereload.js 를 로드한다.
|
||||||
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. -->
|
prod/stage 에는 DevTools 자체가 없으므로(developmentOnly) 개발 프로파일에서만 주입한다. */-->
|
||||||
<script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}"
|
<script th:if="${@environment.acceptsProfiles('local_rinjaemac','local')}"
|
||||||
th:src="|//${#request.serverName}:35729/livereload.js|"></script>
|
th:src="|//${#request.serverName}:35729/livereload.js|"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -21,12 +21,12 @@
|
|||||||
<!-- Desktop Header Layout -->
|
<!-- Desktop Header Layout -->
|
||||||
<div class="desktop-header">
|
<div class="desktop-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<!-- <div class="logo-wrapper">-->
|
<!--/* <div class="logo-wrapper">*/-->
|
||||||
<!-- <a th:href="@{/}" class="logo-link">-->
|
<!--/* <a th:href="@{/}" class="logo-link">*/-->
|
||||||
<!-- <img src="/img/logo/logo-djb.png" alt="DJBank" class="logo" width="114" height="32">-->
|
<!--/* <img src="/img/logo/logo-djb.png" alt="DJBank" class="logo" width="114" height="32">*/-->
|
||||||
<!-- </a>-->
|
<!--/* </a>*/-->
|
||||||
<!-- <a th:href="@{/}" class="logo-text">API Portal</a>-->
|
<!--/* <a th:href="@{/}" class="logo-text">API Portal</a>*/-->
|
||||||
<!-- </div>-->
|
<!--/* </div>*/-->
|
||||||
<div >
|
<div >
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<a th:href="@{/}" class="logo-wrapper">
|
<a th:href="@{/}" class="logo-wrapper">
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
|
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
|
||||||
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
|
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
|
||||||
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
|
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
|
||||||
|
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="/apis" class="nav-link">오픈 API</a></li>
|
<li><a href="/apis" class="nav-link">오픈 API</a></li>
|
||||||
@@ -62,30 +63,20 @@
|
|||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
||||||
<!-- Login State (Anonymous) -->
|
<!-- Login State (Anonymous) -->
|
||||||
<!-- <div class="auth-group" sec:authorize="isAnonymous()">-->
|
<!--/* <div class="auth-group" sec:authorize="isAnonymous()">*/-->
|
||||||
|
|
||||||
<!-- <a th:href="@{/login}" class="login-btn login-btn-box">-->
|
<!--/* <a th:href="@{/login}" class="login-btn login-btn-box">*/-->
|
||||||
<!-- <img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">로그인</a>-->
|
<!--/* <img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">로그인</a>*/-->
|
||||||
<!-- </div>-->
|
<!--/* </div>*/-->
|
||||||
|
|
||||||
<div class="auth-group" sec:authorize="isAnonymous()">
|
<div class="auth-group" sec:authorize="isAnonymous()">
|
||||||
<a th:href="@{/login}" class="login-btn login-btn-box">로그인</a>
|
<a th:href="@{/login}" class="login-btn login-btn-box">로그인</a>
|
||||||
<a href="#" class="btn-signup">회원가입</a>
|
<a th:href="@{/signup}" class="btn-signup">회원가입</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logout State (Authenticated) -->
|
<!-- Logout State (Authenticated) -->
|
||||||
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
|
||||||
<div class="header-user-info">
|
<div class="header-user-info">
|
||||||
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
|
|
||||||
<i class="fas fa-clock session-timer-icon"></i>
|
|
||||||
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
|
||||||
</span>
|
|
||||||
<!-- 세션 유지(타임아웃 무시) 체크박스: 비운영 + property 활성 시에만 렌더 (prod 미노출) -->
|
|
||||||
<label class="session-keepalive" th:if="${sessionKeepAliveAllowed}" title="세션 자동 유지 (비운영 전용)">
|
|
||||||
<input type="checkbox" id="sessionKeepAliveToggle">
|
|
||||||
<span>세션 유지</span>
|
|
||||||
</label>
|
|
||||||
<span class="divider">•</span>
|
|
||||||
<div class="user-identity">
|
<div class="user-identity">
|
||||||
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
|
||||||
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
|
||||||
@@ -102,6 +93,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')">
|
<li sec:authorize="hasRole('ROLE_APP')">
|
||||||
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
|
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
|
||||||
|
<a th:href="@{/webhook}" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"><i class="fas fa-bell"></i>Webhook 관리</a>
|
||||||
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
|
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
|
||||||
</li>
|
</li>
|
||||||
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
|
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
|
||||||
@@ -140,6 +132,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 우측 상단 Float 세션 타이머 (인증 사용자, PC 전용). 모바일에서는 CSS로 숨김 -->
|
||||||
|
<div class="session-float" sec:authorize="isAuthenticated()">
|
||||||
|
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
|
||||||
|
<i class="fas fa-clock session-timer-icon"></i>
|
||||||
|
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
|
||||||
|
</span>
|
||||||
|
<!-- 세션 유지(타임아웃 무시) 체크박스: 비운영 + property 활성 시에만 렌더 (prod 미노출) -->
|
||||||
|
<label class="session-keepalive" th:if="${sessionKeepAliveAllowed}" title="세션 자동 유지 (비운영 전용)">
|
||||||
|
<input type="checkbox" id="sessionKeepAliveToggle">
|
||||||
|
<span>세션 유지</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Drawer/Modal -->
|
<!-- Mobile Drawer/Modal -->
|
||||||
<div class="mobile-drawer" id="mobileDrawer">
|
<div class="mobile-drawer" id="mobileDrawer">
|
||||||
<div class="drawer-overlay" id="drawerOverlay"></div>
|
<div class="drawer-overlay" id="drawerOverlay"></div>
|
||||||
@@ -202,6 +207,7 @@
|
|||||||
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
|
<li><a th:href="@{/service/intro}">API 포탈 소개</a></li>
|
||||||
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
|
<li><a th:href="@{/service/guide}">회원가입 안내</a></li>
|
||||||
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
|
<li><a th:href="@{/service/oauth2-guide}">OAuth2 개발가이드</a></li>
|
||||||
|
<li><a th:href="@{/service/webhook-dev-guide}">웹훅 개발가이드</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -244,8 +250,8 @@
|
|||||||
<ul class="drawer-submenu">
|
<ul class="drawer-submenu">
|
||||||
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
|
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
|
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
|
||||||
|
<li sec:authorize="hasRole('ROLE_API_KEY_REQUEST')"><a th:href="@{/webhook}">Webhook 관리</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
|
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/commission/manage}">과금 관리</a></li>
|
|
||||||
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
|
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
|
||||||
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
|
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -278,7 +284,7 @@
|
|||||||
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
|
var FORCE_LOGOUT_URL = /*[[@{/login?forceLogout=true}]]*/ '/login?forceLogout=true';
|
||||||
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
|
var LOGOUT_URL = /*[[@{/actionLogout.do}]]*/ '/actionLogout.do';
|
||||||
|
|
||||||
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 15;
|
var timeoutMinutes = /*[[${sessionTimeoutMinutes}]]*/ 10;
|
||||||
var remainingSeconds = timeoutMinutes * 60;
|
var remainingSeconds = timeoutMinutes * 60;
|
||||||
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
|
var WARNING_SECONDS = 60; // 만료 60초 전 연장 확인 모달
|
||||||
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
|
var POLL_INTERVAL_MS = 30000; // 서버 잔여시간 동기화 주기
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
|
<aside class="service-sidebar" th:fragment="sidebar(activeMenu)">
|
||||||
<nav class="service-nav">
|
<nav class="service-nav">
|
||||||
<!-- 서비스 소개 그룹 (Service) -->
|
<!-- 서비스 소개 그룹 (Service) -->
|
||||||
<th:block th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2'}">
|
<th:block th:if="${activeMenu == 'intro' or activeMenu == 'guide' or activeMenu == 'oauth2' or activeMenu == 'webhookGuide'}">
|
||||||
<a th:href="@{/service/intro}"
|
<a th:href="@{/service/intro}"
|
||||||
th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
|
th:classappend="${activeMenu == 'intro'} ? 'service-nav__item--active' : ''"
|
||||||
class="service-nav__item">API 포탈 소개</a>
|
class="service-nav__item">API 포탈 소개</a>
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
<a th:href="@{/service/oauth2-guide}"
|
<a th:href="@{/service/oauth2-guide}"
|
||||||
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
|
th:classappend="${activeMenu == 'oauth2'} ? 'service-nav__item--active' : ''"
|
||||||
class="service-nav__item">OAuth2 개발가이드</a>
|
class="service-nav__item">OAuth2 개발가이드</a>
|
||||||
|
<a th:href="@{/service/webhook-dev-guide}"
|
||||||
|
th:classappend="${activeMenu == 'webhookGuide'} ? 'service-nav__item--active' : ''"
|
||||||
|
class="service-nav__item">웹훅 개발가이드</a>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<!-- 고객지원 그룹 (Customer Support) -->
|
<!-- 고객지원 그룹 (Customer Support) -->
|
||||||
@@ -34,7 +37,7 @@
|
|||||||
</th:block>
|
</th:block>
|
||||||
|
|
||||||
<!-- 마이페이지 그룹 (My Page) -->
|
<!-- 마이페이지 그룹 (My Page) -->
|
||||||
<th:block th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'profile' or activeMenu == 'password'}">
|
<th:block th:if="${activeMenu == 'users' or activeMenu == 'apiKey' or activeMenu == 'statistics' or activeMenu == 'webhook' or activeMenu == 'profile' or activeMenu == 'password'}">
|
||||||
<!-- Profile Section -->
|
<!-- Profile Section -->
|
||||||
<div class="service-sidebar__profile">
|
<div class="service-sidebar__profile">
|
||||||
<div class="avatar">
|
<div class="avatar">
|
||||||
@@ -56,6 +59,11 @@
|
|||||||
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
|
th:classappend="${activeMenu == 'apiKey'} ? 'service-nav__item--active' : ''"
|
||||||
class="service-nav__item">인증 키 관리</a>
|
class="service-nav__item">인증 키 관리</a>
|
||||||
|
|
||||||
|
<a th:href="@{/webhook}"
|
||||||
|
th:classappend="${activeMenu == 'webhook'} ? 'service-nav__item--active' : ''"
|
||||||
|
class="service-nav__item"
|
||||||
|
sec:authorize="hasRole('ROLE_API_KEY_REQUEST')">Webhook 관리</a>
|
||||||
|
|
||||||
<a th:href="@{/statistics/api}"
|
<a th:href="@{/statistics/api}"
|
||||||
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
|
th:classappend="${activeMenu == 'statistics'} ? 'service-nav__item--active' : ''"
|
||||||
class="service-nav__item"
|
class="service-nav__item"
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
|
||||||
|
|
||||||
<head th:fragment="headFragment">
|
|
||||||
<title th:text="#{title.html}">개발자 포털</title>
|
|
||||||
|
|
||||||
<meta content="https://www.eactive.co.kr/" property="og:url"/>
|
|
||||||
<meta charset="UTF-8"/>
|
|
||||||
<meta content="max-age=0, public" http-equiv="Cache-Control"/>
|
|
||||||
<meta content="index, follow" name="robots"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge">
|
|
||||||
<meta http-equiv="Cache-Control" content="no-cache">
|
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
|
||||||
|
|
||||||
<meta http-equiv="Expires" content="0">
|
|
||||||
<link rel="stylesheet" type="text/css" th:href="@{/css/style2.css}">
|
|
||||||
<link rel="stylesheet" type="text/css" th:href="@{/css/common2.css}">
|
|
||||||
<link rel="stylesheet" type="text/css" th:href="@{/css/slick.css}">
|
|
||||||
<link rel="stylesheet" type="text/css" th:href="@{/css/daterangepicker.css}">
|
|
||||||
|
|
||||||
<link rel="stylesheet" th:href="@{/plugins/codemirror/codemirror.css}" type="text/css"/>
|
|
||||||
<link rel="stylesheet" th:href="@{/plugins/codemirror/theme/monokai.css}" type="text/css"/>
|
|
||||||
<link rel="stylesheet" th:href="@{/plugins/summernote/summernote-lite.css}" type="text/css" />
|
|
||||||
<link rel="stylesheet" th:href="@{/plugins/jquery-ui/jquery-ui.min.css}" />
|
|
||||||
|
|
||||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
|
||||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
|
||||||
<!--[if lt IE 9]>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
|
||||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
|
|
||||||
<![endif]-->
|
|
||||||
|
|
||||||
<script th:src="@{/plugins/jquery/jquery-3.7.1.min.js}"></script>
|
|
||||||
<script th:src="@{/js/lodash.js}"></script>
|
|
||||||
|
|
||||||
<!-- Portal 설정값 전역 노출 -->
|
|
||||||
<script th:inline="javascript">
|
|
||||||
window.PORTAL_CONFIG = {
|
|
||||||
file: {
|
|
||||||
maxSize: /*[[${@portalProperties.file.maxSize}]]*/ '8MB',
|
|
||||||
maxSizeBytes: /*[[${@portalProperties.file.maxSizeBytes}]]*/ 8388608,
|
|
||||||
allowedExtensions: /*[[${@portalProperties.file.allowedExtensions}]]*/ 'pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,gif,jpg,jpeg,png'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script th:src="@{/js/common.js}"></script>
|
|
||||||
<script th:src="@{/js/moment.min.js}"></script>
|
|
||||||
<script th:src="@{/js/daterangepicker.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/codemirror.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/mode/clike.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/mode/javascript.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/addon/display/fullscreen.js}"></script>
|
|
||||||
<script th:src="@{/plugins/codemirror/addon/display/placeholder.js}"></script>
|
|
||||||
<script th:src="@{/plugins/summernote/summernote-lite.min.js}"></script>
|
|
||||||
<script th:src="@{/plugins/summernote/summernote-cleaner.js}"></script>
|
|
||||||
<script th:src="@{/plugins/jquery-ui/jquery-ui.min.js}"></script>
|
|
||||||
<script th:src="@{/js/htmx.min.js}"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
</container-descriptor>
|
</container-descriptor>
|
||||||
|
|
||||||
<session-descriptor>
|
<session-descriptor>
|
||||||
<timeout-secs>1800</timeout-secs>
|
<!-- 세션 타임아웃 10분 고정 (application.yml server.servlet.session.timeout=10m 과 동일 값 유지) -->
|
||||||
|
<timeout-secs>600</timeout-secs>
|
||||||
<cookie-name>JSESSIONID_PORTAL</cookie-name>
|
<cookie-name>JSESSIONID_PORTAL</cookie-name>
|
||||||
<persistent-store-type>replicated_if_clustered</persistent-store-type>
|
<persistent-store-type>replicated_if_clustered</persistent-store-type>
|
||||||
</session-descriptor>
|
</session-descriptor>
|
||||||
|
|||||||
Reference in New Issue
Block a user