Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41be827b81 | |||
| 78898ab5a8 | |||
| 19c17f4e91 | |||
| c2d84bb024 | |||
| 6434d48b05 | |||
| d5f0f3aab4 | |||
| dfc859c9cf | |||
| 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
|
||||
primary key,
|
||||
|
||||
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
|
||||
|
||||
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
||||
|
||||
public PortalApplication() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
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);
|
||||
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 방지)
|
||||
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 헤더가 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
@@ -84,16 +92,15 @@ public class ApiTesterFilter implements Filter {
|
||||
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||
|| url.contains(gatewayProperty.tokenPath());
|
||||
|
||||
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
|
||||
String requestBody = readBody(httpServletRequest);
|
||||
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
|
||||
|
||||
if (tokenRequest) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
String body = sb.toString();
|
||||
String body = requestBody;
|
||||
|
||||
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||
auditType = "TOKEN_MOCK";
|
||||
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
@@ -106,7 +113,7 @@ public class ApiTesterFilter implements Filter {
|
||||
String scope = params.getOrDefault("scope", "default");
|
||||
|
||||
String token = "{\n" +
|
||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
||||
" \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
|
||||
" \"token_type\": \"bearer\",\n" +
|
||||
" \"expires_in\": 86400,\n" +
|
||||
" \"scope\": \""+scope +"\",\n" +
|
||||
@@ -117,6 +124,7 @@ public class ApiTesterFilter implements Filter {
|
||||
response.getWriter().println(token);
|
||||
} else {
|
||||
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||
auditType = "TOKEN_GW";
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||
@@ -133,6 +141,7 @@ public class ApiTesterFilter implements Filter {
|
||||
|
||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||
if (apiSpecInfoDto == null) {
|
||||
auditType = "SPEC_NOT_FOUND";
|
||||
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||
return;
|
||||
@@ -142,6 +151,7 @@ public class ApiTesterFilter implements Filter {
|
||||
|
||||
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||
auditType = "SAMPLE";
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||
return;
|
||||
@@ -151,6 +161,7 @@ public class ApiTesterFilter implements Filter {
|
||||
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||
auditType = gw ? "GW" : "MOCK";
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
@@ -160,6 +171,10 @@ public class ApiTesterFilter implements Filter {
|
||||
}
|
||||
headers.remove("original-url");
|
||||
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;
|
||||
Map<String, String[]> paramMap;
|
||||
@@ -179,7 +194,7 @@ public class ApiTesterFilter implements Filter {
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
String responseStr;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
|
||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
}
|
||||
@@ -202,6 +217,9 @@ public class ApiTesterFilter implements Filter {
|
||||
logger.error("테스트베드 프록시 처리 오류", e);
|
||||
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} finally {
|
||||
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
|
||||
System.currentTimeMillis() - auditStart);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.app.controller;
|
||||
|
||||
import com.eactive.apim.portal.apprequest.entity.AppRequest;
|
||||
import com.eactive.apim.portal.approval.statemachine.InvalidApprovalTransitionException;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
@@ -178,7 +179,6 @@ public class MyAppController {
|
||||
|
||||
model.addAttribute("apiKey", apiKey);
|
||||
model.addAttribute("secretAvailable", secretAvailable);
|
||||
model.addAttribute("authType", "OAuth2");
|
||||
|
||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||
}
|
||||
@@ -217,9 +217,17 @@ public class MyAppController {
|
||||
appServiceFacade.cancelApiRequest(id, SecurityUtil.getPortalAuthenticatedUser().getPortalOrg());
|
||||
result.put("success", true);
|
||||
result.put("message", "신청이 취소되었습니다.");
|
||||
} catch (Exception e) {
|
||||
} catch (InvalidApprovalTransitionException e) {
|
||||
result.put("success", false);
|
||||
result.put("message", "신청 취소 중 오류가 발생했습니다: " + e.getMessage());
|
||||
result.put("message", "내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.");
|
||||
} catch (IllegalStateException e) {
|
||||
log.error("API Key 신청 취소 중 GW 차단 실패. id={}", id, e);
|
||||
result.put("success", false);
|
||||
result.put("message", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("API Key 신청 취소 실패. id={}", id, e);
|
||||
result.put("success", false);
|
||||
result.put("message", "신청 취소 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -59,6 +59,7 @@ public class AppServiceFacade {
|
||||
private final ApiServiceHelper apiServiceHelper;
|
||||
private final FileService fileService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AdminGatewayClient adminGatewayClient;
|
||||
|
||||
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
|
||||
|
||||
@@ -68,9 +69,13 @@ public class AppServiceFacade {
|
||||
}
|
||||
|
||||
public List<AppRequest> getPendingApiKeyList(PortalOrg portalOrg) {
|
||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE),
|
||||
List<AppRequestType> types = Arrays.asList(AppRequestType.NEW, AppRequestType.MODIFY, AppRequestType.DELETE);
|
||||
List<AppRequest> appRequests = appRequestRepository.findAllByOrgAndTypeIsInAndApproval_ApprovalStatusIn(portalOrg, types,
|
||||
Arrays.asList(new ProcessingState(), new RequestedState()));
|
||||
|
||||
// 승인정보(approval) 없는 신청도 목록에 노출한다. (사용자가 직접 삭제 가능)
|
||||
appRequests.addAll(appRequestRepository.findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||
|
||||
return appRequests;
|
||||
}
|
||||
|
||||
@@ -134,7 +139,23 @@ public class AppServiceFacade {
|
||||
}
|
||||
|
||||
public void cancelApiRequest(String id, PortalOrg portalOrg) {
|
||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(approvalService::cancelAppApproval);
|
||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
|
||||
if (request.getApproval() == null) {
|
||||
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
|
||||
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
|
||||
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
|
||||
if (StringUtils.isNotBlank(request.getClientId())) {
|
||||
try {
|
||||
adminGatewayClient.blockClient(request.getClientId());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.", e);
|
||||
}
|
||||
}
|
||||
appRequestRepository.delete(request);
|
||||
} else {
|
||||
approvalService.cancelAppApproval(request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -164,9 +185,15 @@ public class AppServiceFacade {
|
||||
Map<String, ApiServiceDTO> mainIconsMap = apiServiceHelper.getMainIconsFromServiceDtos(apiServices);
|
||||
|
||||
for (String apiId : apiList) {
|
||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||
// 신청 이후 API 스펙/그룹이 삭제된 경우 null 가능
|
||||
ApiSpecInfoDto spec = apiService.selectDetail(apiId);
|
||||
if (spec == null) {
|
||||
continue;
|
||||
}
|
||||
ApiServiceDTO serviceDTO = mainIconsMap.get(apiId);
|
||||
if (serviceDTO != null) {
|
||||
spec.setService(serviceDTO.getGroupName());
|
||||
}
|
||||
appRequest.getApiSpecList().add(spec);
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -25,6 +26,8 @@ import java.util.Optional;
|
||||
* <li>GET /api/session/status - 잔여 시간/유효성 폴링 (인증 필요)</li>
|
||||
* <li>POST /api/session/heartbeat - 세션 연장 (lastAccessTime 갱신)</li>
|
||||
* <li>POST /api/session/check-duplicate - 로그인 전 중복 세션 확인 (CSRF 예외)</li>
|
||||
* <li>GET /api/session/ping - 익명 세션 keepalive (로그인/회원가입 페이지)</li>
|
||||
* <li>GET /api/session/csrf - 현재 CSRF 토큰 조회 (로그인 제출 직전 안전망)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -109,6 +112,37 @@ public class SessionApiController {
|
||||
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번째 옥텟을 ***로 치환)
|
||||
* 예: 192.168.240.178 → 192.168.***.178
|
||||
|
||||
@@ -20,8 +20,9 @@ import java.util.Optional;
|
||||
public class UserSessionService {
|
||||
|
||||
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)에서 적용 */
|
||||
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() {
|
||||
String value = portalPropertyService.getOrCreateProperty(
|
||||
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);
|
||||
}
|
||||
return SESSION_TIMEOUT_MINUTES;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -51,7 +51,7 @@ public class AccountController {
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
|
||||
@PostMapping("/confirm_password")
|
||||
@PostMapping("/password/confirm")
|
||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
boolean isPasswordCorrect = userFacade.verifyCurrentPassword(currentLoginId, inputPassword);
|
||||
@@ -60,19 +60,23 @@ public class AccountController {
|
||||
return ResponseEntity.ok(new ValidationResponse(isPasswordCorrect, message));
|
||||
}
|
||||
|
||||
@GetMapping("/change_password")
|
||||
public String showChangePasswordPage(Model model) {
|
||||
@GetMapping("/password/verify")
|
||||
public String showChangePasswordPage(Model model, HttpSession session) {
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
// ENFORCE 강제 상태면 변경 페이지에 "변경/로그아웃" 강제 팝업을 띄운다.
|
||||
if (Boolean.TRUE.equals(session.getAttribute("pwEnforce"))) {
|
||||
model.addAttribute("forcedPasswordReset", true);
|
||||
}
|
||||
return "apps/mypage/passwordChangeEntry";
|
||||
}
|
||||
|
||||
@GetMapping("/new_password")
|
||||
@GetMapping("/password/change")
|
||||
public String showNewPasswordPage(Model model) {
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
@PostMapping("/verify_current_password")
|
||||
@PostMapping("/password/verify")
|
||||
public String verifyCurrentPassword(@RequestParam String currentPassword, RedirectAttributes redirectAttributes, HttpSession session, Model model) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
if (userFacade.verifyCurrentPassword(currentLoginId, currentPassword)) {
|
||||
@@ -80,11 +84,11 @@ public class AccountController {
|
||||
return "apps/mypage/passwordChange";
|
||||
} else {
|
||||
redirectAttributes.addFlashAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
|
||||
return "redirect:/change_password";
|
||||
return "redirect:/password/verify";
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/mypage/change_new_password")
|
||||
@PostMapping("/password/change")
|
||||
public String updatePassword(@RequestParam String newPassword,
|
||||
@RequestParam String confirmPassword,
|
||||
HttpSession session,
|
||||
@@ -96,10 +100,11 @@ public class AccountController {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
userFacade.updatePassword(currentLoginId, newPassword, confirmPassword);
|
||||
|
||||
// 비밀번호 만료 관련 세션 속성 제거
|
||||
// 비밀번호 만료/강제 관련 세션 속성 제거
|
||||
session.removeAttribute("passwordExpired");
|
||||
session.removeAttribute("success");
|
||||
session.removeAttribute("redirectUrl");
|
||||
session.removeAttribute("pwEnforce");
|
||||
|
||||
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
|
||||
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
|
||||
@@ -113,11 +118,16 @@ public class AccountController {
|
||||
redirectAttributes.addFlashAttribute("success", "비밀번호가 성공적으로 변경되었습니다.");
|
||||
return "redirect:/login";
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 검증 실패(비밀번호 규칙/이력 등) — 사용자에게 안내, 스택은 불필요
|
||||
logger.warn("비밀번호 변경 검증 실패: {}", e.getMessage());
|
||||
model.addAttribute("error", e.getMessage());
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
return "apps/mypage/passwordChange";
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("error", e.getMessage());
|
||||
// 예기치 못한 오류(트랜잭션 롤백 등) — 원인 추적을 위해 스택은 남기되,
|
||||
// 사용자에게는 시스템 예외 메시지를 노출하지 않고 일반 안내만 보여준다.
|
||||
logger.error("비밀번호 변경 처리 중 오류", e);
|
||||
model.addAttribute("error", "비밀번호 변경 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
model.addAttribute("passwordChangeRequest", new PasswordChangeRequestDTO());
|
||||
return "apps/mypage/passwordChange";
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.eactive.apim.portal.apps.user.dto;
|
||||
import com.eactive.apim.portal.common.validator.AuthNumberMatch;
|
||||
import com.eactive.apim.portal.common.validator.CellPhone;
|
||||
import com.eactive.apim.portal.common.validator.PasswordMatch;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
import org.hibernate.validator.constraints.NotEmpty;
|
||||
@@ -12,7 +12,7 @@ import org.hibernate.validator.constraints.NotEmpty;
|
||||
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
|
||||
@PasswordMatch(input = "password", confirm = "password2")
|
||||
@Data
|
||||
@PasswordRuleForDjbank(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
|
||||
public class PortalUserRegistrationDTO {
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.dto;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordMatch;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import com.eactive.apim.portal.common.validator.UniqueId;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserStatus;
|
||||
import lombok.Data;
|
||||
@@ -13,7 +13,7 @@ import java.io.Serializable;
|
||||
|
||||
@PasswordMatch(input = "password", confirm = "password2")
|
||||
@Data
|
||||
@PasswordRuleForDjbank(loginId = "userId", password = "password", mobile = "mobilePhone")
|
||||
@PasswordRule(loginId = "userId", password = "password", mobile = "mobilePhone")
|
||||
public class UserRegisterDTO implements Serializable {
|
||||
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
@Override
|
||||
public ValidationResponse checkPassword(String password, String loginId, String mobileNumber) {
|
||||
boolean isValid = passwordValidator.isValidPassword(password, loginId, mobileNumber);
|
||||
String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~20자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다.";
|
||||
String message = isValid ? "유효한 비밀번호입니다." : "비밀번호는 영문/숫자/특수문자 포함 8~50자, 로그인 아이디, 휴대폰 번호, 3자리 이상 연속, 반복 문자 사용 불가능 합니다.";
|
||||
return new ValidationResponse(isValid, message);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ public class PasswordService {
|
||||
// 새 비밀번호 설정
|
||||
String newPasswordHash = passwordEncoder.encode(newPassword);
|
||||
user.setPasswordHash(newPasswordHash);
|
||||
// 변경일 기록 → 재설정 강제(null 트리거) 해제
|
||||
user.setPasswordChangeDate(LocalDateTime.now());
|
||||
portalUserRepository.save(user);
|
||||
|
||||
savePasswordHistory(user.getId(), newPasswordHash);
|
||||
|
||||
@@ -184,6 +184,8 @@ public class PortalUserService {
|
||||
user.setPasswordHash(passwordEncoder.encode(dto.getPassword()));
|
||||
user.setMobileNumber(dto.getMobileNumber());
|
||||
user.setEmailAddr(normalizedEmail);
|
||||
// 가입 시점을 비밀번호 변경일로 기록 → 신규 가입자는 재설정 강제 대상에서 제외된다.
|
||||
user.setPasswordChangeDate(java.time.LocalDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.eactive.apim.portal.apps.user.validator;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForKbankValidator;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForDjbankValidator;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@@ -17,13 +16,13 @@ public class PasswordValidator {
|
||||
}
|
||||
|
||||
public boolean isValidPassword(String password, String loginId, String mobileNumber) {
|
||||
PasswordRuleForDjbankValidator validator = new PasswordRuleForDjbankValidator();
|
||||
PasswordRuleValidator validator = new PasswordRuleValidator();
|
||||
return validator.isValid(password, loginId, mobileNumber);
|
||||
}
|
||||
|
||||
private boolean isValidLengthAndCharacters(String password) {
|
||||
final int MIN = 8;
|
||||
final int MAX = 20;
|
||||
final int MAX = 50;
|
||||
final String REGEX = "^(?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "}$";
|
||||
return password.matches(REGEX);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ public class GlobalControllerAdvice {
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). PortalProperty(Portal/session.timeout.minutes)에서 조회.
|
||||
* 화면 세션 타이머 기준이 되는 타임아웃(분). 10분 고정 (UserSessionService.SESSION_TIMEOUT_MINUTES).
|
||||
*/
|
||||
@ModelAttribute("sessionTimeoutMinutes")
|
||||
public int sessionTimeoutMinutes() {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package com.eactive.apim.portal.common.dto;
|
||||
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleForKbank;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRule;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@PasswordRuleForKbank(password = "password", loginId = "loginId", mobile = "mobile")
|
||||
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobile")
|
||||
public class PasswordValidationDTO {
|
||||
private String password;
|
||||
private String loginId;
|
||||
|
||||
+9
@@ -7,6 +7,7 @@ import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
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.file.exception.InvalidFileException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -54,8 +55,16 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(value = AccessDeniedException.class)
|
||||
public ModelAndView handleAccessDeniedException(HttpServletRequest request, AccessDeniedException ex) {
|
||||
// 미로그인 사용자는 로그인 페이지로 유도, 로그인 상태에서의 권한 부족은 오류 안내 페이지로 표시한다.
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
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)
|
||||
public ModelAndView handlePortalRedirectException(HttpServletRequest request, PortalRedirectException ex) {
|
||||
|
||||
@@ -5,14 +5,20 @@ import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleValidator.class)
|
||||
@Target({ElementType.FIELD})
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRule {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 3자리 이상 연속,반복 문자 불가)";
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~50자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForDjbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForDjbankValidator implements ConstraintValidator<PasswordRuleForDjbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForDjbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForKbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForKbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForKbankValidator implements ConstraintValidator<PasswordRuleForKbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForKbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
@Constraint(validatedBy = PasswordRuleForDjbankValidator.class)
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface PasswordRuleForKjbank {
|
||||
String message() default "비밀 번호 규칙에 부합하지 않습니다.(영문/숫자/특수문자 포함 8~20자, 아이디, 휴대전화, 3자리 이상 연속,반복 문자 불가)";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
|
||||
String password();
|
||||
|
||||
String loginId();
|
||||
|
||||
String mobile();
|
||||
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleForKjbankValidator implements ConstraintValidator<PasswordRuleForKjbank, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
// 공백 문자 정규식
|
||||
private static final String BLANKPT = "(\\s)";
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRuleForKjbank constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
// 정규식 검사객체
|
||||
Matcher matcher;
|
||||
|
||||
// 공백 체크
|
||||
if (password == null || "".equals(password)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ASCII 문자 비교를 위한 UpperCase
|
||||
String tmpPw = password.toUpperCase();
|
||||
// 문자열 길이
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 비밀번호 정규식 체크
|
||||
matcher = Pattern.compile(REGEX).matcher(tmpPw);
|
||||
if (!matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일한 문자 3개 이상 체크
|
||||
matcher = Pattern.compile(SAMEPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 연속된 문자 / 숫자 3개 이상 체크
|
||||
// ASCII Char를 담을 배열 선언
|
||||
int[] tmpArray = new int[strLen];
|
||||
|
||||
// Make Array
|
||||
for (int i = 0; i < strLen; i++) {
|
||||
tmpArray[i] = tmpPw.charAt(i);
|
||||
}
|
||||
|
||||
// Validation Array
|
||||
for (int i = 0; i < strLen - 2; i++) {
|
||||
if (isContinuous(tmpArray[i], tmpArray[i + 2]) && isContinuous(tmpArray[i], tmpArray[i + 1], tmpArray[i + 2])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Validation Complete
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int second, int third) {
|
||||
// 배열의 연속된 수 검사
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import java.util.regex.Matcher;
|
||||
@@ -8,11 +10,15 @@ import java.util.regex.Pattern;
|
||||
/**
|
||||
* Created by Sungpil Hyun
|
||||
*/
|
||||
public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, String> {
|
||||
public class PasswordRuleValidator implements ConstraintValidator<PasswordRule, Object> {
|
||||
|
||||
// 최소 8자, 최대 20자 상수 선언
|
||||
// 최소 8자, 최대 50자 상수 선언
|
||||
private static final int MIN = 8;
|
||||
private static final int MAX = 20;
|
||||
private static final int MAX = 50;
|
||||
|
||||
private String password;
|
||||
private String loginId;
|
||||
private String mobileNumber;
|
||||
|
||||
// 3자리 연속 문자 정규식
|
||||
private static final String SAMEPT = "(\\w)\\1\\1";
|
||||
@@ -21,11 +27,30 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordRule constraintAnnotation) {
|
||||
this.password = constraintAnnotation.password();
|
||||
this.loginId = constraintAnnotation.loginId();
|
||||
this.mobileNumber = constraintAnnotation.mobile();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(String password, ConstraintValidatorContext constraintValidatorContext) {
|
||||
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
|
||||
|
||||
String passwordValue = null;
|
||||
String loginIdValue = null;
|
||||
String mobileNumberValue = null;
|
||||
|
||||
try {
|
||||
passwordValue = (String) PropertyUtils.getProperty(value, this.password);
|
||||
loginIdValue = (String) PropertyUtils.getProperty(value, this.loginId);
|
||||
mobileNumberValue = (String) PropertyUtils.getProperty(value, this.mobileNumber);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValid(passwordValue, loginIdValue, mobileNumberValue);
|
||||
}
|
||||
|
||||
public boolean isValid(String password, String loginId, String mobileNumber) {
|
||||
// 영어, 숫자, 특수문자 포함한 MIN to MAX 글자 정규식
|
||||
String REGEX = "^((?=.*\\d)(?=.*[a-zA-Z])(?=.*[\\W]).{" + MIN + "," + MAX + "})$";
|
||||
|
||||
@@ -43,10 +68,30 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
int strLen = tmpPw.length();
|
||||
|
||||
// 글자 길이 체크
|
||||
if (strLen > 20 || strLen < 8) {
|
||||
if (strLen > MAX || strLen < MIN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
matcher = Pattern.compile(BLANKPT).matcher(tmpPw);
|
||||
if (matcher.find()) {
|
||||
@@ -94,5 +139,4 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
// 3번째 글자 - 2번째 글자 = 1, 3번째 글자 - 1번째 글자 = 2
|
||||
return Math.abs(third - second) == 1 && Math.abs(third - first) == 2;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
|
||||
persistenceUnit = "gateway";
|
||||
}
|
||||
|
||||
// 개발 환경용
|
||||
// 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
|
||||
if (env.matchesProfiles("local")) {
|
||||
properties.put("hibernate.hbm2ddl.auto", "validate");
|
||||
properties.put("hibernate.hbm2ddl.auto", "none");
|
||||
}
|
||||
|
||||
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 강제(ENFORCE) 가드.
|
||||
*
|
||||
* <p>로그인 시 {@code PortalAuthenticationSuccessHandler} 가 대상자(비밀번호 미변경/만료)에게
|
||||
* 세션 플래그 {@link #ENFORCE_SESSION_ATTR} 를 설정한다. 이 플래그가 있는 동안에는 비밀번호
|
||||
* 변경/검증/로그아웃 경로를 제외한 모든 요청을 변경 페이지로 리다이렉트하여 접근을 차단한다.
|
||||
* 비밀번호 변경 완료 시 플래그가 제거되어 정상 접근이 회복된다.</p>
|
||||
*
|
||||
* <p>정적 자원 경로는 {@code PortalConfigWebDispatcherServlet.addInterceptors} 의
|
||||
* excludePathPatterns 로 제외한다.</p>
|
||||
*/
|
||||
public class PasswordChangeEnforcementInterceptor implements HandlerInterceptor {
|
||||
|
||||
/** ENFORCE 대상 세션 플래그. 로그인 핸들러가 설정, 변경 완료 시 제거. */
|
||||
public static final String ENFORCE_SESSION_ATTR = "pwEnforce";
|
||||
|
||||
/** 강제 상태에서도 접근 허용하는 경로(화이트리스트) */
|
||||
private static final Set<String> ALLOWED_PATHS = new HashSet<>(Arrays.asList(
|
||||
"/password/verify", // 현재 비밀번호 입력(진입) + 검증(POST)
|
||||
"/password/change", // 새 비밀번호 폼(GET) + 실제 변경(POST)
|
||||
"/password/confirm", // 비밀번호 확인 AJAX
|
||||
"/actionLogout.do", // 로그아웃
|
||||
"/login",
|
||||
"/error", "/403", "/404"
|
||||
));
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
HttpSession session = request.getSession(false);
|
||||
if (session == null || !Boolean.TRUE.equals(session.getAttribute(ENFORCE_SESSION_ATTR))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AJAX/API 등 비(非)페이지 요청은 강제 리다이렉트 대상에서 제외한다.
|
||||
// (세션 heartbeat 같은 인프라 호출을 302로 튕기면 keepalive JS가 세션만료로 오판하여
|
||||
// 로그인↔홈↔변경페이지 무한 리다이렉트가 발생한다.)
|
||||
if (!isTopLevelHtmlNavigation(request)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String path = request.getServletPath();
|
||||
if (path != null && ALLOWED_PATHS.contains(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String target = request.getContextPath() + "/password/verify";
|
||||
// 이미 목적지면 재리다이렉트하지 않는다(무한 루프 방지).
|
||||
if (request.getRequestURI().equals(target)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
response.sendRedirect(target);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 브라우저 주소창 이동(최상위 HTML 문서 요청)인지 판별한다.
|
||||
* GET + Accept: text/html + 비-AJAX 만 강제 리다이렉트 대상으로 본다.
|
||||
*/
|
||||
private boolean isTopLevelHtmlNavigation(HttpServletRequest request) {
|
||||
if (!"GET".equalsIgnoreCase(request.getMethod())) {
|
||||
return false;
|
||||
}
|
||||
if ("XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With"))) {
|
||||
return false;
|
||||
}
|
||||
String fetchMode = request.getHeader("Sec-Fetch-Mode");
|
||||
if (fetchMode != null && !"navigate".equalsIgnoreCase(fetchMode)) {
|
||||
return false;
|
||||
}
|
||||
String accept = request.getHeader("Accept");
|
||||
return accept != null && accept.contains("text/html");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 강제 정책 레벨.
|
||||
*
|
||||
* <p>PTL_PROPERTY (group={@code Portal}, name={@code passwordChangeEnforcement}) 값으로 제어한다.</p>
|
||||
* <ul>
|
||||
* <li>{@link #NONE} — 정책 미적용. 안내/강제 없음.</li>
|
||||
* <li>{@link #PERMISSIVE} — 대상자 로그인 시 1회 안내 팝업만. 강제 없음.</li>
|
||||
* <li>{@link #ENFORCE} — 대상자는 비밀번호 변경 완료 전까지 변경/검증/로그아웃 외 접근 차단.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum PasswordEnforcementPolicy {
|
||||
NONE,
|
||||
PERMISSIVE,
|
||||
ENFORCE;
|
||||
|
||||
/** PTL_PROPERTY 그룹명 */
|
||||
public static final String PROPERTY_GROUP = "Portal";
|
||||
/** PTL_PROPERTY 이름 */
|
||||
public static final String PROPERTY_NAME = "passwordChangeEnforcement";
|
||||
/** 기본값 (배포 직후 동작) */
|
||||
public static final PasswordEnforcementPolicy DEFAULT = ENFORCE;
|
||||
|
||||
/**
|
||||
* 문자열을 정책으로 파싱한다. 대소문자 무시, 미해당/공백이면 {@link #DEFAULT} 반환.
|
||||
*/
|
||||
public static PasswordEnforcementPolicy from(String value) {
|
||||
if (value == null) {
|
||||
return DEFAULT;
|
||||
}
|
||||
try {
|
||||
return PasswordEnforcementPolicy.valueOf(value.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return DEFAULT;
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
-8
@@ -13,6 +13,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
@@ -52,6 +53,7 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
@Override
|
||||
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
|
||||
@@ -85,13 +87,18 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
session.setAttribute("dormantLoginId", username);
|
||||
session.setAttribute("redirectUrl", contextPath + "/dormant_account");
|
||||
} else if (isTemporaryPasswordLogin(user)) {
|
||||
session.setAttribute("success", "임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.");
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/new_password");
|
||||
applyPasswordChangeState(session,
|
||||
"임시 비밀번호로 로그인하셨습니다. <br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (isPasswordChangeRequired(user)) {
|
||||
session.setAttribute("success", "비밀번호를 변경한 지 90일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.");
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", contextPath + "/new_password");
|
||||
applyPasswordChangeState(session,
|
||||
"비밀번호를 변경한 지 " + portalProperties.getPasswordExpirationDays() + "일이 경과하였습니다.<br>계정 보안을 위해 비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/change");
|
||||
} else if (user.getPasswordChangeDate() == null) {
|
||||
// 비밀번호 변경일 미기록(예: 기존 가입자) → 재설정 대상. 현재 비밀번호 검증 진입 경로로 안내.
|
||||
applyPasswordChangeState(session,
|
||||
"계정 보안을 위해 비밀번호 재설정이 필요합니다.<br>비밀번호를 변경해 주세요.",
|
||||
contextPath + "/password/verify");
|
||||
}
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
|
||||
@@ -122,8 +129,8 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
|
||||
clientIp, request.getHeader("User-Agent"));
|
||||
|
||||
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
|
||||
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
|
||||
// 물리 세션 타임아웃 10분 고정. yml(timeout: 10m)·weblogic.xml(timeout-secs 600)과 동일 값이지만
|
||||
// 컨테이너 설정(콘솔 override 등)과 무관하게 보장하기 위해 명시 적용 → 물리=논리 단일화(CSRF 수명 포함).
|
||||
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
|
||||
|
||||
// 로그인 성공 시 세션 정보 로깅
|
||||
@@ -179,6 +186,35 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
sessionLogger.info(logMessage.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 대상자에게 정책(NONE/PERMISSIVE/ENFORCE)을 적용한다.
|
||||
* <ul>
|
||||
* <li>NONE — 아무것도 하지 않음.</li>
|
||||
* <li>PERMISSIVE — 안내 알림만(로그인 후 dismissible 팝업). 강제 없음.</li>
|
||||
* <li>ENFORCE — 안내 + 세션 강제 플래그 설정 → 인터셉터가 변경 완료까지 접근 차단.</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void applyPasswordChangeState(HttpSession session, String message, String redirectUrl) {
|
||||
PasswordEnforcementPolicy policy = PasswordEnforcementPolicy.from(
|
||||
portalPropertyService.getOrCreateProperty(
|
||||
PasswordEnforcementPolicy.PROPERTY_GROUP,
|
||||
PasswordEnforcementPolicy.PROPERTY_NAME,
|
||||
PasswordEnforcementPolicy.DEFAULT.name(),
|
||||
"비밀번호 변경 강제 정책 (NONE|PERMISSIVE|ENFORCE)"));
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.setAttribute("success", message);
|
||||
session.setAttribute("passwordExpired", true);
|
||||
session.setAttribute("redirectUrl", redirectUrl);
|
||||
|
||||
if (policy == PasswordEnforcementPolicy.ENFORCE) {
|
||||
session.setAttribute(PasswordChangeEnforcementInterceptor.ENFORCE_SESSION_ATTR, Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPasswordChangeRequired(PortalUser user) {
|
||||
// 가장 최근 비밀번호 변경 이력 조회
|
||||
Optional<UserPasswordHistory> latestHistory = passwordHistoryRepository
|
||||
|
||||
@@ -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.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
@@ -32,7 +33,7 @@ public class PortalConfigLog {
|
||||
String query = request.getQueryString();
|
||||
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);
|
||||
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
|
||||
|
||||
@@ -86,6 +86,7 @@ public class PortalConfigSecurity {
|
||||
// 운영(prod/eapim/devportal)은 동일 호스트(IP:PORT)에 여러 서비스가 떠 있어
|
||||
// 쿠키가 호스트 단위로 공유·과포화되면서 XSRF-TOKEN 쿠키가 누락 → 로그인 403이 발생했다.
|
||||
// 기존 클라이언트(X-XSRF-TOKEN 헤더, _csrf 파라미터)와 호환되도록 헤더명을 고정한다.
|
||||
// 세션에 저장되므로 CSRF 토큰 수명은 세션 타임아웃(10분)과 동일하다.
|
||||
HttpSessionCsrfTokenRepository csrfTokenRepository = new HttpSessionCsrfTokenRepository();
|
||||
csrfTokenRepository.setHeaderName("X-XSRF-TOKEN");
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.multipart.support.MultipartFilter;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceChainRegistration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@@ -76,6 +77,17 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
registry.addConverter(enabledStatusConverter());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 비밀번호 변경 강제(ENFORCE) 가드. 정적 자원은 제외한다.
|
||||
registry.addInterceptor(new PasswordChangeEnforcementInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(
|
||||
"/css/**", "/js/**", "/img/**", "/images/**", "/webfonts/**",
|
||||
"/font/**", "/html/**", "/plugins/**", "/favicon.ico",
|
||||
"/api/**");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public EnabledStatusConverter enabledStatusConverter() {
|
||||
return new EnabledStatusConverter();
|
||||
|
||||
+10
@@ -32,6 +32,7 @@ public class DjbTestbedGatewayProperty {
|
||||
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_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_TIMEOUT_SEC = "10";
|
||||
@@ -47,6 +48,9 @@ public class DjbTestbedGatewayProperty {
|
||||
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
|
||||
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() {
|
||||
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
|
||||
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
|
||||
@@ -86,6 +90,12 @@ public class DjbTestbedGatewayProperty {
|
||||
"테스트베드 토큰 발급 시 서버 프록시(/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} 값도 자동 변환한다.
|
||||
* {@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 를 주입하고,
|
||||
* 서버 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 대상 외).
|
||||
*/
|
||||
@RestController
|
||||
@@ -45,22 +48,34 @@ public class DjbTestbedSpecController {
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
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);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||
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));
|
||||
}
|
||||
|
||||
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
private String buildSpecJson(String id, HttpServletRequest request) throws IOException {
|
||||
/** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
|
||||
@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)) {
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
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);
|
||||
@@ -70,6 +85,8 @@ public class DjbTestbedSpecController {
|
||||
|
||||
DjbAuthType authType = authService.resolveAuthType(id);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 원본 반환. */
|
||||
public String toYaml(String specJson) {
|
||||
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:
|
||||
context-path: /
|
||||
session:
|
||||
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
|
||||
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
|
||||
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
|
||||
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
|
||||
# timeout: 10m
|
||||
# 세션 타임아웃 10분 고정 (DB property 관리 폐지).
|
||||
# WebLogic 배포 시에는 weblogic.xml <timeout-secs>600 이 동일 값을 적용한다.
|
||||
# CSRF 토큰은 세션에 저장(HttpSessionCsrfTokenRepository)되므로 수명도 이 값과 동일하다.
|
||||
timeout: 10m
|
||||
cookie:
|
||||
name: JSESSIONID_PORTAL
|
||||
encoding:
|
||||
@@ -30,6 +29,8 @@ spring:
|
||||
default-page-size: '10'
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
|
||||
web:
|
||||
resources:
|
||||
@@ -203,6 +204,10 @@ portal:
|
||||
method: GET
|
||||
view-name: apps/service/oauth2-guide
|
||||
|
||||
- path-pattern: /service/webhook-dev-guide
|
||||
method: GET
|
||||
view-name: apps/service/webhook-dev-guide
|
||||
|
||||
- path-pattern: /dashboard
|
||||
method: GET
|
||||
view-name: apps/mypage/dashboard
|
||||
@@ -295,6 +300,9 @@ page:
|
||||
oauth2_guide:
|
||||
name: "OAuth2 개발가이드"
|
||||
path: "/service/oauth2-guide"
|
||||
webhook_dev_guide:
|
||||
name: "웹훅 개발가이드"
|
||||
path: "/service/webhook-dev-guide"
|
||||
apis:
|
||||
name: "API"
|
||||
path: "#"
|
||||
@@ -361,12 +369,12 @@ page:
|
||||
credential_detail:
|
||||
name: "인증키 정보"
|
||||
path: "/myapikey/credential_detail"
|
||||
change_password:
|
||||
password_verify:
|
||||
name: "비밀번호 변경"
|
||||
path: "/change_password"
|
||||
verify_current_password:
|
||||
path: "/password/verify"
|
||||
password_change:
|
||||
name: "비밀번호 변경"
|
||||
path: "/verify_current_password"
|
||||
path: "/password/change"
|
||||
myapikey_register_step1:
|
||||
name: "앱 생성 (기본 정보)"
|
||||
path: "/myapikey/register/step1"
|
||||
@@ -388,6 +396,27 @@ page:
|
||||
api_statistics:
|
||||
name: "이용 통계"
|
||||
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:
|
||||
|
||||
@@ -56,6 +56,37 @@
|
||||
</encoder>
|
||||
</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>
|
||||
|
||||
<!-- ERROR 레벨만 별도 수집(스택 트레이스 포함). 장애 원인 추적용. -->
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/error.log</file>
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>ERROR</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_PATH}/backup/error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
|
||||
<maxFileSize>200MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
||||
@@ -68,16 +99,22 @@
|
||||
<appender-ref ref="HTTP_SESSION" />
|
||||
</logger>
|
||||
|
||||
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
|
||||
<appender-ref ref="API_TESTER_AUDIT" />
|
||||
</logger>
|
||||
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
|
||||
<springProfile name="dev">
|
||||
<root level="DEBUG">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
</configuration>
|
||||
@@ -39,7 +39,7 @@ deptUserManageRegister.id=Department User ID
|
||||
deptUserManageRegister.name=Department User Name
|
||||
portalUser.confirm.password=Please enter your existing password
|
||||
portalUser.Register.userName=Name
|
||||
portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-20 characters)
|
||||
portalUser.Register.pass=Password (Combination of uppercase letters, lowercase letters, numbers, special characters, 8-50 characters)
|
||||
portalUser.Register.passConfirm=Confirm Password
|
||||
portalUser.Register.email=Email ID
|
||||
portalUser.Register.domain=Domain
|
||||
|
||||
@@ -39,7 +39,7 @@ deptUserManageRegister.name=\uBD80\uC11C \uC0AC\uC6A9\uC790 \uC774\uB984
|
||||
entrprsUserManageList.regName=\uBC95\uC778 \uC0AC\uC6A9\uC790 \uB4F1\uB85D \uC774\uB984
|
||||
portalUser.confirm.password=\uAE30\uC874 \uBE44\uBC00\uBC88\uD638\uB97C \uC785\uB825\uD574\uC8FC\uC138\uC694
|
||||
portalUser.Register.userName=\uC774\uB984
|
||||
portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-20\uC790
|
||||
portalUser.Register.pass=\uC601\uBB38 \uB300\uBB38\uC790,\uC601\uBB38 \uC18C\uBB38\uC790,\uC22B\uC790,\uD2B9\uC218\uBB38\uC790 \uC870\uD569 8-50\uC790
|
||||
portalUser.Register.passConfirm=\uBE44\uBC00\uBC88\uD638 \uD655\uC778
|
||||
portalUser.Register.email=\uC774\uBA54\uC77C \uC544\uC774\uB514
|
||||
portalUser.Register.domain=\uB3C4\uBA54\uC778
|
||||
|
||||
+1421
-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('');
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 비밀번호 문자열 정책 라이브 검증 (공용)
|
||||
*
|
||||
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
|
||||
* "문자열" 규칙을 그대로 클라이언트로 포팅한다. 아이디/휴대전화 포함 여부는
|
||||
* 민감정보 노출을 피하기 위해 서버 검증에만 맡긴다.
|
||||
*
|
||||
* 사용법(마크업 구동):
|
||||
* <ul class="password-policy-checklist" data-password-input="newPassword">
|
||||
* <li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">...</span></li>
|
||||
* ... (rule: length | letter | digit | special | nospace | norepeat | noseq)
|
||||
* </ul>
|
||||
* <input type="password" id="newPassword" ...>
|
||||
*
|
||||
* DOM ready 시 자동으로 스캔하여 대상 input 에 바인딩한다.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// 3자리 연속(오름/내림) 문자·숫자 검사 — 서버 로직과 동일하게 대문자로 비교
|
||||
function hasSequential(pw) {
|
||||
var s = pw.toUpperCase();
|
||||
for (var i = 0; i < s.length - 2; i++) {
|
||||
var a = s.charCodeAt(i), b = s.charCodeAt(i + 1), c = s.charCodeAt(i + 2);
|
||||
var sameCategory = (a > 47 && c < 58) || (a > 64 && c < 91); // 숫자 0-9 또는 영문 A-Z
|
||||
var consecutive = Math.abs(c - b) === 1 && Math.abs(c - a) === 2;
|
||||
if (sameCategory && consecutive) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 규칙별 판정 함수 (통과=true)
|
||||
var RULES = {
|
||||
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
|
||||
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
|
||||
digit: function (pw) { return /[0-9]/.test(pw); },
|
||||
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
|
||||
nospace: function (pw) { return !/\s/.test(pw); },
|
||||
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
|
||||
noseq: function (pw) { return !hasSequential(pw); }
|
||||
};
|
||||
|
||||
// 전체 문자열 규칙 통과 여부
|
||||
function isValid(pw) {
|
||||
if (!pw) {
|
||||
return false;
|
||||
}
|
||||
for (var key in RULES) {
|
||||
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 체크리스트(ul) 하나를 대상 input 에 바인딩
|
||||
function bind(input, list) {
|
||||
var $input = (input && input.jquery) ? input : $(input);
|
||||
var $list = (list && list.jquery) ? list : $(list);
|
||||
var $items = $list.find('li[data-rule]');
|
||||
if (!$input.length || !$items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
function update() {
|
||||
var pw = $input.val() || '';
|
||||
$items.each(function () {
|
||||
var $li = $(this);
|
||||
var rule = RULES[$li.attr('data-rule')];
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
$li.removeClass('is-idle is-pass is-fail');
|
||||
if (pw.length === 0) {
|
||||
$li.addClass('is-idle');
|
||||
} else {
|
||||
$li.addClass(rule(pw) ? 'is-pass' : 'is-fail');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$input.on('input.passwordPolicy', update);
|
||||
update();
|
||||
}
|
||||
|
||||
// 마크업 구동 자동 초기화
|
||||
function init(root) {
|
||||
var $root = root ? $(root) : $(document);
|
||||
$root.find('ul.password-policy-checklist[data-password-input]').each(function () {
|
||||
var $list = $(this);
|
||||
var input = document.getElementById($list.attr('data-password-input'));
|
||||
if (input) {
|
||||
bind(input, $list);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
global.PasswordPolicy = {
|
||||
RULES: RULES,
|
||||
isValid: isValid,
|
||||
bind: bind,
|
||||
init: init
|
||||
};
|
||||
|
||||
$(function () {
|
||||
init(document);
|
||||
});
|
||||
})(window);
|
||||
@@ -26,6 +26,9 @@
|
||||
|
||||
var startTs = 0;
|
||||
var targetOpblock = null;
|
||||
// Execute 클릭 후에만 true. spec 로딩 등 실행 외 응답이 responseInterceptor 로
|
||||
// 들어와도 그리드를 만들지 않기 위한 게이트 (실행 전 UI 미노출 보장).
|
||||
var pendingExecute = false;
|
||||
|
||||
function now() {
|
||||
return (global.performance && performance.now) ? performance.now() : Date.now();
|
||||
@@ -206,6 +209,7 @@
|
||||
|
||||
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
|
||||
function renderNetworkError(msg) {
|
||||
pendingExecute = false;
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (!grid) return;
|
||||
grid.classList.remove("djb-empty");
|
||||
@@ -250,6 +254,7 @@
|
||||
}
|
||||
|
||||
startTs = now();
|
||||
pendingExecute = true;
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (grid) {
|
||||
grid.classList.add("djb-empty");
|
||||
@@ -264,10 +269,13 @@
|
||||
*/
|
||||
function renderResponse(res) {
|
||||
if (!res) return;
|
||||
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
|
||||
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
|
||||
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
|
||||
// Execute 를 누른 적 없으면 무시 — spec 로딩(/…/swagger.json) 응답도
|
||||
// responseInterceptor 로 들어오는데, 이때 렌더하면 실행 전 응답 본문에
|
||||
// OpenAPI spec 이 그려진다. url 검사는 버전에 따라 res.url 이 비어 무력화될
|
||||
// 수 있어 실행 게이트를 1차 방어로 둔다.
|
||||
if (!pendingExecute) return;
|
||||
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
|
||||
pendingExecute = false;
|
||||
var ms = Math.max(0, Math.round(now() - startTs));
|
||||
var status = res.status || 0;
|
||||
var statusText = res.statusText || "";
|
||||
|
||||
@@ -12,5 +12,5 @@ html {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
@@ -101,8 +101,9 @@ $z-index-dropdown: 100;
|
||||
$z-index-sticky: 200;
|
||||
$z-index-fixed: 300;
|
||||
$z-index-header: 350;
|
||||
$z-index-modal-backdrop: 400;
|
||||
$z-index-modal: 500;
|
||||
// 모달은 헤더(.global-header z-index:1000 하드코딩)보다 항상 위에 떠야 한다
|
||||
$z-index-modal-backdrop: 1040;
|
||||
$z-index-modal: 1050;
|
||||
$z-index-popover: 600;
|
||||
$z-index-tooltip: 700;
|
||||
$z-index-notification: 800;
|
||||
|
||||
@@ -8,20 +8,39 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// FAQ Accordion - Figma Design
|
||||
|
||||
.faq-container {
|
||||
|
||||
|
||||
.table-controls {
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
display: contents;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.faq-accordion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: $spacing-2xl;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: $transition-base;
|
||||
|
||||
// Active state - when accordion is open
|
||||
&.active {
|
||||
&:hover {
|
||||
.faq-question {
|
||||
background: #4685ef;
|
||||
border-color: #e8dddd;
|
||||
@@ -33,14 +52,8 @@
|
||||
|
||||
.faq-icon {
|
||||
color: #fff;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
display: block;
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -598,6 +598,11 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.md {
|
||||
font-size: 14px;
|
||||
padding: 11px 45px;
|
||||
}
|
||||
|
||||
|
||||
@include respond-to('sm') {
|
||||
font-size: 15px;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// DJBank Custom — Q&A 댓글 영역 (DJPGPT0002)
|
||||
// 설계서: DJPCSQ0300P 참조
|
||||
@@ -160,7 +162,8 @@
|
||||
}
|
||||
|
||||
.djb-comment-form {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -202,6 +205,17 @@ textarea.djb-comment-input {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
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 {
|
||||
@@ -223,6 +237,13 @@ button.djb-comment-submit {
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
background-color: #f8faff;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// 비밀번호 문자열 정책 라이브 체크리스트 (마이페이지 비밀번호 변경 + 회원가입 공용)
|
||||
.password-policy-checklist {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 12px 0 0 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 6px;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.policy-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
|
||||
&::before {
|
||||
content: "\2022"; // •
|
||||
}
|
||||
}
|
||||
|
||||
&.is-idle {
|
||||
color: #888;
|
||||
|
||||
.policy-icon {
|
||||
color: #b5b5b5;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-pass {
|
||||
color: #1a8f4c;
|
||||
|
||||
.policy-icon {
|
||||
color: #1a8f4c;
|
||||
|
||||
&::before {
|
||||
content: "\2714"; // ✔
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-fail {
|
||||
color: #d63a3a;
|
||||
|
||||
.policy-icon {
|
||||
color: #d63a3a;
|
||||
|
||||
&::before {
|
||||
content: "\2716"; // ✖
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.password-policy-note {
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
color: #888;
|
||||
}
|
||||
@@ -71,12 +71,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치.
|
||||
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
// 가로 여유가 있을 때만 노출.
|
||||
// 헤더 .container(max-width:1280px, 중앙정렬)의 우측 여백에 위젯(폭 ~200px)이 들어갈 만큼
|
||||
// 넓은 화면(약 1700px 이상)에서만 표시 → 그 외에는 헤더 우측 메뉴(사용자명·로그아웃·마이페이지)를 가리므로 숨김.
|
||||
// (PC 전용: 모바일 ≤768px 도 당연히 숨김)
|
||||
@media (max-width: 1699px) {
|
||||
.session-float {
|
||||
top: 12px;
|
||||
right: 72px;
|
||||
padding: 4px 10px;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +366,9 @@
|
||||
white-space: normal;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
&::before { display: none; }
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-title-link {
|
||||
@@ -378,7 +380,7 @@
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Used in: User management, API key list, etc.
|
||||
@@ -813,6 +935,7 @@
|
||||
// 공지사항 모바일 디자인 - 검색창, 목록, 페이지네이션
|
||||
// -----------------------------------------------------------------------------
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
|
||||
// 검색 필드 - Figma: 335px × 40px, border-radius 8px
|
||||
.search-field {
|
||||
display: flex;
|
||||
@@ -995,7 +1118,8 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
i, svg {
|
||||
i,
|
||||
svg {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
--accent-orange: #FF6B6B;
|
||||
--accent-green: #6BCF7F;
|
||||
--accent-purple: #A78BFA;
|
||||
--accent-color:#efdcb2;
|
||||
--accent-color: #efdcb2;
|
||||
--accent-light: #E9F9FF;
|
||||
|
||||
// Base colors
|
||||
@@ -38,7 +38,7 @@
|
||||
--shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2);
|
||||
|
||||
// border
|
||||
--border-color : #E2E8F0;
|
||||
--border-color: #E2E8F0;
|
||||
|
||||
//transition
|
||||
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
@@ -225,7 +225,7 @@ body.design-survey-active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size:24px;
|
||||
font-size: 24px;
|
||||
|
||||
.logo-text-bold {
|
||||
font-weight: 700;
|
||||
@@ -999,7 +999,7 @@ body.design-survey-active {
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: 36px;
|
||||
gap: 28px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -1064,7 +1064,7 @@ body.design-survey-active {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-gray);
|
||||
padding: 8px 0;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
transition: var(--transition-smooth);
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
@use 'components/breadcrumb' as *;
|
||||
@use 'components/test-env-notice' as *;
|
||||
@use 'components/djb-inquiry-comments' as *;
|
||||
@use 'components/password-policy' as *;
|
||||
|
||||
// 5. Page-specific styles
|
||||
@use 'pages/index' as *;
|
||||
@@ -66,6 +67,7 @@
|
||||
@use 'pages/terms-agreements' as *;
|
||||
@use 'pages/service' as *;
|
||||
@use 'pages/api-statistics' as *;
|
||||
@use 'pages/webhook' as *;
|
||||
|
||||
// 6. Themes
|
||||
@use 'themes/dark' as *;
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
}
|
||||
|
||||
// Sidebar overrides for mobile toggle menu inside market
|
||||
// Commented out to allow horizontal scrollbar on mobile screens as well
|
||||
/*
|
||||
.service-sidebar {
|
||||
@media (max-width: 768px) {
|
||||
position: fixed;
|
||||
@@ -52,6 +54,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
// Hero Section
|
||||
@@ -311,6 +314,7 @@
|
||||
flex: 1;
|
||||
// padding-left: $spacing-sm;
|
||||
// padding-right: $spacing-sm;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -334,37 +338,7 @@
|
||||
|
||||
// Mobile Menu Toggle
|
||||
.api-mobile-toggle {
|
||||
display: none;
|
||||
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;
|
||||
}
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
background: transparent;
|
||||
@@ -803,7 +910,11 @@
|
||||
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;
|
||||
color: $text-gray;
|
||||
line-height: $line-height-normal;
|
||||
@@ -1013,8 +1124,16 @@
|
||||
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 {
|
||||
// 과거 회귀: :not(.editor-content) 제거
|
||||
.detail-content:not(.editor-content) {
|
||||
font-size: $font-size-xs;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -1097,7 +1216,11 @@
|
||||
|
||||
// API Market Content - Mobile (화면 폭 제한)
|
||||
.api-market-content {
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.api-card {
|
||||
height: 285px;
|
||||
}
|
||||
}
|
||||
@@ -1232,6 +1232,14 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 내부 결재 진행중: 신청 취소 불가 안내
|
||||
.dt-cancel-notice {
|
||||
margin: 10px 0 0;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// Footer Buttons layout
|
||||
.dt-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -255,11 +255,19 @@
|
||||
max-width: 1200px;
|
||||
margin: 40px auto 100px;
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-management-header {
|
||||
@@ -268,6 +276,14 @@
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
@@ -318,6 +334,14 @@
|
||||
color: inherit;
|
||||
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 {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0px 6px 15px rgba(192, 192, 192, 0.35);
|
||||
@@ -335,12 +359,23 @@
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +385,10 @@
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-header {
|
||||
@@ -366,6 +405,10 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-badge-container {
|
||||
@@ -391,6 +434,11 @@
|
||||
color: #ef9546;
|
||||
}
|
||||
|
||||
&.badge-requested {
|
||||
background: #ffebeb;
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
&.badge-approved {
|
||||
background: #1b4ab7;
|
||||
color: #ffffff;
|
||||
@@ -411,6 +459,10 @@
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-footer-row {
|
||||
@@ -419,6 +471,12 @@
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-expected-date {
|
||||
@@ -427,7 +485,13 @@
|
||||
margin-left: 16px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
margin-left: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-card-expected-date {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
|
||||
@@ -2938,6 +2938,10 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
border: none;
|
||||
background: none;
|
||||
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;
|
||||
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) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -3032,13 +3046,13 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3467,6 +3481,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
@media (max-width: 768px) {
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3707,7 +3722,7 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0px 8px 12px rgba(194, 194, 194, 0.4);
|
||||
background: linear-gradient(to bottom, #50caff, #2088ff);
|
||||
border-color: transparent;
|
||||
border: none;
|
||||
|
||||
.s2-api-card-badge {
|
||||
background-color: #d8f0ff;
|
||||
@@ -4027,7 +4042,8 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
}
|
||||
|
||||
// ── Floating Cart Button ──
|
||||
.s2-floating-cart {
|
||||
// @at-root: body 직속(pagePopups)으로 렌더되므로 step2-wrap 스코프 밖 전역 규칙으로 컴파일
|
||||
@at-root .s2-floating-cart {
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
right: 40px;
|
||||
@@ -4114,7 +4130,8 @@ input[type="checkbox"]:checked+.custom-checkbox {
|
||||
}
|
||||
|
||||
// ── Selected APIs Modal ──
|
||||
.s2-modal {
|
||||
// @at-root: body 직속(pagePopups)으로 렌더되므로 step2-wrap 스코프 밖 전역 규칙으로 컴파일
|
||||
@at-root .s2-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
@@ -828,6 +828,13 @@
|
||||
gap: 10px;
|
||||
margin-top: 40px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
flex-direction: column; // Stack vertically on mobile
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
width: 100px;
|
||||
height: 48px;
|
||||
@@ -844,6 +851,11 @@
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 100%;
|
||||
order: 2; // Position Cancel at the bottom
|
||||
}
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
@@ -860,6 +872,12 @@
|
||||
&:hover {
|
||||
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;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
flex-direction: column;
|
||||
// flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -158,6 +158,28 @@
|
||||
color: #0a4ea3;
|
||||
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 {
|
||||
@@ -222,11 +244,13 @@
|
||||
.attachment-filename {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
// Incident Info Table
|
||||
.notice-detail-incident-info {
|
||||
.notice-detail-incident-info,
|
||||
.notice-detail-affected-info {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.detail-table {
|
||||
@@ -250,11 +274,115 @@
|
||||
}
|
||||
|
||||
td {
|
||||
padding-left: 19px;
|
||||
padding: 10px 19px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
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
|
||||
.btn-notice-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 247px;
|
||||
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
|
||||
|
||||
@@ -14,13 +14,97 @@
|
||||
@include respond-to('sm') {
|
||||
margin-bottom: $spacing-3xl;
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
// Recent applications accordion block styling
|
||||
.recent-apps {
|
||||
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-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
@@ -44,9 +128,9 @@
|
||||
font-size: $font-size-2xl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-description {
|
||||
.page-description {
|
||||
font-size: $font-size-lg;
|
||||
color: $text-gray;
|
||||
line-height: $line-height-normal;
|
||||
@@ -70,7 +154,6 @@
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partnership Form Container
|
||||
@@ -194,7 +277,9 @@
|
||||
// .form-actions moved to components/_forms.scss
|
||||
|
||||
// File Upload Wrapper Override for Partnership Page
|
||||
.partnership-card .file-upload-wrapper {
|
||||
.partnership-card,
|
||||
.service-content {
|
||||
.file-upload-wrapper {
|
||||
.file-name-display {
|
||||
&.has-file {
|
||||
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) {
|
||||
.file-upload-btn {
|
||||
order: 1;
|
||||
width: 100%; // Make full-width on mobile
|
||||
}
|
||||
|
||||
.file-name-display {
|
||||
@@ -217,6 +328,7 @@
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +363,7 @@
|
||||
}
|
||||
|
||||
.file-upload-wrapper {
|
||||
|
||||
.file-upload-btn,
|
||||
.file-remove-btn {
|
||||
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;
|
||||
gap: 40px;
|
||||
min-width: 0; // Prevent flex item from expanding horizontally beyond parent
|
||||
|
||||
&.n-gap {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.service-sidebar {
|
||||
width: 213px;
|
||||
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 {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.service-nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: nowrap !important;
|
||||
justify-content: flex-start !important;
|
||||
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로 보정
|
||||
@@ -1008,7 +1043,7 @@ $o2leg-err-fg: #a23b3b;
|
||||
// -- Section 2: 사전 준비 ----------------------------------------------------
|
||||
&__prereq-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -1079,18 +1114,28 @@ $o2leg-err-fg: #a23b3b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
// 연속 배치된 grid 사이 간격을 내부 gap(24px)과 동일하게 유지
|
||||
// (언어별 코드 블럭이 두 grid 에 나뉘어 있어 간격이 달라 보이는 문제 방지)
|
||||
+.oauth2-2legged__step-grid {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__endpoint-box {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
margin-bottom: 20px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid $o2leg-border;
|
||||
border-radius: 12px;
|
||||
|
||||
.oauth2-2legged__method,
|
||||
.oauth2-2legged__endpoint-content-type {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
&__method {
|
||||
@@ -1244,6 +1289,7 @@ $o2leg-err-fg: #a23b3b;
|
||||
background: $o2leg-text-dark;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
margin-top: 24px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
@@ -9,11 +9,9 @@
|
||||
|
||||
// User Management Container (List Page)
|
||||
.user-management-container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: $spacing-3xl 0;
|
||||
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) {
|
||||
padding: $spacing-lg $spacing-md;
|
||||
@@ -109,12 +107,6 @@
|
||||
grid-template-columns: 24px 112px 1fr 100px 100px 287px;
|
||||
padding: 0 29px; // Match standard board-table padding
|
||||
gap: 10px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.header-cell,
|
||||
@@ -505,201 +497,137 @@
|
||||
display: contents;
|
||||
|
||||
.total-count {
|
||||
order: 1;
|
||||
text-align: left;
|
||||
font-size: 16px;
|
||||
color: $black;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1.5px solid #212529;
|
||||
order: 1 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
text-align: left !important;
|
||||
font-size: 16px !important;
|
||||
color: $black !important;
|
||||
padding-bottom: 6px !important;
|
||||
border-bottom: 1.5px solid #212529 !important;
|
||||
margin-bottom: 0 !important;
|
||||
|
||||
strong {
|
||||
color: #0049b4;
|
||||
font-weight: $font-weight-bold;
|
||||
font-size: 16px;
|
||||
color: #0049b4 !important;
|
||||
font-weight: $font-weight-bold !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 이용자 추가 버튼 - 테이블 아래로 이동
|
||||
// 이용자 추가 버튼 (검색창 위치와 동일하게 총건수 바로 아래에 노출하도록 순서 조정)
|
||||
.search-box {
|
||||
order: 4;
|
||||
width: 100%;
|
||||
order: 2 !important;
|
||||
width: 100% !important;
|
||||
margin-top: 10px !important;
|
||||
|
||||
.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;
|
||||
width: 40px !important;
|
||||
min-width: 40px;
|
||||
flex: none;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
height: 44px !important;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
.list-table-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// List Table - 모바일 디자인 (Q&A 세로 카드형 레이아웃 적용)
|
||||
.board-table--user-list {
|
||||
order: 3 !important;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
overflow: visible;
|
||||
|
||||
.board-table-header {
|
||||
display: none !important; // 헤더는 모바일에서 숨김
|
||||
}
|
||||
|
||||
// Row - 모바일 디자인 (Figma: 40px 높이)
|
||||
.list-table-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 8px;
|
||||
gap: 0;
|
||||
position: relative; // dropdown positioning
|
||||
|
||||
// 짝수/홀수 배경색
|
||||
&:nth-child(even) {
|
||||
background-color: #EDF5FD;
|
||||
}
|
||||
|
||||
&:nth-child(odd) {
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($primary-blue, 0.08);
|
||||
}
|
||||
.board-table-row {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
height: auto !important;
|
||||
padding: 20px 15px !important;
|
||||
gap: 12px !important;
|
||||
border-bottom: 1px solid #e2e8f0 !important;
|
||||
background: $white;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.row-cell {
|
||||
font-size: 13px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: #212529;
|
||||
padding: 0 4px;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex !important;
|
||||
justify-content: flex-start !important;
|
||||
width: 100% !important;
|
||||
font-size: 14px !important;
|
||||
color: #212529 !important;
|
||||
padding: 0 !important;
|
||||
white-space: normal !important;
|
||||
|
||||
// data-label 숨김
|
||||
&::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 컬럼 - 모바일에서 숨김
|
||||
&:nth-child(1) {
|
||||
display: none;
|
||||
// NO 컬럼 - 모바일 숨김
|
||||
&[data-label="NO"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// 성명 컬럼 - 60px
|
||||
&:nth-child(2) {
|
||||
width: 60px !important;
|
||||
min-width: 60px;
|
||||
flex: none;
|
||||
// 성명 컬럼 - 제목처럼 크게 노출 (라벨 제거)
|
||||
&[data-label="성명"] {
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #0f172a !important;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #212529;
|
||||
color: #0f172a !important;
|
||||
font-weight: 600 !important;
|
||||
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 {
|
||||
display: flex !important;
|
||||
width: 40px !important;
|
||||
min-width: 40px;
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
margin-top: 0;
|
||||
padding-top: 0 !important;
|
||||
border-top: none;
|
||||
justify-content: flex-end !important;
|
||||
|
||||
&.has-actions {
|
||||
margin-top: 8px;
|
||||
padding-top: 12px !important;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
// row-actions 클래스 표시
|
||||
.row-actions {
|
||||
&::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.actions-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.actions-mobile {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty State - 모바일 조정
|
||||
.user-empty-state {
|
||||
@@ -718,9 +646,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination - 모바일 디자인 (테이블 다음, 버튼 이전)
|
||||
// Pagination - 모바일 디자인 (테이블 다음)
|
||||
.pagination {
|
||||
order: 3;
|
||||
order: 4 !important;
|
||||
gap: 13px;
|
||||
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>
|
||||
</th:block>
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<section class="api-market-container">
|
||||
<section class="container api-market-container">
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="api-market-sidebar" id="apiSidebar">
|
||||
|
||||
<div class="api-sidebar-header">
|
||||
<img th:src="@{/img/api_sidebar.png}" alt="API">
|
||||
</div>
|
||||
|
||||
<nav class="api-sidebar-nav">
|
||||
<aside class="service-sidebar" id="apiSidebar">
|
||||
<nav class="service-nav">
|
||||
<!-- All APIs -->
|
||||
<div class="menu-section">
|
||||
<a class="menu-title" th:classappend="${selected == '-1'} ? 'active'" th:href="@{/apis}">
|
||||
<a class="service-nav__item"
|
||||
th:classappend="${selected == '-1' || #request.getParameter('selected') == '-1'} ? 'service-nav__item--active' : ''"
|
||||
th:href="@{/apis}">
|
||||
전체
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Service Categories -->
|
||||
<div class="menu-section" th:each="service : ${services}">
|
||||
<a class="menu-title"
|
||||
th:classappend="${selected == service.id} ? 'active'"
|
||||
<a class="service-nav__item" th:each="service : ${services}"
|
||||
th:classappend="${(#request.getParameter('selected') != null ? #request.getParameter('selected') : (selected != null ? selected : apiSpecInfo.service)) == service.id} ? 'service-nav__item--active' : ''"
|
||||
th:href="@{/apis(groupIds=${service.id})}">
|
||||
[[${service.groupName}]]
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -93,7 +86,9 @@
|
||||
<div class="api-simple-description" th:if="${apiSpecInfo.apiSimpleDescription != null}">
|
||||
<p th:text="${apiSpecInfo.apiSimpleDescription}">Simple API description</p>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,7 +101,8 @@
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>Request Specification</h3>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,7 +122,8 @@
|
||||
<div class="org-section-header org-section-header--agreement">
|
||||
<h3>Response Specification</h3>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,7 +298,9 @@
|
||||
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
|
||||
const loadScript = (src) => {
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/api_img.svg}" alt="OPEN API 3D 아이콘"
|
||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
<img th:src="@{/img/keyimage/api_img.png}" alt="OPEN API 3D 아이콘" class="service-keyImg" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
@@ -129,8 +128,11 @@
|
||||
apiCards.forEach(function (card) {
|
||||
// Click handler
|
||||
card.addEventListener('click', function () {
|
||||
const href = this.getAttribute('data-href');
|
||||
let href = this.getAttribute('data-href');
|
||||
if (href) {
|
||||
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
|
||||
const val = selectedGroup ? selectedGroup : '-1';
|
||||
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
|
||||
window.location.href = href;
|
||||
}
|
||||
});
|
||||
@@ -139,8 +141,11 @@
|
||||
card.addEventListener('keypress', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const href = this.getAttribute('data-href');
|
||||
let href = this.getAttribute('data-href');
|
||||
if (href) {
|
||||
const selectedGroup = groupIdsInput ? groupIdsInput.value : '';
|
||||
const val = selectedGroup ? selectedGroup : '-1';
|
||||
href += (href.includes('?') ? '&' : '?') + 'selected=' + encodeURIComponent(val);
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-8
@@ -1,8 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
|
||||
|
||||
<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'}">
|
||||
|
||||
<div class="djb-comment-list-box">
|
||||
@@ -13,26 +14,23 @@
|
||||
|
||||
<div class="djb-comment-form-wrapper" th:if="${commentWritable}">
|
||||
<form id="djbCommentForm" class="djb-comment-form" onsubmit="return false;">
|
||||
<textarea id="djbCommentInput"
|
||||
class="djb-comment-input"
|
||||
rows="4"
|
||||
maxlength="2000"
|
||||
placeholder="내용"
|
||||
<textarea id="djbCommentInput" class="djb-comment-input" rows="4" maxlength="2000" placeholder="내용"
|
||||
required></textarea>
|
||||
<div class="djb-comment-form-actions">
|
||||
<label class="djb-comment-private-toggle">
|
||||
<input type="checkbox" id="djbCommentPrivate"> 비공개
|
||||
</label>
|
||||
<span class="djb-comment-char-counter" id="djbCommentCharCounter">0 / 2000</span>
|
||||
<button type="submit" class="djb-comment-submit">댓글달기</button>
|
||||
</div>
|
||||
<button type="submit" class="djb-comment-submit">댓글달기</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="djb-comment-form-wrapper djb-comment-readonly" th:unless="${commentWritable}">
|
||||
<p class="djb-comment-readonly-msg">종료된 문의에는 댓글을 작성할 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -3,34 +3,67 @@
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section layout:fragment="contentFragment">
|
||||
<div class="notice-detail-container">
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<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 -->
|
||||
<div class="notice-detail-header">
|
||||
<h2 class="notice-detail-title">
|
||||
<span th:text="${faq.faqQuestion}">FAQ 질문</span>
|
||||
</h2>
|
||||
<span class="notice-detail-date" th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.11.01</span>
|
||||
<p class="notice-detail-date" th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.11.01</p>
|
||||
<div class="notice-detail-title-wrapper">
|
||||
<span class="faq-question-q"
|
||||
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>
|
||||
|
||||
<!-- Attachment Section -->
|
||||
<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()}">
|
||||
<a th:href="'javascript:fn_downloadFile(\'' + ${fileDetail.fileId} + '\',\''+ ${fileDetail.fileSn} +'\')'" class="notice-attachment-link">
|
||||
<svg width="22" height="22" 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"/>
|
||||
<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">
|
||||
<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>
|
||||
<span class="attachment-label">첨부파일</span>
|
||||
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
||||
<span
|
||||
class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,12 +78,14 @@
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<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>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
@@ -65,7 +100,7 @@
|
||||
return textArea.value;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var element = document.getElementById('faqDetail');
|
||||
if (element) {
|
||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
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" alt="타이틀 배경">
|
||||
<h1>FAQ</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="signup-guide-v2">
|
||||
<!-- Hero Section -->
|
||||
@@ -26,14 +33,12 @@
|
||||
</section>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="service-main">
|
||||
<div class="container service-main">
|
||||
<!-- Sidebar -->
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('faq')}"></th:block>
|
||||
|
||||
<!-- Content -->
|
||||
<section class="service-content">
|
||||
<div class="notice-list-container"
|
||||
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
|
||||
<section class="service-content n-gap faq-container">
|
||||
|
||||
<!-- Search and Filter Controls -->
|
||||
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
|
||||
@@ -60,9 +65,10 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- FAQ Accordion -->
|
||||
<!-- FAQ Accordion (Click to Redirect) -->
|
||||
<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">
|
||||
<span class="faq-question-q"
|
||||
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">
|
||||
<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"
|
||||
stroke-linejoin="round" />
|
||||
stroke-linejoin="round" transform="rotate(-90 7 4)" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content editor-content" th:utext="${faq.faqAnswer}">
|
||||
답변 내용이 여기에 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,8 +105,6 @@
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,37 +123,6 @@
|
||||
document.faqForm.page.value = 1;
|
||||
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>
|
||||
</th:block>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<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;" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
@@ -32,7 +32,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="service-main">
|
||||
<div class="container service-main">
|
||||
<!-- Sidebar -->
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
문의 내용이 여기에 표시됩니다.
|
||||
</div>
|
||||
<!-- 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"
|
||||
download>
|
||||
첨부 이미지 다운로드
|
||||
@@ -127,7 +128,7 @@
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
var successMsg = [[${success}]];
|
||||
var successMsg = [[${ success }]];
|
||||
if (successMsg) {
|
||||
customPopups.showAlert(successMsg);
|
||||
}
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<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>
|
||||
</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">
|
||||
<!-- Hero Section -->
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<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;" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
@@ -25,7 +25,8 @@
|
||||
<span class="service-hero__badge-text">Q&A 게시판</span>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
@@ -53,24 +54,15 @@
|
||||
<!-- Subject Field -->
|
||||
<div class="form-group">
|
||||
<label class="djb-label" for="inquirySubject">제목 <span class="required">*</span></label>
|
||||
<input type="text"
|
||||
id="inquirySubject"
|
||||
th:field="*{inquirySubject}"
|
||||
class="djb-input"
|
||||
placeholder="제목을 입력하세요"
|
||||
required
|
||||
maxlength="200">
|
||||
<input type="text" id="inquirySubject" th:field="*{inquirySubject}" class="djb-input"
|
||||
placeholder="제목을 입력하세요" required maxlength="200">
|
||||
</div>
|
||||
|
||||
<!-- Content Field -->
|
||||
<div class="form-group">
|
||||
<label class="djb-label" for="inquiryDetail">내용 <span class="required">*</span></label>
|
||||
<textarea id="inquiryDetail"
|
||||
th:field="*{inquiryDetail}"
|
||||
class="djb-textarea"
|
||||
placeholder="내용을 입력하세요"
|
||||
required
|
||||
maxlength="2000"></textarea>
|
||||
<textarea id="inquiryDetail" th:field="*{inquiryDetail}" class="djb-textarea" placeholder="내용을 입력하세요"
|
||||
required maxlength="2000"></textarea>
|
||||
<div class="djb-char-counter-wrapper">
|
||||
<span class="char-counter">0 / 2000</span>
|
||||
</div>
|
||||
@@ -111,13 +103,13 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:if="${error}" th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
const errorMsg = [[${error}]];
|
||||
const errorMsg = [[${ error }]];
|
||||
customPopups.showAlert(errorMsg);
|
||||
});
|
||||
</script>
|
||||
@@ -133,7 +125,7 @@
|
||||
const maxLength = inquiryDetailTextarea.getAttribute('maxlength');
|
||||
charCounter.textContent = currentLength + ' / ' + maxLength;
|
||||
|
||||
inquiryDetailTextarea.addEventListener('input', function() {
|
||||
inquiryDetailTextarea.addEventListener('input', function () {
|
||||
const currentLength = this.value.length;
|
||||
const maxLength = this.getAttribute('maxlength');
|
||||
charCounter.textContent = currentLength + ' / ' + maxLength;
|
||||
@@ -182,4 +174,5 @@
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
</html>
|
||||
@@ -3,20 +3,26 @@
|
||||
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" alt="타이틀 배경">
|
||||
<h1>Q&A</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="signup-guide-v2">
|
||||
<!-- Hero Section -->
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<!-- Used notice icon as a placeholder, can be updated if inquiry icon exists -->
|
||||
<img th:src="@{/img/keyimage/notice_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;" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
<span class="service-hero__badge-dot"></span>
|
||||
<span class="service-hero__badge-text">공지사항 목록</span>
|
||||
<span class="service-hero__badge-text">Q&A 게시판</span>
|
||||
</div>
|
||||
<h1 class="service-hero__title">Q&A</h1>
|
||||
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
|
||||
@@ -26,12 +32,12 @@
|
||||
</section>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="service-main">
|
||||
<div class="container service-main">
|
||||
<!-- Sidebar -->
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('qna')}"></th:block>
|
||||
|
||||
<!-- Content -->
|
||||
<section class="service-content">
|
||||
<section class="service-content n-gap">
|
||||
<section class="user-management-container inquiry-list-container"
|
||||
style="padding: 0; min-height: auto; margin: 0; background: transparent; box-shadow: none;">
|
||||
|
||||
@@ -43,8 +49,10 @@
|
||||
<div class="table-controls">
|
||||
<h2 class="total-count">
|
||||
총 <strong th:text="${page.totalElements}">0</strong>건
|
||||
<span class="inquiry-visibility-notice" th:if="${visibilityCeiling != null}">
|
||||
공개범위 설정: <span th:text="${visibilityCeiling.label()}">법인공개</span>
|
||||
<span class="inquiry-visibility-notice" th:if="${visibilityCeiling != null}"
|
||||
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>
|
||||
</h2>
|
||||
|
||||
@@ -69,7 +77,7 @@
|
||||
<div class="board-table-header">
|
||||
<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="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: 80px;">조회수</div>
|
||||
<div class="header-cell" style="width: 120px;">등록일</div>
|
||||
@@ -81,41 +89,50 @@
|
||||
th:classappend="${inquiry.privatePlaceholder} ? ' board-table-row--private'"
|
||||
th:onclick="${inquiry.privatePlaceholder} ? null : ('location.href=\'' + @{/inquiry/detail(id=${inquiry.id})} + '\'')"
|
||||
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>
|
||||
<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})}"
|
||||
class="notice-title-link"
|
||||
th:classappend="${inquiry.privatePlaceholder} ? ' notice-title-link--disabled'">
|
||||
<span class="notice-number"
|
||||
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" />
|
||||
<path d="M7 10V7a5 5 0 0 1 10 0v3" stroke="currentColor" stroke-width="2" fill="none" />
|
||||
</svg>
|
||||
</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"
|
||||
th:if="${commentCounts != null and commentCounts.get(inquiry.id) != null and commentCounts.get(inquiry.id) > 0}"
|
||||
th:text="|[${commentCounts.get(inquiry.id)}]|">[3]</span>
|
||||
</a>
|
||||
</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-org"
|
||||
th:if="${inquiry.inquirerOrgName != null and !inquiry.inquirerOrgName.isEmpty()}"
|
||||
th:text="|(${inquiry.inquirerOrgName})|">(법인명)</span>
|
||||
</div>
|
||||
<div class="row-cell" style="width: 120px;" data-label="처리상태">
|
||||
<span class="inquiry-status-badge"
|
||||
th:classappend="${T(com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus).badgeClass(inquiry.inquiryStatus)}"
|
||||
<div class="row-cell row-cell--status" data-label="처리상태">
|
||||
<!-- completed for RESPONDED, closed for CLOSED, pending for PENDING, maintenance for REVIEWING -->
|
||||
<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)}">
|
||||
답변대기
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-cell" style="width: 80px;" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
||||
<div class="row-cell" style="width: 120px;" data-label="등록일"
|
||||
<div class="row-cell row-cell--views" data-label="조회수" th:text="${inquiry.viewCount}">0</div>
|
||||
<div class="row-cell row-cell--date" data-label="등록일"
|
||||
th:text="${#temporals.format(inquiry.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,8 +150,8 @@
|
||||
|
||||
<!-- New Inquiry Button -->
|
||||
<div style="display: flex; justify-content: center; margin-top: 40px;">
|
||||
<a th:href="@{/inquiry/new}" class="btn btn-primary"
|
||||
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;">
|
||||
<a th:href="@{/inquiry/new}" class="btn-action-primary btn-notice-list"
|
||||
style="display: inline-flex; align-items: center; justify-content: center; text-decoration: none;">
|
||||
문의하기
|
||||
</a>
|
||||
</div>
|
||||
@@ -150,7 +167,7 @@
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
$(document).ready(function () {
|
||||
var successMsg = [[${success}]];
|
||||
var successMsg = [[${ success }]];
|
||||
if (successMsg) {
|
||||
customPopups.showAlert(successMsg);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||
|
||||
<body>
|
||||
<section layout:fragment="title">
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" class="title-image" alt="타이틀 배경">
|
||||
<h1>공지사항</h1>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<th:block layout:fragment="contentFragment">
|
||||
<div class="signup-guide-v2">
|
||||
<!-- Hero Section -->
|
||||
<section class="service-hero">
|
||||
@@ -25,26 +25,31 @@
|
||||
<span class="service-hero__badge-text">고객지원</span>
|
||||
</div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="service-main">
|
||||
<div class="container service-main">
|
||||
<!-- Sidebar -->
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
||||
|
||||
<!-- 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 -->
|
||||
<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">
|
||||
<span class="notice-type-badge notice-type-badge--incident" th:if="${portalNotice.noticeType == '3'}">장애</span>
|
||||
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${portalNotice.noticeType == '2'}">점검</span>
|
||||
<span class="notice-type-badge lg notice-type-badge--incident"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,26 +58,57 @@
|
||||
<div class="notice-detail-incident-info" th:if="${portalNotice.incidentOrMaintenance}">
|
||||
<table class="detail-table">
|
||||
<colgroup>
|
||||
<col style="width: 160px;"><col><col style="width: 160px;"><col>
|
||||
<col style="width: 160px;">
|
||||
<col>
|
||||
<col style="width: 160px;">
|
||||
<col>
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<td>
|
||||
<th:block th:if="${portalNotice.affectedApis != null and !portalNotice.affectedApis.isEmpty()}">
|
||||
<span th:each="api, iterStat : ${portalNotice.affectedApis}">
|
||||
<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>
|
||||
<td colspan="3">
|
||||
<div class="affected-apis-wrapper">
|
||||
<!-- First 3 APIs (Always visible) -->
|
||||
<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>
|
||||
</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>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -83,12 +119,16 @@
|
||||
<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-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">
|
||||
<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>
|
||||
<span class="attachment-label">첨부파일</span>
|
||||
<span class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
||||
<span
|
||||
class="attachment-filename">[[${fileDetail.originalFileName}]].[[${fileDetail.fileExtension}]]</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,14 +143,15 @@
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -120,13 +161,31 @@
|
||||
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) {
|
||||
var textArea = document.createElement('textarea');
|
||||
textArea.innerHTML = text;
|
||||
return textArea.value;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var element = document.getElementById('noticeDetail');
|
||||
if (element) {
|
||||
element.innerHTML = decodeHTMLEntities(element.innerHTML);
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
<section class="service-hero">
|
||||
<div class="service-hero__inner">
|
||||
<div class="service-hero__icon-wrapper">
|
||||
<img th:src="@{/img/keyimage/notice_img.svg}" alt="공지사항 아이콘"
|
||||
style="width: 100%; height: 100%; object-fit: contain;" />
|
||||
<img th:src="@{/img/keyimage/notice_img.png}" alt="공지사항 아이콘" class="service-keyImg" />
|
||||
</div>
|
||||
<div class="service-hero__content">
|
||||
<div class="service-hero__badge">
|
||||
@@ -32,7 +31,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="service-main">
|
||||
<div class="container service-main">
|
||||
<!-- Sidebar -->
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('notice')}"></th:block>
|
||||
|
||||
@@ -86,9 +85,15 @@
|
||||
<span class="notice-number"
|
||||
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--incident" th:if="${notice.noticeType == '3'}">장애</span>
|
||||
<span class="notice-type-badge notice-type-badge--maintenance" th:if="${notice.noticeType == '2'}">점검</span>
|
||||
<span th:text="${notice.noticeSubject}">공지사항 제목</span>
|
||||
<span class="notice-type-badge notice-type-badge--incident"
|
||||
th:if="${notice.noticeType == '3'}">장애</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()}">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
|
||||
@@ -31,29 +31,27 @@
|
||||
</section>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="service-main">
|
||||
<div class="container service-main">
|
||||
<!-- Sidebar -->
|
||||
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('feedback')}"></th:block>
|
||||
|
||||
<!-- Content -->
|
||||
<section class="service-content">
|
||||
<div class="djb-board-write-container">
|
||||
|
||||
<!-- Title Bar -->
|
||||
<div class="djb-board-write-header">
|
||||
<h2 class="title">피드백 / 개선 요청</h2>
|
||||
<p class="subtitle">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을 환영합니다.</p>
|
||||
<!-- 상단: 최근 작성한 글 목록 -->
|
||||
<div class="recent-apps">
|
||||
<div class="recent-apps-header-group">
|
||||
<p class="recent-apps-title" style="font-size: 24px; font-weight: 700; color: #000; margin-bottom: 8px;">
|
||||
피드백 / 개선 요청</p>
|
||||
<p class="recent-apps-subtitle" style="font-size: 14px; color: #7f8a95; margin-bottom: 24px;">최근 최대 3개의 나의
|
||||
피드백/ 개선 요청을 보여줍니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 내가 작성한 최근 글(최대 3건) 아코디언 -->
|
||||
<div class="recent-apps" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
|
||||
<p class="recent-apps-title">내가 작성한 최근 글</p>
|
||||
<ul class="recent-apps-list">
|
||||
<ul class="recent-apps-list" th:if="${recentApplications != null and !recentApplications.isEmpty()}">
|
||||
<li class="recent-apps-item" th:each="item : ${recentApplications}">
|
||||
<button type="button" class="recent-apps-head">
|
||||
<span class="recent-apps-subject" th:text="${item.bizSubject}">제목</span>
|
||||
<span class="recent-apps-date"
|
||||
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"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
@@ -64,6 +62,22 @@
|
||||
</div>
|
||||
</li>
|
||||
</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>
|
||||
|
||||
<!-- Form Content -->
|
||||
@@ -87,6 +101,27 @@
|
||||
</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 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
|
||||
document.getElementById('bizSubject').focus();
|
||||
});
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
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) {
|
||||
var loginId = form.id.value;
|
||||
@@ -157,26 +175,8 @@
|
||||
|
||||
$(function () {
|
||||
|
||||
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
|
||||
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
|
||||
// 단, 입력 중에는 이탈 금지 → 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();
|
||||
// 세션 만료 대응은 head의 익명 keepalive ping(/api/session/ping)이 담당하고,
|
||||
// 제출 직전 refreshCsrfAndThen()이 CSRF 토큰 재발급 안전망 역할을 한다.
|
||||
|
||||
var successMsg = [[${success}]];
|
||||
console.log("Success message:", successMsg);
|
||||
@@ -225,7 +225,9 @@
|
||||
$('#loginLoading').removeClass('active');
|
||||
customPopups.showAlert('[[#{login.passLengthShort}]]');
|
||||
} else {
|
||||
refreshCsrfAndThen(form, function () {
|
||||
checkDuplicateAndLogin(form);
|
||||
});
|
||||
}
|
||||
}
|
||||
form.classList.add('was-validated');
|
||||
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user